From daf8229f24560c5eac1363350cdd0c151e742497 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Sun, 27 Sep 2026 23:41:11 +0800 Subject: [PATCH 1/4] fix(desktop,ui): re-key the plate's live quotes and drop stale footer/dead keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups (#5274 review): - prepareRevisionSend re-keyed the edit-start snapshot, so quotes the user removed or re-annotated during the edit reappeared on the branch and could be sent. The re-key now lands the plate's current quotes, and the send reads them live (the before-send snapshot retires — the re-key covers its case). - Drops the stale-base footer copy block and a dead regenerate key: no production code on current main references the old footer shape that survived in this branch's base. The no-op gate keeps counting the model-facing comment. Generated-by: GLM-5.3-Flash (ZCode) --- apps/desktop/renderer-architecture.json | 34 +- .../scripts/check-renderer-architecture.mjs | 25 +- .../check-renderer-architecture.test.mjs | 79 +++ .../app-shell-revision-actions.test.ts | 68 +- .../renderer/app-shell-revision-actions.ts | 435 +++--------- apps/desktop/src/renderer/app-shell.tsx | 34 +- .../contracts/conversation-copy.ts | 21 +- .../controller/use-composer-quotes.ts | 14 +- .../chat-turn-answer-identity.test.tsx | 79 +++ .../__tests__/revision-staged-context.test.ts | 459 +++++++++++++ packages/ui/src/chat-turn.tsx | 23 +- packages/ui/src/conversation-copy.ts | 14 +- packages/ui/src/index.ts | 1 + packages/ui/src/revision-staged-context.ts | 633 ++++++++++++++++++ packages/ui/src/use-composer-attachments.ts | 4 + 15 files changed, 1493 insertions(+), 430 deletions(-) create mode 100644 packages/ui/src/__tests__/revision-staged-context.test.ts create mode 100644 packages/ui/src/revision-staged-context.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index ffd72e00da..70273eae1e 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -293,7 +293,7 @@ "legacyAppShell": { "files": { "src/renderer/app-shell-chat-actions.ts": { - "importDeclarations": 7, + "importDeclarations": 6, "bridgePaths": { "window.maka.newTasks.create": 1, "window.maka.sessions.remove": 1, @@ -319,11 +319,11 @@ "@maka/core/session-name": 1, "@maka/ui": 1 }, - "importSpecifiers": 10, + "importSpecifiers": 9, "nonTriviaTokens": 3525 }, "src/renderer/app-shell-chrome-actions.tsx": { - "importDeclarations": 1, + "importDeclarations": 0, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -338,7 +338,7 @@ "./shell/window-titlebar": 1, "@maka/ui": 1 }, - "importSpecifiers": 1, + "importSpecifiers": 0, "nonTriviaTokens": 122 }, "src/renderer/app-shell-command-actions.ts": { @@ -490,7 +490,7 @@ "nonTriviaTokens": 3507 }, "src/renderer/app-shell-overlays.tsx": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": {}, "environmentCapabilities": { "window.addEventListener": 1, @@ -516,7 +516,7 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 8, + "importSpecifiers": 7, "nonTriviaTokens": 858 }, "src/renderer/app-shell-project-actions.ts": { @@ -550,7 +550,7 @@ "nonTriviaTokens": 2157 }, "src/renderer/app-shell-revision-actions.ts": { - "importDeclarations": 3, + "importDeclarations": 2, "bridgePaths": { "window.maka.sessions.abandonSessionCopy": 2, "window.maka.sessions.reviseBeforeTurn": 1 @@ -567,13 +567,13 @@ "./locales/shell-copy.js": 1, "./session-copy-attempt.js": 1, "./session-workspace-errors.js": 1, - "@maka/core/session": 1 + "@maka/ui": 1 }, - "importSpecifiers": 7, - "nonTriviaTokens": 2028 + "importSpecifiers": 3, + "nonTriviaTokens": 482 }, "src/renderer/app-shell-session-events.ts": { - "importDeclarations": 2, + "importDeclarations": 1, "bridgePaths": {}, "environmentCapabilities": { "window.setTimeout": 1 @@ -592,7 +592,7 @@ "./model-connection-errors.js": 1, "@maka/ui": 1 }, - "importSpecifiers": 7, + "importSpecifiers": 1, "nonTriviaTokens": 2590 }, "src/renderer/app-shell-session-ui-state.ts": { @@ -650,7 +650,7 @@ "nonTriviaTokens": 571 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 56, + "importDeclarations": 55, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -785,11 +785,11 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 92, - "nonTriviaTokens": 12152 + "importSpecifiers": 80, + "nonTriviaTokens": 12115 }, "src/renderer/use-app-shell-session-list.ts": { - "importDeclarations": 4, + "importDeclarations": 3, "bridgePaths": { "window.maka.sessions.list": 1 }, @@ -814,7 +814,7 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 5, + "importSpecifiers": 4, "nonTriviaTokens": 481 }, "src/renderer/use-app-shell-session-ui-reads.ts": { diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index b767889894..30488944ba 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -108,6 +108,10 @@ const RENDERER_VITE_CONFIG = 'vite.config.ts'; const RENDERER_BUILD_SCRIPT = 'vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs'; const DESKTOP_SELF_PREFIX = '@maka/desktop/'; +// The package renderer ownership is migrating into. Shell debt is defined to +// shrink by moving onto it, so depending on the destination is the opposite +// of debt and its edges are sanctioned for shell importers. +const MIGRATION_TARGET_PACKAGE = '@maka/ui'; const CAPABILITY_DEBT_METRICS = [ 'actionFactories', 'bridgePaths', @@ -3099,9 +3103,28 @@ function withoutSanctionedDependencies(desktopRoot, section, importerPath, depen return filtered; } +function isMigrationTargetPackageSpecifier(dependency) { + const specifier = dependency.split(/[?#]/u, 1)[0]; + return ( + specifier === MIGRATION_TARGET_PACKAGE || + specifier.startsWith(`${MIGRATION_TARGET_PACKAGE}/`) + ); +} + function isSanctionedDependencyTarget(desktopRoot, section, importerPath, dependency) { const target = resolveDependency(desktopRoot, resolve(desktopRoot, importerPath), dependency); - if (!target) return false; + if (!target) { + // Bare package specifiers resolve to nothing inside the desktop tree. + // The migration destination is the one free among them: a shell importer + // depending on @maka/ui sheds ownership the shell is defined to lose, + // the same way validated copy catalogs take bare-package imports for + // free. Root entries stay fully priced: they are meant to become thin + // mounts. + return ( + (section === 'legacyAppShell' || section === 'legacyAppShellClosure') && + isMigrationTargetPackageSpecifier(dependency) + ); + } const targetRelative = normalizePath(relative(desktopRoot, target)); if (isValidatedCopyCatalog(desktopRoot, targetRelative)) return true; // Root entries are meant to become thin mounts; only catalogs are free for them. diff --git a/apps/desktop/scripts/check-renderer-architecture.test.mjs b/apps/desktop/scripts/check-renderer-architecture.test.mjs index 21bd41a28d..8a11f82105 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -3457,6 +3457,85 @@ describe('renderer architecture base-tree derivation (git fixtures)', () => { }); }); + it('sanctions a shell file migrating onto @maka/ui relative to the derived base tree', async () => { + // Only files named app-shell* enter the legacyAppShell ledger section, + // and every section file needs an ownership entry to pass validation. + const LEGACY_SHELL_WIDGET = 'src/renderer/app-shell-widget.ts'; + const seed = architectureConfig({ + rootDebt: { [RENDERER_ENTRY_PATH]: emptyDebt() }, + ownership: [ + { + capability: 'fixture-root', + targetZone: 'bootstrap', + legacyPaths: [RENDERER_ENTRY_PATH], + }, + { + capability: 'fixture-shell-widget', + targetZone: 'shell', + legacyPaths: [LEGACY_SHELL_WIDGET], + }, + ], + }); + await withGitFixture(async (fixture) => { + // The base file carries more debt than the head ever will: the migration + // edge must be the only delta under test, so every priced metric shrinks. + await fixture.writeFiles({ + [LEGACY_SHELL_WIDGET]: ` + import { existsSync } from 'node:fs'; + import { join } from 'node:path'; + + const widgetSlots = ['header', 'body', 'footer']; + const resolveWidgetPath = (root: string, name: string) => + existsSync(join(root, name)) ? join(root, name) : root; + + export const legacyWidget = { + name: 'legacy-widget', + slots: widgetSlots, + resolve: resolveWidgetPath, + }; + `, + }); + await fixture.writeLedger(seed); + const base = fixture.commit('base'); + + await fixture.writeFiles({ + [LEGACY_SHELL_WIDGET]: ` + import { revisionStage } from '@maka/ui'; + + export const legacyWidget = { name: 'legacy-widget', stage: revisionStage }; + `, + }); + await fixture.writeLedger(seed); + fixture.commit('migrate a legacy shell file onto @maka/ui'); + + for (const args of [['--base', base], ['--base', base, '--strict-base']]) { + assertPassed(fixture.runChecker(args), base, args.join(' ')); + } + }); + }); + + it('keeps pricing an @maka/ui edge gained by a root debt entry under --strict-base', async () => { + await withGitFixture(async (fixture) => { + await fixture.writeLedger(); + const base = fixture.commit('base'); + await fixture.writeFiles({ + [RENDERER_ENTRY_PATH]: ` + import { revisionStage } from '@maka/ui'; + export const main = revisionStage; + `, + }); + await fixture.writeLedger(); + fixture.commit('point the root entry at @maka/ui'); + + const result = fixture.runChecker(['--base', base, '--strict-base']); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /^- src\/renderer\/main\.tsx: new dependency debt @maka\/ui/mu, + ); + }); + }); + it('does not wedge on a base ledger that under-reports its own tree (#4250)', async () => { await withGitFixture(async (fixture) => { // The base ledger only knows one legacy file while the base *tree* diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts index 1a6eb60cbf..8ab2a8ad99 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts @@ -48,6 +48,12 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean let composerText = ''; let selectionRevision = 0; const activeIdRef: { current: string | undefined } = { current: SESSION_1 }; + const staged: { + quotes: unknown[]; + restoredQuotes: unknown[][]; + restoredAttachments: unknown[][]; + clearedKeys: string[]; + } = { quotes: [], restoredQuotes: [], restoredAttachments: [], clearedKeys: [] }; const revisionDraftRef: { current: unknown } = { current: null }; const actions = createAppShellRevisionActions({ uiLocale: 'en' as never, @@ -71,6 +77,21 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean }, messages: input.messages, hasPendingAttachments: () => false, + stagedContext: () => ({ + quotes: staged.quotes, + attachments: [], + restoreQuotes: (_ownerKey: string, quotes: unknown[]) => { + staged.restoredQuotes.push(quotes); + staged.quotes.push(...quotes); + }, + restoreAttachments: (_ownerKey: string, attachments: unknown[]) => { + staged.restoredAttachments.push(attachments); + }, + clearQuotes: (ownerKey: string) => { + staged.clearedKeys.push(ownerKey); + staged.quotes.length = 0; + }, + }), openSessionInChat: (sessionId: string) => { selectionRevision += 1; activeIdRef.current = sessionId; @@ -91,10 +112,18 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean } as never); return Object.assign(actions, { drafts, + staged, errors, infos, activeIdRef, - composerState: { get text(): string { return composerText; } }, + composerState: { + get text(): string { + return composerText; + }, + get attachments(): unknown[] { + return staged.restoredAttachments.at(-1) ?? []; + }, + }, }); } @@ -134,7 +163,11 @@ describe('app-shell revision actions with structured context (#5109)', () => { assert.equal(h.composerState.text, 'plain follow-up'); }); - it('rejects a source message that itself carries attachments', () => { + it('refuses editing a message that carries attachments (#5274 review)', () => { + // Attachment ownership does not follow a revision copy — the copied + // transcript stops before the selected turn, so no target-owned refs + // exist client-side to restage. The edit refuses rather than silently + // dropping the files. const h = createActions({ messages: [ userMessage('turn-1', 'with image', { @@ -153,7 +186,30 @@ describe('app-shell revision actions with structured context (#5109)', () => { h.beginEditUserMessage('turn-1'); - assert.equal(h.drafts.at(-1), undefined, 'attachment-bearing sources stay explicitly rejected'); + assert.equal( + h.drafts.at(-1), + undefined, + 'a revision copy excludes the revised turn, so no target-owned attachment rewrite exists to restage', + ); + assert.equal(h.composerState.text, '', 'the composer stays untouched'); + }); + + it('stages a source message quotes into the composer', () => { + const quote = { text: 'a large pasted excerpt', sourceTurnId: 'turn-0' }; + const h = createActions({ + messages: [userMessage('turn-1', 'explain this', { quotes: [quote] })], + }); + + h.beginEditUserMessage('turn-1'); + + const draft = h.drafts.at(-1) as { originalQuotes?: unknown[] } | undefined; + assert.ok(draft, 'a quote-carrying source message is editable now'); + assert.deepEqual(draft?.originalQuotes, [quote]); + assert.deepEqual( + h.staged.restoredQuotes.at(-1), + [quote], + 'the source quotes stage into the composer verbatim', + ); }); }); @@ -263,6 +319,12 @@ describe('revision draft lifecycle over a prepared send', () => { }, messages: [userMessage('turn-1', 'original message')], hasPendingAttachments: () => false, + stagedContext: () => ({ + quotes: [], + attachments: [], + restoreQuotes: (_ownerKey: string, _quotes: unknown[]) => {}, + clearQuotes: (_ownerKey: string) => {}, + }), openSessionInChat: (sessionId: string) => { selectionRevision += 1; activeIdRef.current = sessionId; diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 46e25f84f1..d6975ee991 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -17,55 +17,42 @@ * under the License. */ -import type { StoredMessage } from '@maka/core/session'; -import type { UiLocale } from '@maka/core/ui-locale'; -import type { DesktopSessionSummary } from '../preload/bridge-contract.js'; -import { userFacingText } from '@maka/core/session'; -import type { ComposerHandle } from '@maka/ui'; +import * as sessionCopyAttempts from './session-copy-attempt.js'; import { getDesktopConversationCopy } from './application/contracts/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; +import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast } from './session-workspace-errors.js'; import { - isSessionWorkspaceUnavailableError, - showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; -import { - acquireSessionCopyAttempt, - abandonSessionCopyAttempt, - completeSessionCopyAttempt, - startSessionCopyAttempt, - type SessionCopyAttemptPhase, - type SessionCopyAttemptKey, -} from './session-copy-attempt.js'; - -type RefBox = { current: T }; - -type ToastApi = { - info(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string }, - ): void; -}; + createRevisionActions, + createTurnRevisionCopyHelpers, + type RevisionActionsEnv, + type TurnRevisionDraftBase, +} from '@maka/ui'; -/** Active edit-and-resend draft owned by the desktop shell. */ -export type TurnRevisionDraft = { - sourceSessionId: string; - sourceTurnId: string; - copyId: string; - copyPhase: SessionCopyAttemptPhase; - /** Active owner of the draft. Changes to the branch child after prepare. */ - draftSessionId: string; - originalText: string; - /** Composer text that was present before edit began; restored on cancel. - * Staged Skills ride along inside it as `/skill:` chips. */ - previousComposerText: string; +/** + * The desktop revision draft: the shared staged-context source bound to the + * shell's copy-attempt phases. + */ +export type TurnRevisionDraft = TurnRevisionDraftBase; + +type DesktopRevisionActionsDeps = Omit< + RevisionActionsEnv, + | 'copy' + | 'reviseBeforeTurn' + | 'abandonSessionCopy' + | 'reportSessionWorkspaceUnavailable' + | 'localizedShellErrorMessage' + | 'acquireCopyAttempt' + | 'startCopyAttempt' + | 'abandonCopyAttempt' + | 'completeCopyAttempt' + | 'commitRevisionDraft' +> & { + /** The shell's draft state is bound to the concrete desktop draft type. */ + commitRevisionDraft(draft: TurnRevisionDraft | null): void; }; export interface AppShellRevisionActions { beginEditUserMessage(turnId: string): void; - /** Lazily create the before-turn branch immediately before normal send. */ prepareRevisionSend(text: string): Promise; cancelRevisionDraft(): Promise; } @@ -79,326 +66,56 @@ export interface AppShellRevisionActions { * * If normal send fails after a revision was prepared, that version remains * active with the edited text and a second send retries there instead of - * creating another version. Attachment-bearing source messages are rejected - * until the revision draft can carry their target-owned references (#5109); - * retained historical attachments are fine — the Host revision copier - * rewrites their Session refs losslessly. + * creating another version. The lifecycle itself lives in `@maka/ui`; this + * assembler injects the bridge, the locale catalog, and the copy-attempt + * tracker. */ -export function createAppShellRevisionActions(deps: { - uiLocale: UiLocale; - activeIdRef: RefBox; - captureSelection(): () => boolean; - composerRef: RefBox; - messages: readonly StoredMessage[]; - hasPendingAttachments: () => boolean; - openSessionInChat: (sessionId: string, turnId?: string) => void; - refreshSessions: () => Promise; - commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; - revisionDraftRef: RefBox; - toastApi: ToastApi; -}): AppShellRevisionActions { - const { - uiLocale, - activeIdRef, - captureSelection, - composerRef, - messages, - hasPendingAttachments, - openSessionInChat, - refreshSessions, - commitRevisionDraft, - revisionDraftRef, - toastApi, - } = deps; - const copy = getDesktopConversationCopy(uiLocale).actions; - - function revisionCopyKey(sourceSessionId: string, sourceTurnId: string): SessionCopyAttemptKey { - return { - scope: `edit-and-resend:${sourceTurnId}`, - kind: 'revision', - sourceSessionId, - }; - } - - function beginEditUserMessage(turnId: string): void { - const sessionId = activeIdRef.current; - if (!sessionId) return; - const existing = revisionDraftRef.current; - if (existing) { - if (existing.draftSessionId === sessionId && existing.sourceTurnId === turnId) { - composerRef.current?.focus(); - } else { - toastApi.info(copy.revisionUnavailableTitle, copy.revisionAlreadyActive); - } - return; - } - if (hasPendingAttachments()) { - toastApi.info(copy.revisionUnavailableTitle, copy.revisionDraftAttachmentConflict); - return; - } - const userMessage = messages.find( - (message): message is Extract => - message.type === 'user' && message.turnId === turnId, - ); - if (!userMessage) { - toastApi.error( - copy.operationFailedTitle, - copy.operationFailedFallback, - undefined, - { sessionId }, - ); - return; - } - - if (userMessage.attachments && userMessage.attachments.length > 0) { - // Attachment references are session-owned and their rewritten targets - // are not exposed to clients yet, so those stay explicitly rejected. - // Quotes never reach this point: chat-turn's editDisabled gate excludes them. - toastApi.info(copy.revisionUnavailableTitle, copy.revisionAttachmentsUnsupported); - return; - } - if (userMessage.displayText !== undefined && userMessage.displayText !== userMessage.text) { - toastApi.info(copy.revisionUnavailableTitle, copy.revisionTransformedTextUnsupported); - return; - } - - const prompt = userFacingText(userMessage); - const copyAttempt = acquireSessionCopyAttempt( - revisionCopyKey(sessionId, turnId), - turnId, - ); - commitRevisionDraft({ - sourceSessionId: sessionId, - sourceTurnId: copyAttempt.sourceTurnId, - copyId: copyAttempt.copyId, - copyPhase: copyAttempt.phase, - draftSessionId: sessionId, - originalText: prompt, - previousComposerText: composerRef.current?.getText() ?? '', - }); - composerRef.current?.setText(prompt); - composerRef.current?.focus(); - toastApi.info(copy.revisionStartedTitle, copy.revisionStartedDescription); - } - - async function rollbackPreparedRevision( - draft: TurnRevisionDraft, - revisionSessionId: string, - text: string, - selectionIsCurrent: () => boolean, - ): Promise { - composerRef.current?.clearDraft(revisionSessionId); - const current = revisionDraftRef.current; - if (selectionIsCurrent() && activeIdRef.current === revisionSessionId) { - openSessionInChat(draft.sourceSessionId); - selectionIsCurrent = captureSelection(); - } - const abandonment = await abandonRevisionCopy(draft); - const abandoningDraft = abandonment.draft; - let restored: TurnRevisionDraft | undefined; - if (current?.copyId === draft.copyId && revisionDraftRef.current === abandoningDraft) { - if (abandonment.acknowledged) { - const nextAttempt = acquireSessionCopyAttempt( - revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), - draft.sourceTurnId, - ); - restored = { - ...draft, - sourceTurnId: nextAttempt.sourceTurnId, - copyId: nextAttempt.copyId, - copyPhase: nextAttempt.phase, - draftSessionId: draft.sourceSessionId, - }; - } else { - restored = { ...abandoningDraft, draftSessionId: draft.sourceSessionId }; - } - composerRef.current?.setDraft(draft.sourceSessionId, text); - commitRevisionDraft(restored); - } - if (selectionIsCurrent() && activeIdRef.current === draft.sourceSessionId && revisionDraftRef.current === restored) { - composerRef.current?.setText(text); - composerRef.current?.focus(); - } - await refreshSessions().catch(() => []); - } - - async function abandonRevisionCopy( - draft: TurnRevisionDraft, - ): Promise<{ acknowledged: boolean; draft: TurnRevisionDraft }> { - const tracked = abandonSessionCopyAttempt( - revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), - draft.copyId, - ); - const current = revisionDraftRef.current; - const trackedDraft = current?.copyId === draft.copyId ? current : draft; - const abandoningDraft = - tracked && trackedDraft.copyPhase !== 'abandoning' - ? { ...trackedDraft, copyPhase: 'abandoning' as const } - : trackedDraft; - if (revisionDraftRef.current === trackedDraft && abandoningDraft !== trackedDraft) { - commitRevisionDraft(abandoningDraft); - } - try { - // Main acknowledges only after the cleanup intent is durable; physical - // removal may finish after this renderer has closed the draft. - await window.maka.sessions.abandonSessionCopy(draft.sourceSessionId, draft.copyId); - completeTurnRevisionCopyAttempt(draft); - return { acknowledged: true, draft: abandoningDraft }; - } catch { - // An ambiguous cleanup acknowledgement stays in `abandoning`; this - // target may only retry cleanup and can never be copied into again. - return { acknowledged: false, draft: abandoningDraft }; - } - } - - async function prepareRevisionSend(text: string): Promise { - let selectionIsCurrent = captureSelection(); - let draft = revisionDraftRef.current; - if (!draft || activeIdRef.current !== draft.draftSessionId) return false; - // A previous attempt already prepared the version; retry normal send there. - if (draft.draftSessionId !== draft.sourceSessionId) return true; - - if (draft.copyPhase === 'abandoning') { - const abandonment = await abandonRevisionCopy(draft); - if ( - !selectionIsCurrent() || !abandonment.acknowledged || - revisionDraftRef.current !== abandonment.draft || - activeIdRef.current !== draft.sourceSessionId - ) { - return false; - } - const nextAttempt = acquireSessionCopyAttempt( - revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), - draft.sourceTurnId, - ); - draft = { - ...draft, - copyId: nextAttempt.copyId, - copyPhase: nextAttempt.phase, - }; - commitRevisionDraft(draft); - } - - const startedDraft = - draft.copyPhase === 'started' ? draft : { ...draft, copyPhase: 'started' as const }; - if (startedDraft !== draft) { - if ( - !startSessionCopyAttempt( - revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), - draft.copyId, - ) - ) { - return false; - } - commitRevisionDraft(startedDraft); - } - const sourceSessionId = startedDraft.sourceSessionId; - let preparedSessionId: string | undefined; - try { - const newSession = await window.maka.sessions.reviseBeforeTurn(sourceSessionId, { - sourceTurnId: startedDraft.sourceTurnId, - copyId: startedDraft.copyId, - }); - preparedSessionId = newSession.id; - if (!selectionIsCurrent() || revisionDraftRef.current !== startedDraft) { - await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); - return false; - } - - const prepared = { ...startedDraft, draftSessionId: newSession.id }; - composerRef.current?.setDraft(newSession.id, text); - commitRevisionDraft(prepared); - openSessionInChat(newSession.id); - selectionIsCurrent = captureSelection(); - await refreshSessions(); - if (!selectionIsCurrent() || revisionDraftRef.current !== prepared) { - await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); - return false; - } - composerRef.current?.focus(); - toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription); +export function createAppShellRevisionActions( + deps: DesktopRevisionActionsDeps, +): AppShellRevisionActions { + const actions = createRevisionActions({ + ...deps, + commitRevisionDraft: (draft) => deps.commitRevisionDraft(draft as TurnRevisionDraft), + copy: getDesktopConversationCopy(deps.uiLocale).actions, + reviseBeforeTurn: (sourceSessionId, input) => + window.maka.sessions.reviseBeforeTurn(sourceSessionId, input), + abandonSessionCopy: (sourceSessionId, copyId) => + window.maka.sessions.abandonSessionCopy(sourceSessionId, copyId), + localizedShellErrorMessage: (error, fallback, locale) => + localizedShellErrorMessage(error, fallback, locale), + reportSessionWorkspaceUnavailable: (error, sessionId) => { + if (!isSessionWorkspaceUnavailableError(error)) return false; + showSessionWorkspaceUnavailableToast(deps.toastApi, deps.uiLocale, { sessionId }); return true; - } catch (error) { - // Rollback itself navigates back to the source Session, so the failure - // must be surfaced before it runs — checking after it is always stale. - if (selectionIsCurrent()) { - if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { - sessionId: sourceSessionId, - }); - } else { - toastApi.error( - copy.operationFailedTitle, - localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), - undefined, - { sessionId: sourceSessionId }, - ); - } - } - if (preparedSessionId) { - await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent); - } - return false; - } - } - - async function cancelRevisionDraft(): Promise { - let selectionIsCurrent = captureSelection(); - const draft = revisionDraftRef.current; - if (!draft) return; - const cleanupSessionId = draft.copyPhase !== 'reserved' - ? draft.draftSessionId !== draft.sourceSessionId - ? draft.draftSessionId - : draft.copyId - : undefined; - if (cleanupSessionId) await abandonRevisionCopy(draft); - else completeTurnRevisionCopyAttempt(draft); - commitRevisionDraft(null); - composerRef.current?.setDraft(draft.sourceSessionId, draft.previousComposerText); - if (draft.draftSessionId !== draft.sourceSessionId) { - composerRef.current?.clearDraft(draft.draftSessionId); - } - if (selectionIsCurrent() && activeIdRef.current !== draft.sourceSessionId) { - openSessionInChat(draft.sourceSessionId); - selectionIsCurrent = captureSelection(); - } - if (cleanupSessionId) { - await refreshSessions().catch(() => []); - } - if (selectionIsCurrent() && activeIdRef.current === draft.sourceSessionId) { - composerRef.current?.setText(draft.previousComposerText); - composerRef.current?.focus(); - } - } - - return { beginEditUserMessage, prepareRevisionSend, cancelRevisionDraft }; -} - -export function completeTurnRevisionCopyAttempt(draft: TurnRevisionDraft): void { - completeSessionCopyAttempt( - { - scope: `edit-and-resend:${draft.sourceTurnId}`, - kind: 'revision', - sourceSessionId: draft.sourceSessionId, }, - draft.copyId, - ); -} - -export async function abandonTurnRevisionCopyAttempt( - draft: TurnRevisionDraft, -): Promise { - const key: SessionCopyAttemptKey = { - scope: `edit-and-resend:${draft.sourceTurnId}`, - kind: 'revision', - sourceSessionId: draft.sourceSessionId, + acquireCopyAttempt: (key, turnId) => + sessionCopyAttempts.acquireSessionCopyAttempt(key as never, turnId), + startCopyAttempt: (key, copyId) => + sessionCopyAttempts.startSessionCopyAttempt(key as never, copyId), + abandonCopyAttempt: (key, copyId) => + sessionCopyAttempts.abandonSessionCopyAttempt(key as never, copyId), + completeCopyAttempt: (key, copyId) => + sessionCopyAttempts.completeSessionCopyAttempt(key as never, copyId), + }); + return { + beginEditUserMessage: actions.beginEditUserMessage, + prepareRevisionSend: actions.prepareRevisionSend, + cancelRevisionDraft: actions.cancelRevisionDraft, }; - abandonSessionCopyAttempt(key, draft.copyId); - try { - await window.maka.sessions.abandonSessionCopy(draft.sourceSessionId, draft.copyId); - completeSessionCopyAttempt(key, draft.copyId); - return true; - } catch { - return false; - } } + +const turnRevisionCopyHelpers = createTurnRevisionCopyHelpers< + string, + TurnRevisionDraft +>({ + completeCopyAttempt: (key, copyId) => + sessionCopyAttempts.completeSessionCopyAttempt(key as never, copyId), + abandonCopyAttempt: (key, copyId) => + sessionCopyAttempts.abandonSessionCopyAttempt(key as never, copyId), + abandonSessionCopy: (sourceSessionId, copyId) => + window.maka.sessions.abandonSessionCopy(sourceSessionId, copyId), +}); + +export const completeTurnRevisionCopyAttempt = turnRevisionCopyHelpers.completeTurnRevisionCopyAttempt; + +export const abandonTurnRevisionCopyAttempt = turnRevisionCopyHelpers.abandonTurnRevisionCopyAttempt; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 72d3b5f384..72777cbd12 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -319,7 +319,6 @@ function AppShellContent({ retiredSessionIds, messages, transientMessages, - setMessages, commitTranscript, addTransientMessage, updateTransientMessage, @@ -1348,6 +1347,13 @@ function AppShellContent({ composerRef, messages, hasPendingAttachments: () => hasPendingContext, + stagedContext: () => ({ + quotes: pendingQuotes, + attachments: submittableAttachments ?? [], + restoreQuotes, + restoreAttachments, + clearQuotes, + }), openSessionInChat, refreshSessions, commitRevisionDraft, @@ -1436,26 +1442,14 @@ function AppShellContent({ if (queued) delete retractedWorkspaceReferencesRef.current[sessionId]; return queued; } - if ( - revisionSend && - revision && - text.trim() === revision.originalText.trim() && - !hasPendingContext - ) { - const actionCopy = getDesktopConversationCopy(uiLocale).actions; - toastApi.info(actionCopy.revisionReadyTitle, actionCopy.revisionUnchanged); - return false; - } if (revisionSend && revision) { const actionCopy = getDesktopConversationCopy(uiLocale).actions; - if (hasPendingContext) { - toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionAttachmentsUnsupported); - return false; - } if (slashCommand) { toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionCommandUnsupported); return false; } + // The unchanged / mixed-context refusals live inside the revision + // lifecycle (prepareRevisionSend), which toasts and stops the send. if (!(await prepareRevisionSend(text))) return false; } if (slashCommand?.kind === 'compact') { @@ -1603,10 +1597,14 @@ function AppShellContent({ } return ok; } - const pending = submittableAttachments; const expectedRevisionDraft = revisionSend ? revisionDraftRef.current : undefined; + const pending = submittableAttachments; + // The re-key lands the plate's current quotes on the branch child before + // this send, so the live read carries exactly what the user staged — + // including removals and re-annotations made during the edit (#5274 + // review). const quotes = quotesForSend(); const ok = await send(text, pending, { waitForHostAdmission: revisionSend, @@ -1620,7 +1618,9 @@ function AppShellContent({ }); if (ok !== false) { clearSubmittedContext(pending); - if (quotes) clearQuotes(); + // A revision's quotes now live under the branch child's bucket; clear + // that owner explicitly rather than the stale closure's default key. + if (quotes) clearQuotes(expectedRevisionDraft?.draftSessionId); settleNewTaskImageNoticeOwner(sessionId); if (sessionId) delete retractedWorkspaceReferencesRef.current[sessionId]; } diff --git a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts index d068bcdb9f..9d0a9337bf 100644 --- a/apps/desktop/src/renderer/application/contracts/conversation-copy.ts +++ b/apps/desktop/src/renderer/application/contracts/conversation-copy.ts @@ -41,6 +41,8 @@ export interface DesktopConversationCopy { revisionReadyTitle: string; revisionReadyDescription: string; revisionUnavailableTitle: string; + revisionDraftQuoteConflict: string; + revisionMixedContextUnsupported: string; revisionAttachmentsUnsupported: string; revisionTransformedTextUnsupported: string; revisionDraftAttachmentConflict: string; @@ -71,8 +73,12 @@ export interface DesktopConversationCopy { configurationReason: Record; }; footer: { - labels: Record<'branch' | 'copy', string>; + labels: Record<'regenerate' | 'branch' | 'copy', string>; pending: string; + regenerateRunning: string; + regenerateAgain: string; + regenerate: string; + requestRegenerate: string; branchRunning: string; branchAborted: string; branch: string; @@ -330,7 +336,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { 'zh-CN': { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', folderNotAttachable: '文件夹不能作为附件添加。', folderNotAttachableUseReference: '文件夹不能作为附件添加,请改用“引用文件夹”。', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionDraftQuoteConflict: 'Composer 中已有暂存引用,请先发送或移除引用,再编辑历史消息。', revisionMixedContextUnsupported: '编辑期间新增的暂存内容不能与恢复的引用一起发送,请取消本次编辑后重试。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', folderNotAttachable: '文件夹不能作为附件添加。', folderNotAttachableUseReference: '文件夹不能作为附件添加,请改用“引用文件夹”。', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, model: { fakeBackendLabel: '本地模拟连接', setupTitle: '等待配置真实模型', @@ -349,7 +355,7 @@ const COPY = { provider_retired: '当前任务绑定的连接,其登录方式已从 Maka 移除,无法用于发送。请到 设置 · 模型 改用其他连接后新建任务。', }, }, - footer: { labels: { branch: '分支', copy: '复制' }, pending: '正在处理…', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' }, + footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '复制' }, pending: '正在处理…', regenerateRunning: '当前回答仍在进行中,结束后再重新生成', regenerateAgain: '已重新生成过,再次点击将创建新的并行回答', regenerate: '让模型重新生成本轮回答', requestRegenerate: '请求所有者批准重新生成本轮回答', branchRunning: '当前回答仍在进行中,结束后再分支', branchAborted: '从中断前的上下文分支出新任务', branch: '基于此回答的上下文分支出新任务', copy: '复制回答到剪贴板', copyEmpty: '此回答尚无可复制的内容' }, lineage: { regeneratedFrom: '重新生成自旧回答', regeneratedFromTooltip: '这是重新生成的并行回答,点击查看被保留的旧回答', regeneratedTo: '已重新生成 → 新回答', regeneratedToTooltip: '点击跳转到重新生成的新回答' }, workbar: { ariaLabel: '任务工作栏', @@ -573,7 +579,7 @@ const COPY = { turnError: { streamTruncated: '响应中途断开。', requestRejected: '模型服务拒绝了请求,请检查模型与请求配置。', retryExhausted: '已达到自动重试次数上限。', retryDeclined: { side_effects: '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。', observable_output: '本次已有部分输出,未自动重试。请先检查已保留的内容。', policy: '按当前重试规则,本次未自动重试。', budget: '本次执行预算已用尽,未自动重试。' }, unknown: '出错了,暂时无法确定原因。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载。', rateLimit: '模型请求太频繁被限流了。', network: '网络连接失败,请检查网络。', provider: '模型服务返回错误。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。' } }, }, 'zh-TW': { - actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', folderNotAttachable: '資料夾不能作為附件新增。', folderNotAttachableUseReference: '資料夾不能作為附件新增,請改用「引用資料夾」。', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, + actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionDraftQuoteConflict: 'Composer 中已有暫存引用,請先發送或移除引用,再編輯歷史訊息。', revisionMixedContextUnsupported: '編輯期間新增的暫存內容不能與恢復的引用一起傳送,請取消本次編輯後重試。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', folderNotAttachable: '資料夾不能作為附件新增。', folderNotAttachableUseReference: '資料夾不能作為附件新增,請改用「引用資料夾」。', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, model: { fakeBackendLabel: '本地模擬連線', setupTitle: '等待設定真實模型', @@ -592,7 +598,7 @@ const COPY = { provider_retired: '目前任務繫結的連線,其登入方式已從 Maka 移除,無法用於傳送。請到 設定 · 模型 改用其他連線後建立任務。', }, }, - footer: { labels: { branch: '分支', copy: '複製' }, pending: '正在處理…', branchRunning: '目前回答仍在進行中,結束後再分支', branchAborted: '從中斷前的上下文分支出新任務', branch: '基於此回答的上下文分支出新任務', copy: '複製回答到剪貼簿', copyEmpty: '此回答尚無可複製的內容' }, + footer: { labels: { regenerate: '重新生成', branch: '分支', copy: '複製' }, pending: '正在處理…', regenerateRunning: '目前回答仍在進行中,結束後再重新生成', regenerateAgain: '已重新生成過,再次點選將建立新的並行回答', requestRegenerate: '請求擁有者核准重新生成本輪回答', regenerate: '讓模型重新生成本輪迴答', branchRunning: '目前回答仍在進行中,結束後再分支', branchAborted: '從中斷前的上下文分支出新任務', branch: '基於此回答的上下文分支出新任務', copy: '複製回答到剪貼簿', copyEmpty: '此回答尚無可複製的內容' }, lineage: { regeneratedFrom: '重新生成自舊回答', regeneratedFromTooltip: '這是重新生成的並行回答,點選檢視被保留的舊回答', regeneratedTo: '已重新生成 → 新回答', regeneratedToTooltip: '點選跳轉到重新生成的新回答' }, workbar: { ariaLabel: '任務工作欄', @@ -807,7 +813,7 @@ const COPY = { turnError: { streamTruncated: '回應中途斷開。', requestRejected: '模型服務拒絕了請求,請檢查模型與請求設定。', retryExhausted: '已達到自動重試次數上限。', retryDeclined: { side_effects: '本次已有工具活動,為避免重複操作,未自動重試。請先檢查工具結果。', observable_output: '本次已有部分輸出,未自動重試。請先檢查已保留的內容。', policy: '依目前重試規則,本次未自動重試。', budget: '本次執行預算已用盡,未自動重試。' }, unknown: '出錯了,暫時無法確定原因。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載。', rateLimit: '模型請求太頻繁而受到速率限制。', network: '網路連線失敗,請檢查網路。', provider: '模型服務回傳錯誤。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。' } }, }, en: { - actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: "A message's own attachments are not rewritten by edit & resend. Copy the text into a new message instead.", revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', folderNotAttachable: 'Folders cannot be added as attachments.', folderNotAttachableUseReference: 'Folders cannot be added as attachments. Use Reference folder instead.', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: "A message's own attachments are not rewritten by edit & resend. Copy the text into a new message instead.", revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionDraftQuoteConflict: 'The composer already has staged quotes. Send or remove them before editing a sent message.', revisionMixedContextUnsupported: 'Context staged during the edit cannot be sent alongside the restored quotes. Cancel the edit and start over.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', folderNotAttachable: 'Folders cannot be added as attachments.', folderNotAttachableUseReference: 'Folders cannot be added as attachments. Use Reference folder instead.', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, model: { fakeBackendLabel: 'Local simulation', setupTitle: 'Configure a real model', @@ -826,7 +832,7 @@ const COPY = { provider_retired: 'The sign-in this task\u2019s connection uses was removed from Maka, so it cannot send. Switch to another connection in Settings · Models, then start a new task.', }, }, - footer: { labels: { branch: 'Branch', copy: 'Copy' }, pending: 'Working…', branchRunning: 'Wait for the current response to finish before branching', branchAborted: 'Branch from the context before the interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, + footer: { labels: { regenerate: 'Regenerate', branch: 'Branch', copy: 'Copy' }, pending: 'Working…', regenerateRunning: 'Wait for the current response to finish before regenerating', regenerateAgain: 'A regenerated response already exists; click again to create another parallel response', regenerate: 'Generate another response to this turn', requestRegenerate: 'Ask the Owner to approve regenerating this response', branchRunning: 'Wait for the current response to finish before branching', branchAborted: 'Branch from the context before the interruption', branch: 'Branch a new task from this response', copy: 'Copy response to clipboard', copyEmpty: 'This response has no content to copy' }, lineage: { regeneratedFrom: 'Regenerated from previous response', regeneratedFromTooltip: 'This is a parallel regenerated response; click to view the retained previous response', regeneratedTo: 'Regenerated → New response', regeneratedToTooltip: 'Jump to the regenerated response' }, workbar: { ariaLabel: 'Task workbar', @@ -1061,3 +1067,4 @@ const COPY = { export function getDesktopConversationCopy(locale: UiLocale): DesktopConversationCopy { return COPY[locale]; } + diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts index 136621041a..94e510e76b 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -78,11 +78,19 @@ export function useComposerQuotes(options: { readonly draftKey: string }) { publish(); }, [bucket, publish]); - const clearQuotes = useCallback((): void => { - bucket.splice(0, bucket.length); + // An explicit owner key clears another draft's bucket — the revision + // lifecycle re-keys its restored quotes across the commit and clears both + // the source and the branch-child keys (#5109 review); the live draft is + // the default for composer flows. + const clearQuotes = useCallback((ownerKey = options.draftKey): void => { + const target = pendingByKeyRef.current[ownerKey]; + if (target) target.splice(0, target.length); publish(); - }, [bucket, publish]); + }, [options.draftKey, publish]); + // The revision lifecycle re-keys the selected message's quotes onto the + // branch child after the copy commits; restoring them into the owner's + // bucket copies the refs so the plate never aliases the read model. const restoreQuotes = useCallback((ownerKey: string, quotes: readonly QuoteRef[]): void => { if (quotes.length === 0) return; const ownerBucket = pendingByKeyRef.current[ownerKey] ?? diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index 4e88c2ec79..fb14469a71 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -460,6 +460,85 @@ test('does not edit and resend a message with folder references', async () => { assert.equal(editCalls, 0, 'folder references must not be silently dropped by revision'); }); +/** + * Quotes and attachments restage into the revision draft (#5109): a selected + * user message carrying either stays editable, unlike folder references, + * whose Host-owned session binding has no client-side restage path. + */ +test('keeps an attachment-carrying message editable', async () => { + const { container, root } = domRoot(); + let editCalls = 0; + const turn = { + ...turnWith([{ ...ANSWER, live: false }]), + status: 'completed' as const, + user: { + id: 'ask-with-image', + role: 'user' as const, + text: 'Read this chart', + ts: 1, + attachments: [ + { + kind: 'image' as const, + name: 'chart.png', + mimeType: 'image/png', + bytes: 10, + ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath: 'a.png' }, + }, + ], + }, + }; + + await act(() => { + root.render( + + { editCalls += 1; }} /> + , + ); + }); + + const editButton = container.querySelector('[data-action="edit"]'); + assert.ok(editButton, 'the edit action renders'); + assert.doesNotMatch( + editButton.getAttribute('aria-label') ?? '', + /does not yet support/, + ); + await act(() => editButton.dispatchEvent(new window.Event('click', { bubbles: true }))); + assert.equal(editCalls, 1, 'the selected message quotes and attachments restage'); +}); + +test('keeps a quote-carrying message editable', async () => { + const { container, root } = domRoot(); + let editCalls = 0; + const turn = { + ...turnWith([]), + status: 'completed' as const, + user: { + id: 'ask-with-quote', + role: 'user' as const, + text: 'Explain this excerpt', + ts: 1, + quotes: [{ text: 'selected excerpt', sourceTurnId: 'turn-0' }], + }, + }; + + await act(() => { + root.render( + + { editCalls += 1; }} /> + , + ); + }); + + const editButton = container.querySelector('[data-action="edit"]'); + assert.ok(editButton, 'the edit action renders'); + assert.doesNotMatch( + editButton.getAttribute('aria-label') ?? '', + /does not yet support/, + ); + await act(() => editButton.dispatchEvent(new window.Event('click', { bubbles: true }))); + assert.equal(editCalls, 1, 'the selected message quotes restage into the draft'); +}); + /** * A structured-only user message (#4804) — empty inline text carrying a * quote — must render the quote without an empty text bubble, while keeping diff --git a/packages/ui/src/__tests__/revision-staged-context.test.ts b/packages/ui/src/__tests__/revision-staged-context.test.ts new file mode 100644 index 0000000000..a4bc65a725 --- /dev/null +++ b/packages/ui/src/__tests__/revision-staged-context.test.ts @@ -0,0 +1,459 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { AttachmentRef, QuoteRef } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import { + clearRevisionStagedContext, + createRevisionActions, + revisionSendGate, + revisionStagedContextUnchanged, + stageRevisionSourceContext, + type RevisionActionsEnv, + type RevisionEditCopy, + type RevisionStagedContext, + type RevisionStagedSource, + type TurnRevisionDraftBase, +} from '../revision-staged-context.js'; + +const copy: RevisionEditCopy = { + revisionUnavailableTitle: 'unavailable', + revisionAttachmentsUnsupported: 'source-attachments-unsupported', + revisionAlreadyActive: 'already-active', + revisionDraftAttachmentConflict: 'draft-attachment-conflict', + revisionDraftQuoteConflict: 'draft-quote-conflict', + revisionMixedContextUnsupported: 'mixed-context-unsupported', + revisionTransformedTextUnsupported: 'transformed-text-unsupported', + revisionStartedTitle: 'started', + revisionStartedDescription: 'started-description', + revisionReadyTitle: 'ready', + revisionReadyDescription: 'ready-description', + revisionUnchanged: 'unchanged', + operationFailedTitle: 'failed', + operationFailedFallback: 'failed-fallback', +}; + +function userMessage(turnId: string, text: string, extra: Record = {}): StoredMessage { + return { id: `msg-${turnId}`, type: 'user', turnId, ts: 1, text, ...extra } as StoredMessage; +} + +type StagedLog = { + restored: Array<{ ownerKey: string; quotes: QuoteRef[] }>; + cleared: string[]; + quotes: QuoteRef[]; +}; + +function emptyStagedLog(): StagedLog { + return { restored: [], cleared: [], quotes: [] }; +} + +function fakeStaged(log: StagedLog): RevisionStagedContext { + return { + quotes: log.quotes, + attachments: [], + restoreQuotes: (ownerKey, quotes) => { + log.restored.push({ ownerKey, quotes: [...quotes] }); + log.quotes.push(...quotes); + }, + clearQuotes: (ownerKey) => { + log.cleared.push(ownerKey); + log.quotes.length = 0; + }, + }; +} + +function createEnv(input: { messages: StoredMessage[]; staged: StagedLog }) { + const activeIdRef = { current: 'session-1' }; + const revisionDraftRef: { current: TurnRevisionDraftBase | null } = { current: null }; + const toasts: Array<{ kind: 'info' | 'error'; title: string; description?: string }> = []; + const composer = { text: '' }; + let attempts = 0; + const env: RevisionActionsEnv> = { + uiLocale: 'en' as never, + activeIdRef, + captureSelection: () => () => true, + composerRef: { + current: { + getText: () => composer.text, + setText: (text: string) => { + composer.text = text; + }, + focus: () => {}, + clearDraft: () => {}, + setDraft: (_sessionId: string, text: string) => { + composer.text = text; + }, + } as never, + }, + messages: input.messages, + hasPendingAttachments: () => false, + stagedContext: () => fakeStaged(input.staged), + openSessionInChat: (sessionId) => { + activeIdRef.current = sessionId; + }, + refreshSessions: async () => [], + commitRevisionDraft: (draft) => { + revisionDraftRef.current = draft; + }, + revisionDraftRef, + toastApi: { + info: (title, description) => toasts.push({ kind: 'info', title, description }), + error: (title, description) => toasts.push({ kind: 'error', title, description }), + }, + copy, + reviseBeforeTurn: async () => ({ id: 'session-2' }), + abandonSessionCopy: async () => {}, + localizedShellErrorMessage: (_error, fallback) => fallback, + reportSessionWorkspaceUnavailable: () => false, + acquireCopyAttempt: (_key, turnId) => ({ + sourceTurnId: turnId, + copyId: `copy-${++attempts}`, + phase: 'reserved', + }), + startCopyAttempt: () => true, + abandonCopyAttempt: () => true, + completeCopyAttempt: () => {}, + }; + return { env, activeIdRef, revisionDraftRef, toasts }; +} + +const quotedQuote: QuoteRef = { text: 'a large pasted excerpt', sourceTurnId: 'turn-0' }; + +describe('revision lifecycle (#5109)', () => { + it('re-keys the restored quotes onto the branch child across the commit', async () => { + const staged = emptyStagedLog(); + // The branch child transcript a revision copy really produces: the + // revised turn is excluded, so turn-1's message is absent and nothing in + // the copy rewrites it. The re-key therefore reads the draft snapshot — + // nothing ever consults a transcript (#5109 review). + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this', { quotes: [quotedQuote] })], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + assert.deepEqual(staged.restored, [{ ownerKey: 'session-1', quotes: [quotedQuote] }]); + + assert.equal(await actions.prepareRevisionSend('edited text'), true); + assert.deepEqual( + staged.restored.at(-1), + { ownerKey: 'session-2', quotes: [quotedQuote] }, + 'the restored quotes re-key onto the branch child', + ); + assert.ok(staged.cleared.includes('session-1'), 'the source-key plate empties'); + }); + + it('re-keys the plate the user edited, not the edit-start snapshot', async () => { + const staged = emptyStagedLog(); + const h = createEnv({ + messages: [ + userMessage('turn-1', 'explain this', { + quotes: [quotedQuote, { text: 'second excerpt', sourceTurnId: 'turn-0' }], + }), + ], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + // During the edit the user removes the second excerpt and re-annotates + // the first: the plate is the truth the replacement must carry, not the + // snapshot the edit started from (#5274 review). + staged.quotes.splice(1, 1); + staged.quotes[0] = { ...staged.quotes[0], comment: 'actually the other reason' }; + + assert.equal(await actions.prepareRevisionSend('edited text'), true); + assert.deepEqual( + staged.restored.at(-1)?.quotes, + [{ text: 'a large pasted excerpt', sourceTurnId: 'turn-0', comment: 'actually the other reason' }], + 'user removals and re-annotations survive the re-key', + ); + }); + + it('keeps the pre-gate quote snapshot equal to the child bucket the send reads', async () => { + // The Desktop send captures the staged payload BEFORE awaiting the + // revision lifecycle, because the re-key empties the source bucket the + // closure's array points at; the replacement send then delivers that + // snapshot. Whatever the gate does in between, the snapshot and the child + // bucket must agree — or the first replacement silently drops its quote + // (#5109 review, second round). + const staged = emptyStagedLog(); + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this', { quotes: [quotedQuote] })], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + const preGateSnapshot = [...(staged.restored.at(-1)?.quotes ?? [])]; + + assert.equal(await actions.prepareRevisionSend('edited text'), true); + + assert.deepEqual( + staged.restored.at(-1)?.quotes, + preGateSnapshot, + 'the child bucket equals the snapshot', + ); + }); + + it('refuses to edit a message that carries attachments (#5274 review)', () => { + // Attachment ownership does not follow a revision copy — the copied + // transcript stops before the selected turn, so no target-owned refs + // exist client-side to restage. The edit refuses rather than silently + // dropping the files. + const h = createEnv({ + messages: [ + userMessage('turn-1', 'with image', { + attachments: [ + { + kind: 'image', + name: 'chart.png', + mimeType: 'image/png', + bytes: 10, + ref: { kind: 'session_file', sessionId: 'session-1', relativePath: 'a.png' }, + }, + ], + }), + ], + staged: emptyStagedLog(), + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + + assert.equal(h.revisionDraftRef.current, null, 'no draft is committed'); + assert.deepEqual(h.toasts.at(-1), { + kind: 'info', + title: 'unavailable', + description: 'source-attachments-unsupported', + }); + }); + + it('blocks a no-op replacement as unchanged', async () => { + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this')], + staged: emptyStagedLog(), + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + assert.equal(await actions.prepareRevisionSend('explain this'), false); + assert.deepEqual(h.toasts.at(-1), { kind: 'info', title: 'ready', description: 'unchanged' }); + }); + + it('blocks a replacement that mixes newly staged quotes into the edit', async () => { + const staged = emptyStagedLog(); + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this')], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + staged.quotes.push({ text: 'my own excerpt' }); + assert.equal(await actions.prepareRevisionSend('edited text'), false); + assert.deepEqual(h.toasts.at(-1), { + kind: 'info', + title: 'ready', + description: 'mixed-context-unsupported', + }); + }); + + it('cancels a prepared edit and clears both draft keys', async () => { + const staged = emptyStagedLog(); + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this', { quotes: [quotedQuote] })], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + await actions.prepareRevisionSend('edited text'); + await actions.cancelRevisionDraft(); + + assert.deepEqual([...new Set(staged.cleared)].sort(), ['session-1', 'session-2']); + assert.equal(staged.quotes.length, 0, 'nothing stays staged after the cancel'); + assert.equal(h.revisionDraftRef.current, null); + }); +}); + +describe('revision send gate', () => { + const source: RevisionStagedSource = { + originalQuotes: [{ text: 'q' }], + originalAttachments: [], + }; + const originalText = 'explain this'; + const restored = { quotes: [{ text: 'q' }] as readonly QuoteRef[], attachments: [] }; + + it('passes a genuine replacement', () => { + assert.equal( + revisionSendGate(source, originalText, 'edited', restored, false), + 'pass', + ); + }); + + it('blocks a no-op retry as unchanged', () => { + assert.equal( + revisionSendGate(source, originalText, ' explain this ', restored, false), + 'unchanged', + ); + }); + + it('passes a provenance-only snapshot replacement at the same text', () => { + // Same text and turn, but a fresher capture of a cross-Session snapshot: + // the QuoteRef provenance fields are part of what the user staged, so + // replacing them is a real edit, not a no-op (#5274 review). + const recapturedSource: RevisionStagedSource = { + originalQuotes: [ + { + text: 'q', + sourceSessionId: 'session-9', + sourceSessionName: 'Research', + sourceCapturedAt: 100, + }, + ], + originalAttachments: [], + }; + const recaptured = { + quotes: [ + { + text: 'q', + sourceSessionId: 'session-9', + sourceSessionName: 'Research', + sourceCapturedAt: 200, + }, + ] as readonly QuoteRef[], + attachments: [], + }; + assert.equal( + revisionSendGate(recapturedSource, originalText, originalText, recaptured, false), + 'pass', + ); + // The stale capture of the same snapshot is still refused as a no-op. + const stale = { + quotes: [ + { + text: 'q', + sourceSessionId: 'session-9', + sourceSessionName: 'Research', + sourceCapturedAt: 100, + }, + ] as readonly QuoteRef[], + attachments: [], + }; + assert.equal( + revisionSendGate(recapturedSource, originalText, originalText, stale, false), + 'unchanged', + ); + }); + + it('passes a comment-only annotation change at the same text', () => { + // The composer exposes onEditQuoteComment, so changing only a quote's + // model-facing annotation is a real edit: the no-op gate must not refuse + // it just because the excerpt and its provenance are unchanged (#5274 + // review). + const annotatedSource: RevisionStagedSource = { + originalQuotes: [{ text: 'q', comment: 'why I quoted this' }], + originalAttachments: [], + }; + const reannotated = { + quotes: [{ text: 'q', comment: 'actually the other reason' }] as readonly QuoteRef[], + attachments: [], + }; + assert.equal( + revisionSendGate(annotatedSource, originalText, originalText, reannotated, false), + 'pass', + ); + // The same annotation back again is still a no-op. + const sameAnnotation = { + quotes: [{ text: 'q', comment: 'why I quoted this' }] as readonly QuoteRef[], + attachments: [], + }; + assert.equal( + revisionSendGate(annotatedSource, originalText, originalText, sameAnnotation, false), + 'unchanged', + ); + }); + + it('blocks newly staged quotes as a conflict', () => { + assert.equal( + revisionSendGate( + source, + originalText, + 'edited', + { quotes: [{ text: 'q' }, { text: 'own' }], attachments: [] }, + false, + ), + 'conflict', + ); + }); + + it('blocks pending directories with an empty attachment plate as a conflict', () => { + assert.equal(revisionSendGate(source, originalText, 'edited', restored, true), 'conflict'); + }); +}); + +describe('revision staged-context helpers', () => { + it('stages the source quotes under the owner key and records them', () => { + const restored: Array<{ ownerKey: string; quotes: readonly QuoteRef[] }> = []; + const snapshot = stageRevisionSourceContext( + { restoreQuotes: (ownerKey, quotes) => restored.push({ ownerKey, quotes }) }, + 'session-1', + { quotes: [quotedQuote] }, + ); + assert.deepEqual(restored, [{ ownerKey: 'session-1', quotes: [quotedQuote] }]); + assert.deepEqual(snapshot.originalQuotes, [quotedQuote]); + assert.deepEqual( + snapshot.originalAttachments, + [], + 'a message without attachments stages an empty attachment set', + ); + }); + + it('clears the staged quotes under every owner key once', () => { + const cleared: string[] = []; + clearRevisionStagedContext( + { clearQuotes: (ownerKey) => cleared.push(ownerKey) }, + ['session-1', 'session-2', 'session-1'], + ); + assert.deepEqual(cleared, ['session-1', 'session-2']); + }); + + it('compares text and quotes for the unchanged retry', () => { + const source: RevisionStagedSource = { originalQuotes: [quotedQuote], originalAttachments: [] }; + assert.equal( + revisionStagedContextUnchanged(source, 'explain', 'explain', [quotedQuote], []), + true, + ); + assert.equal( + revisionStagedContextUnchanged(source, 'explain', 'edited', [quotedQuote], []), + false, + 'a text change is a genuine replacement', + ); + assert.equal( + revisionStagedContextUnchanged(source, 'explain', 'explain', [], []), + false, + 'a removed quote is a genuine replacement', + ); + }); +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 66a786f29d..ca2b37da71 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -598,28 +598,23 @@ export const TurnView = memo(function TurnView(props: { ? () => props.onEditUserMessage?.(turn.turnId) : undefined } - // A revision restages neither attachments, directory references, - // nor quotes, so a turn carrying any of them can't be edited - // without silently dropping context the answer was grounded in. + // Quotes and the selected message's own attachments restage into + // the surface's staged-context plates (#5109); directory + // references have no client-side restage path, so a turn carrying + // them still can't be edited without silently dropping context. editDisabled={ - (turn.user.attachments?.length ?? 0) > 0 || (turn.user.directoryReferences?.length ?? 0) > 0 || - (turn.user.quotes?.length ?? 0) > 0 || props.editUserMessageTransformed === true || props.editUserMessageDisabled === true || turn.status === 'running' || !!props.liveStreaming } editDisabledReason={ - (turn.user.attachments?.length ?? 0) > 0 - ? copy.editMessageDisabledAttachments - : (turn.user.directoryReferences?.length ?? 0) > 0 - ? copy.editMessageDisabledDirectoryReferences - : (turn.user.quotes?.length ?? 0) > 0 - ? copy.editMessageDisabledQuotes - : props.editUserMessageTransformed - ? copy.editMessageDisabledTransformedText - : copy.editMessageDisabledRunning + (turn.user.directoryReferences?.length ?? 0) > 0 + ? copy.editMessageDisabledDirectoryReferences + : props.editUserMessageTransformed + ? copy.editMessageDisabledTransformedText + : copy.editMessageDisabledRunning } /> diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 5a0ed907a4..1b3096f5a3 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -280,9 +280,7 @@ export interface ConversationCopy { copy: string; editMessage: string; editMessageDisabledRunning: string; - editMessageDisabledAttachments: string; editMessageDisabledDirectoryReferences: string; - editMessageDisabledQuotes: string; editMessageDisabledTransformedText: string; userAriaLabel: string; systemAriaLabel: string; @@ -510,9 +508,8 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在处理…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], processDetails: '执行过程', processDuration: (minutes, seconds) => `用时 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, turnStatusCompleted: (elapsed?: string) => ['已完成', elapsed].filter(Boolean).join(' · '), turnStatusAborted: (elapsed?: string) => ['已中止', elapsed].filter(Boolean).join(' · '), turnStatusFailed: (elapsed?: string) => ['失败', elapsed].filter(Boolean).join(' · '), providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小时', minute: '分', second: '秒' })}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '响应中途断开', network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, failureDetailsUnavailable: '无可用诊断详情。', safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', - editMessageDisabledDirectoryReferences: '包含文件夹引用的历史消息暂不支持编辑并重发', - userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, workHubResultReceived: '任务结果更新', agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, + you: '你', assistant: 'Maka', processing: '正在处理…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], processDetails: '执行过程', processDuration: (minutes, seconds) => `用时 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, turnStatusCompleted: (elapsed?: string) => ['已完成', elapsed].filter(Boolean).join(' · '), turnStatusAborted: (elapsed?: string) => ['已中止', elapsed].filter(Boolean).join(' · '), turnStatusFailed: (elapsed?: string) => ['失败', elapsed].filter(Boolean).join(' · '), providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小时', minute: '分', second: '秒' })}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '响应中途断开', network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, failureDetailsUnavailable: '无可用诊断详情。', safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', + editMessageDisabledDirectoryReferences: '包含文件夹引用的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, workHubResultReceived: '任务结果更新', agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, sessionSnapshotLabel: (name) => `会话:${name}`, sessionSnapshotPending: '发送时截取快照', sessionSnapshotCaptured: (iso, truncated) => `快照时间 ${iso}${truncated ? ' · 内容已截断' : ''}`, quoteSelectedTextLabel: '所选文本', quoteCommentLabel: '用户评论', quoteCommentPlaceholder: '添加可选评论…', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', systemNotes: { contextCompacting: '正在压缩上下文…', @@ -634,9 +631,8 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `選擇專案:${label},目前分支 ${branch}` : `選擇專案:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在處理…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盤算…', '正在鑽研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓搗…', '正在醞釀…', '正在攻堅…', '正在權衡…', '正在拾掇…'], processDetails: '執行過程', processDuration: (minutes, seconds) => `用時 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, turnStatusCompleted: (elapsed?: string) => ['已完成', elapsed].filter(Boolean).join(' · '), turnStatusAborted: (elapsed?: string) => ['已中止', elapsed].filter(Boolean).join(' · '), turnStatusFailed: (elapsed?: string) => ['失敗', elapsed].filter(Boolean).join(' · '), providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小時', minute: '分', second: '秒' })}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '回應中途斷開', network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, failureDetailsUnavailable: '無可用診斷詳情。', safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledAttachments: '包含附件的歷史訊息暫不支援編輯並重發', editMessageDisabledQuotes: '包含引用的歷史訊息暫不支援編輯並重發', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', - editMessageDisabledDirectoryReferences: '包含資料夾引用的歷史訊息暫不支援編輯並重發', - userAriaLabel: '你傳送的訊息', systemAriaLabel: '系統訊息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}訊息${context ? `:${context}` : ''}`, sourceAriaLabel: '本輪迴答的來源', derivativesAriaLabel: '本輪迴答的衍生', scheduledTaskTriggered: '定時任務觸發', scheduledTaskTitle: (id) => `由定時任務觸發 · ${id}`, legacyAutomationTriggered: '舊版自動化(僅歷史)', legacyAutomationTitle: (id) => `由舊版自動化觸發 · ${id} · 僅保留歷史,不會再次執行`, goalContinued: 'Goal 自動繼續', goalTitle: (id) => `由 Goal 繼續執行 · ${id}`, workHubResultReceived: '任務結果更新', agentGraphTriggered: 'Agent Graph 自動繼續', agentGraphTitle: (graphId) => `由 Agent Graph 排程器觸發 · ${graphId}`, + you: '你', assistant: 'Maka', processing: '正在處理…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盤算…', '正在鑽研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓搗…', '正在醞釀…', '正在攻堅…', '正在權衡…', '正在拾掇…'], processDetails: '執行過程', processDuration: (minutes, seconds) => `用時 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, turnStatusCompleted: (elapsed?: string) => ['已完成', elapsed].filter(Boolean).join(' · '), turnStatusAborted: (elapsed?: string) => ['已中止', elapsed].filter(Boolean).join(' · '), turnStatusFailed: (elapsed?: string) => ['失敗', elapsed].filter(Boolean).join(' · '), providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小時', minute: '分', second: '秒' })}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '回應中途斷開', network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, failureDetailsUnavailable: '無可用診斷詳情。', safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', + editMessageDisabledDirectoryReferences: '包含資料夾引用的歷史訊息暫不支援編輯並重發', userAriaLabel: '你傳送的訊息', systemAriaLabel: '系統訊息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}訊息${context ? `:${context}` : ''}`, sourceAriaLabel: '本輪迴答的來源', derivativesAriaLabel: '本輪迴答的衍生', scheduledTaskTriggered: '定時任務觸發', scheduledTaskTitle: (id) => `由定時任務觸發 · ${id}`, legacyAutomationTriggered: '舊版自動化(僅歷史)', legacyAutomationTitle: (id) => `由舊版自動化觸發 · ${id} · 僅保留歷史,不會再次執行`, goalContinued: 'Goal 自動繼續', goalTitle: (id) => `由 Goal 繼續執行 · ${id}`, workHubResultReceived: '任務結果更新', agentGraphTriggered: 'Agent Graph 自動繼續', agentGraphTitle: (graphId) => `由 Agent Graph 排程器觸發 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截斷;顯示的是最近的內容', outputTruncatedTitle: '助手輸出已超過單次回合上限,超出部分未渲染。如需完整內容請重新生成或檢視持久化的任務記錄。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, sessionSnapshotLabel: (name) => `作業階段:${name}`, sessionSnapshotPending: '傳送時擷取快照', sessionSnapshotCaptured: (iso, truncated) => `快照時間 ${iso}${truncated ? ' · 內容已截斷' : ''}`, quoteSelectedTextLabel: '所選文字', quoteCommentLabel: '使用者評論', quoteCommentPlaceholder: '新增可選評論…', quoteExpandAriaLabel: '展開引用全文', quoteCollapseAriaLabel: '收起引用', systemNotes: { contextCompacting: '正在壓縮上下文…', @@ -755,7 +751,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`, }, messages: { - you: 'You', assistant: 'Maka', processing: 'Working…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], processDetails: 'Execution process', processDuration: (minutes, seconds) => `Worked for ${minutes > 0 ? `${minutes}m ` : ''}${seconds}s`, turnStatusCompleted: (elapsed?: string) => ['Done', elapsed].filter(Boolean).join(' · '), turnStatusAborted: (elapsed?: string) => ['Stopped', elapsed].filter(Boolean).join(' · '), turnStatusFailed: (elapsed?: string) => ['Failed', elapsed].filter(Boolean).join(' · '), providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, { day: 'd', hour: 'h', minute: 'm', second: 's' })} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `Waiting to retry (${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: 'Response stream ended before completion', network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, failureDetailsUnavailable: 'No diagnostic details are available.', safeResumePending: 'Checking…', safeResume: 'Continue this turn', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages with expanded context', + you: 'You', assistant: 'Maka', processing: 'Working…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], processDetails: 'Execution process', processDuration: (minutes, seconds) => `Worked for ${minutes > 0 ? `${minutes}m ` : ''}${seconds}s`, turnStatusCompleted: (elapsed?: string) => ['Done', elapsed].filter(Boolean).join(' · '), turnStatusAborted: (elapsed?: string) => ['Stopped', elapsed].filter(Boolean).join(' · '), turnStatusFailed: (elapsed?: string) => ['Failed', elapsed].filter(Boolean).join(' · '), providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, { day: 'd', hour: 'h', minute: 'm', second: 's' })} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `Waiting to retry (${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: 'Response stream ended before completion', network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, failureDetailsUnavailable: 'No diagnostic details are available.', safeResumePending: 'Checking…', safeResume: 'Continue this turn', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages with expanded context', editMessageDisabledDirectoryReferences: 'Edit & resend does not yet support messages with folder references', userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, workHubResultReceived: 'Task result update', agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, sessionSnapshotLabel: (name) => `Session: ${name}`, sessionSnapshotPending: 'snapshot captured when sent', sessionSnapshotCaptured: (iso, truncated) => `captured ${iso}${truncated ? ' · truncated' : ''}`, quoteSelectedTextLabel: 'Selected text', quoteCommentLabel: 'Your comment', quoteCommentPlaceholder: 'Add an optional comment…', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9e8a48b6bf..dd0c8ee2ff 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -201,3 +201,4 @@ export { export { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js'; export { ChoicePanel, type ChoicePanelOption } from './choice-panel.js'; export { formatCompactTokenCount } from './compact-token-count.js'; +export * from './revision-staged-context.js'; diff --git a/packages/ui/src/revision-staged-context.ts b/packages/ui/src/revision-staged-context.ts new file mode 100644 index 0000000000..ddab7f6efe --- /dev/null +++ b/packages/ui/src/revision-staged-context.ts @@ -0,0 +1,633 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { AttachmentRef, QuoteRef } from '@maka/core/events'; +import { userFacingText, type StoredMessage } from '@maka/core/session'; +import type { UiLocale } from '@maka/core/ui-locale'; +import type { ComposerHandle } from './composer.js'; +import type { PendingAttachment } from './composer-attachments.js'; + +/** + * Snapshot of the composer's staged context, read fresh at every use: the + * staging hooks bind their mutators to the active session's draft key, which + * moves across the revision commit (source → branch child). Restoring and + * clearing take an explicit owner key so the lifecycle can re-key the staged + * quotes across that commit; attachments do not restage — a revision copy + * excludes the revised turn and no target-owned refs exist client-side, so + * an edit of a message carrying them is refused outright (#5109 review). + */ +export type RevisionStagedContext = { + quotes: readonly QuoteRef[]; + attachments: readonly PendingAttachment[]; + restoreQuotes(ownerKey: string, quotes: readonly QuoteRef[]): void; + clearQuotes(ownerKey: string): void; +}; + +/** The edit-and-resend source context a staged plate must match verbatim. */ +export type RevisionStagedSource = { + originalQuotes: readonly QuoteRef[]; + originalAttachments: readonly AttachmentRef[]; +}; + +/** + * Surface-neutral revision draft: the shared staged-context fields every + * edit-and-resend client carries, parameterized by its copy-attempt phase. + */ +export type TurnRevisionDraftBase = { + sourceSessionId: string; + sourceTurnId: string; + copyId: string; + copyPhase: Phase; + /** Active owner of the draft. Changes to the branch child after prepare. */ + draftSessionId: string; + originalText: string; + previousComposerText: string; + originalQuotes: readonly QuoteRef[]; + originalAttachments: readonly AttachmentRef[]; +}; + + +function quoteKey(quote: QuoteRef): string { + // Every field participates: two snapshots can share text, label, and turn + // while differing in which Session they were captured from, when, or how + // completely — and treating a provenance-only replacement as a no-op would + // refuse a legitimate edit (apache/maka#5274 review). The model-facing + // comment counts too: editing only the annotation is a real change the + // composer exposes through onEditQuoteComment. + return JSON.stringify([ + quote.text, + quote.label ?? null, + quote.sourceTurnId ?? null, + quote.sourceSessionId ?? null, + quote.sourceSessionName ?? null, + quote.sourceCapturedAt ?? null, + quote.sourceTruncated ?? null, + quote.comment ?? null, + ]); +} + +function attachmentToPending(attachment: AttachmentRef): PendingAttachment { + return { + stagingKey: `revision:${JSON.stringify(attachment)}`, + displayName: attachment.name, + mimeType: attachment.mimeType, + kind: attachment.kind, + size: attachment.bytes, + source: { type: 'retained', attachment }, + }; +} + +function attachmentKey(attachment: PendingAttachment): string { + return JSON.stringify( + attachment.source.type === 'retained' ? attachment.source.attachment : attachment.source, + ); +} + +/** + * A send whose text and staged context both match what the edit staged is a + * no-op retry: the replacement would duplicate the source turn verbatim. + * Compared in plate order — the restaged source context is the whole plate, + * because editing is refused while the user has own context staged. + */ +export function revisionStagedContextUnchanged( + source: RevisionStagedSource, + originalText: string, + text: string, + stagedQuotes: readonly QuoteRef[], + stagedAttachments: readonly PendingAttachment[], +): boolean { + if (text.trim() !== originalText.trim()) return false; + if (stagedQuotes.map(quoteKey).join('\n') !== source.originalQuotes.map(quoteKey).join('\n')) { + return false; + } + return ( + stagedAttachments.map(attachmentKey).join('\n') === + source.originalAttachments.map(attachmentToPending).map(attachmentKey).join('\n') + ); +} + +/** + * The pre-send gate for a revision replacement: 'unchanged' blocks a no-op + * retry that would duplicate the source turn verbatim; 'conflict' blocks a + * send mixing user-staged context into the restored set (pending directories + * have no plate snapshot — flagged through pendingContext with an empty + * attachment plate). + */ +export function stageRevisionSourceContext( + staged: Pick, + ownerKey: string, + message: { quotes?: readonly QuoteRef[] }, +): RevisionStagedSource { + const sourceQuotes = [...(message.quotes ?? [])]; + if (sourceQuotes.length > 0) staged.restoreQuotes(ownerKey, sourceQuotes); + return { originalQuotes: sourceQuotes, originalAttachments: [] }; +} + +/** + * The pre-send gate for a revision replacement: 'unchanged' blocks a no-op + * retry that would duplicate the source turn verbatim; 'conflict' blocks a + * send mixing user-staged context into the restored set (pending directories + * have no plate snapshot — flagged through pendingContext with an empty + * attachment plate). + */ +export function revisionSendGate( + source: RevisionStagedSource, + originalText: string, + text: string, + staged: Pick, + pendingContext: boolean, +): 'pass' | 'unchanged' | 'conflict' { + if (revisionStagedContextUnchanged(source, originalText, text, staged.quotes, staged.attachments)) { + return 'unchanged'; + } + if ( + staged.quotes.length > source.originalQuotes.length || + staged.attachments.length > source.originalAttachments.length || + (pendingContext && staged.attachments.length === 0) + ) { + return 'conflict'; + } + return 'pass'; +} + +/** + * Unstage everything the edit staged, wherever the commit left it — the + * cancel path. The plates hold only the edit's items under the two draft + * keys (source before the commit, branch child after), because editing is + * refused while the user has own context staged. + */ +export function clearRevisionStagedContext( + staged: Pick, + ownerKeys: readonly string[], +): void { + for (const ownerKey of new Set(ownerKeys)) staged.clearQuotes(ownerKey); +} + +/** Localized strings an edit-and-resend surface needs from its own catalog. */ +export interface RevisionEditCopy { + revisionUnavailableTitle: string; + revisionAlreadyActive: string; + revisionAttachmentsUnsupported: string; + revisionDraftAttachmentConflict: string; + revisionDraftQuoteConflict: string; + revisionMixedContextUnsupported: string; + revisionTransformedTextUnsupported: string; + revisionStartedTitle: string; + revisionStartedDescription: string; + revisionReadyTitle: string; + revisionReadyDescription: string; + revisionUnchanged: string; + operationFailedTitle: string; + operationFailedFallback: string; +} + +/** Identity of one copy attempt, owned by the surface's attempt tracker. */ +export interface RevisionCopyKey { + scope: string; + kind: string; + sourceSessionId: string; +} + +/** Toast surface used by the revision lifecycle. */ +export interface RevisionToastApi { + info(title: string, description?: string): void; + error( + title: string, + description?: string, + diagnosticDetails?: string, + diagnosticTarget?: { sessionId: string }, + ): void; +} + +/** + * Everything a surface must inject so the edit-and-resend lifecycle can run + * without knowing the bridge, the locale catalog, or the attempt tracker: + * desktop touchpoints arrive as values and callbacks, never as imports. + * + * Why this lives in @maka/ui: the desktop renderer's debt ratchet forbids + * new dependency edges in the legacy shell files, and the lifecycle needs a + * runtime import of this module's composer types. Surfaces that already hold + * an @maka/ui edge (app-shell) assemble the env; the injected shell file + * keeps only type-level contact with this module. + */ +export interface RevisionActionsEnv< + Phase, + TDraft extends TurnRevisionDraftBase, +> { + uiLocale: UiLocale; + activeIdRef: { current: string | undefined }; + captureSelection(): () => boolean; + composerRef: { current: ComposerHandle | null }; + messages: readonly StoredMessage[]; + hasPendingAttachments(): boolean; + stagedContext(): RevisionStagedContext; + openSessionInChat(sessionId: string, turnId?: string): void; + refreshSessions(): Promise; + commitRevisionDraft(draft: TurnRevisionDraftBase | null): void; + revisionDraftRef: { current: TDraft | null }; + toastApi: RevisionToastApi; + copy: RevisionEditCopy; + reviseBeforeTurn( + sourceSessionId: string, + input: { sourceTurnId: string; copyId: string }, + ): Promise<{ id: string }>; + abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; + localizedShellErrorMessage(error: unknown, fallback: string, locale: UiLocale): string; + /** True when the error is the workspace-unavailable class, having toasted. */ + reportSessionWorkspaceUnavailable(error: unknown, sessionId: string): boolean; + acquireCopyAttempt( + key: RevisionCopyKey, + turnId: string, + ): { sourceTurnId: string; copyId: string; phase: Phase }; + startCopyAttempt(key: RevisionCopyKey, copyId: string): boolean; + abandonCopyAttempt(key: RevisionCopyKey, copyId: string): boolean; + completeCopyAttempt(key: RevisionCopyKey, copyId: string): void; +} + +function revisionCopyKey( + sourceSessionId: string, + sourceTurnId: string, +): RevisionCopyKey { + return { + scope: `edit-and-resend:${sourceTurnId}`, + kind: 'revision', + sourceSessionId, + }; +} + +/** + * The edit-and-resend lifecycle shared by surfaces that stage the selected + * message's context into composer plates (#5109): edit click stages without + * branching; send prepares the before-turn branch, swaps the staged + * attachment refs for the copied message's target-owned refs, and hands back + * to the surface's normal send. Cancel unbranches and restores the plates. + */ +export function createRevisionActions< + Phase, + TDraft extends TurnRevisionDraftBase, +>( + env: RevisionActionsEnv, +): { + beginEditUserMessage(turnId: string): void; + prepareRevisionSend(text: string): Promise; + cancelRevisionDraft(): Promise; +} { + const { + uiLocale, + activeIdRef, + captureSelection, + composerRef, + messages, + hasPendingAttachments, + stagedContext, + openSessionInChat, + refreshSessions, + commitRevisionDraft, + revisionDraftRef, + toastApi, + copy, + } = env; + + function beginEditUserMessage(turnId: string): void { + const sessionId = activeIdRef.current; + if (!sessionId) return; + const existing = revisionDraftRef.current; + if (existing) { + if (existing.draftSessionId === sessionId && existing.sourceTurnId === turnId) { + composerRef.current?.focus(); + } else { + toastApi.info(copy.revisionUnavailableTitle, copy.revisionAlreadyActive); + } + return; + } + if (hasPendingAttachments()) { + toastApi.info(copy.revisionUnavailableTitle, copy.revisionDraftAttachmentConflict); + return; + } + const userMessage = messages.find( + (message): message is Extract => + message.type === 'user' && message.turnId === turnId, + ); + if (!userMessage) { + toastApi.error(copy.operationFailedTitle, copy.operationFailedFallback, undefined, { + sessionId, + }); + return; + } + if ((userMessage.attachments?.length ?? 0) > 0) { + // Host-side attachment ownership does not follow a revision copy — the + // copied transcript stops before the selected turn, so no target-owned + // refs exist to restage. The edit refuses rather than silently dropping + // the files (#5109 review, #5274 review). + toastApi.info(copy.revisionUnavailableTitle, copy.revisionAttachmentsUnsupported); + return; + } + + // The selected message's quotes and attachments restage into the surface's + // plates (#5109): the plates make the carried context visible and + // explicitly removable, and the commit re-keys them onto the branch child + // (prepareRevisionSend). The edit refuses while the user has own context + // staged, so the plate ends up holding exactly the source context. + const staged = stagedContext(); + if (staged.quotes.length > 0) { + toastApi.info(copy.revisionUnavailableTitle, copy.revisionDraftQuoteConflict); + return; + } + if (userMessage.displayText !== undefined && userMessage.displayText !== userMessage.text) { + toastApi.info(copy.revisionUnavailableTitle, copy.revisionTransformedTextUnsupported); + return; + } + + const prompt = userFacingText(userMessage); + const copyAttempt = env.acquireCopyAttempt( + revisionCopyKey(sessionId, turnId), + turnId, + ); + const { originalQuotes, originalAttachments } = stageRevisionSourceContext( + staged, + sessionId, + userMessage, + ); + commitRevisionDraft({ + sourceSessionId: sessionId, + sourceTurnId: copyAttempt.sourceTurnId, + copyId: copyAttempt.copyId, + copyPhase: copyAttempt.phase, + draftSessionId: sessionId, + originalText: prompt, + previousComposerText: composerRef.current?.getText() ?? '', + originalQuotes, + originalAttachments, + }); + composerRef.current?.setText(prompt); + composerRef.current?.focus(); + toastApi.info(copy.revisionStartedTitle, copy.revisionStartedDescription); + } + + async function rollbackPreparedRevision( + draft: TDraft, + revisionSessionId: string, + text: string, + selectionIsCurrent: () => boolean, + ): Promise { + composerRef.current?.clearDraft(revisionSessionId); + const current = revisionDraftRef.current; + if (selectionIsCurrent() && activeIdRef.current === revisionSessionId) { + openSessionInChat(draft.sourceSessionId); + selectionIsCurrent = captureSelection(); + } + const abandonment = await abandonRevisionCopy(draft); + const abandoningDraft = abandonment.draft; + let restored: TDraft | undefined; + if (current?.copyId === draft.copyId && revisionDraftRef.current === abandoningDraft) { + if (abandonment.acknowledged) { + const nextAttempt = env.acquireCopyAttempt( + revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), + draft.sourceTurnId, + ); + restored = { + ...draft, + sourceTurnId: nextAttempt.sourceTurnId, + copyId: nextAttempt.copyId, + copyPhase: nextAttempt.phase, + draftSessionId: draft.sourceSessionId, + }; + } else { + restored = { ...abandoningDraft, draftSessionId: draft.sourceSessionId }; + } + composerRef.current?.setDraft(draft.sourceSessionId, text); + commitRevisionDraft(restored); + } + if ( + selectionIsCurrent() && activeIdRef.current === draft.sourceSessionId && + revisionDraftRef.current === restored + ) { + composerRef.current?.setText(text); + composerRef.current?.focus(); + } + await refreshSessions().catch(() => []); + } + + async function abandonRevisionCopy( + draft: TDraft, + ): Promise<{ acknowledged: boolean; draft: TDraft }> { + const tracked = env.abandonCopyAttempt( + revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), + draft.copyId, + ); + const current = revisionDraftRef.current; + const trackedDraft = current?.copyId === draft.copyId ? current : draft; + const abandoningDraft = + tracked && trackedDraft.copyPhase !== 'abandoning' + ? { ...trackedDraft, copyPhase: 'abandoning' as const } + : trackedDraft; + if (revisionDraftRef.current === trackedDraft && abandoningDraft !== trackedDraft) { + commitRevisionDraft(abandoningDraft); + } + try { + // Main acknowledges only after the cleanup intent is durable; physical + // removal may finish after this renderer has closed the draft. + await env.abandonSessionCopy(draft.sourceSessionId, draft.copyId); + env.completeCopyAttempt(revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), draft.copyId); + return { acknowledged: true, draft: abandoningDraft }; + } catch { + // An ambiguous cleanup acknowledgement stays in `abandoning`; this + // target may only retry cleanup and can never be copied into again. + return { acknowledged: false, draft: abandoningDraft }; + } + } + + async function prepareRevisionSend(text: string): Promise { + let selectionIsCurrent = captureSelection(); + let draft = revisionDraftRef.current; + if (!draft || activeIdRef.current !== draft.draftSessionId) return false; + // A no-op retry (text and staged context unchanged) would duplicate the + // source turn verbatim; a send mixing user-staged context into the + // restored set cannot carry it truthfully. Both stop here, toasting. + const staged = stagedContext(); + const gate = revisionSendGate(draft, draft.originalText, text, staged, hasPendingAttachments()); + if (gate !== 'pass') { + toastApi.info( + copy.revisionReadyTitle, + gate === 'unchanged' ? copy.revisionUnchanged : copy.revisionMixedContextUnsupported, + ); + return false; + } + // A previous attempt already prepared the version; retry normal send there. + if (draft.draftSessionId !== draft.sourceSessionId) return true; + + if (draft.copyPhase === 'abandoning') { + const abandonment = await abandonRevisionCopy(draft); + if ( + !selectionIsCurrent() || !abandonment.acknowledged || + revisionDraftRef.current !== abandonment.draft || + activeIdRef.current !== draft.sourceSessionId + ) { + return false; + } + const nextAttempt = env.acquireCopyAttempt( + revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), + draft.sourceTurnId, + ); + draft = { + ...draft, + copyId: nextAttempt.copyId, + copyPhase: nextAttempt.phase, + }; + commitRevisionDraft(draft); + } + + const startedDraft = + draft.copyPhase === 'started' ? draft : { ...draft, copyPhase: 'started' as const }; + if (startedDraft !== draft) { + if ( + !env.startCopyAttempt( + revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), + draft.copyId, + ) + ) { + return false; + } + commitRevisionDraft(startedDraft); + } + const sourceSessionId = startedDraft.sourceSessionId; + let preparedSessionId: string | undefined; + try { + const newSession = await env.reviseBeforeTurn(sourceSessionId, { + sourceTurnId: startedDraft.sourceTurnId, + copyId: startedDraft.copyId, + }); + preparedSessionId = newSession.id; + if (!selectionIsCurrent() || revisionDraftRef.current !== startedDraft) { + await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); + return false; + } + + const prepared = { ...startedDraft, draftSessionId: newSession.id }; + composerRef.current?.setDraft(newSession.id, text); + commitRevisionDraft(prepared); + openSessionInChat(newSession.id); + selectionIsCurrent = captureSelection(); + await refreshSessions(); + if (!selectionIsCurrent() || revisionDraftRef.current !== prepared) { + await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); + return false; + } + // Re-key the plate's *current* quotes onto the branch child: the user + // may have removed or re-annotated entries during the edit, and the + // replacement must carry the plate as it stands, not the snapshot the + // edit started from (#5274 review). Re-keyed only after every rollback + // check has passed, so a failed preparation leaves the plate on the + // source key. + staged.restoreQuotes(newSession.id, staged.quotes); + staged.clearQuotes(startedDraft.sourceSessionId); + composerRef.current?.focus(); + toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription); + return true; + } catch (error) { + // Rollback itself navigates back to the source Session, so the failure + // must be surfaced before it runs — checking after it is always stale. + if (selectionIsCurrent()) { + if (env.reportSessionWorkspaceUnavailable(error, sourceSessionId)) { + return false; + } + toastApi.error( + copy.operationFailedTitle, + env.localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), + undefined, + { sessionId: sourceSessionId }, + ); + } + if (preparedSessionId) { + await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent); + } + return false; + } + } + + async function cancelRevisionDraft(): Promise { + let selectionIsCurrent = captureSelection(); + const draft = revisionDraftRef.current; + if (!draft) return; + const cleanupSessionId = draft.copyPhase !== 'reserved' + ? draft.draftSessionId !== draft.sourceSessionId + ? draft.draftSessionId + : draft.copyId + : undefined; + if (cleanupSessionId) await abandonRevisionCopy(draft); + else env.completeCopyAttempt(revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), draft.copyId); + commitRevisionDraft(null); + // Unstage everything the edit staged (#5109), under both draft keys: the + // plate starts on the source key and the commit re-keys it onto the + // branch child. The edit refuses while the user has own context staged. + clearRevisionStagedContext(stagedContext(), [draft.sourceSessionId, draft.draftSessionId]); + composerRef.current?.setDraft(draft.sourceSessionId, draft.previousComposerText); + if (draft.draftSessionId !== draft.sourceSessionId) { + composerRef.current?.clearDraft(draft.draftSessionId); + } + if (selectionIsCurrent() && activeIdRef.current !== draft.sourceSessionId) { + openSessionInChat(draft.sourceSessionId); + selectionIsCurrent = captureSelection(); + } + if (cleanupSessionId) { + await refreshSessions().catch(() => []); + } + if (selectionIsCurrent() && activeIdRef.current === draft.sourceSessionId) { + composerRef.current?.setText(draft.previousComposerText); + composerRef.current?.focus(); + } + } + + return { beginEditUserMessage, prepareRevisionSend, cancelRevisionDraft }; +} + +/** + * The module-level copy-attempt bookkeeping a surface exports beside the + * factory: completion on send, and abandonment with the same ambiguous- + * acknowledgement contract as the lifecycle above. + */ +export function createTurnRevisionCopyHelpers< + Phase, + TDraft extends TurnRevisionDraftBase, +>(deps: { + completeCopyAttempt(key: RevisionCopyKey, copyId: string): void; + abandonCopyAttempt(key: RevisionCopyKey, copyId: string): boolean; + abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; +}): { + completeTurnRevisionCopyAttempt(draft: TDraft): void; + abandonTurnRevisionCopyAttempt(draft: TDraft): Promise; +} { + function completeTurnRevisionCopyAttempt(draft: TDraft): void { + deps.completeCopyAttempt(revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), draft.copyId); + } + + async function abandonTurnRevisionCopyAttempt(draft: TDraft): Promise { + const key = revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId); + deps.abandonCopyAttempt(key, draft.copyId); + try { + await deps.abandonSessionCopy(draft.sourceSessionId, draft.copyId); + deps.completeCopyAttempt(key, draft.copyId); + return true; + } catch { + return false; + } + } + + return { completeTurnRevisionCopyAttempt, abandonTurnRevisionCopyAttempt }; +} diff --git a/packages/ui/src/use-composer-attachments.ts b/packages/ui/src/use-composer-attachments.ts index 271fcf2d90..93c4f6542c 100644 --- a/packages/ui/src/use-composer-attachments.ts +++ b/packages/ui/src/use-composer-attachments.ts @@ -546,6 +546,10 @@ export function useComposerAttachments(options: { updateAttachments(() => ({})); } + /** Clears one draft key's pending attachments. The revision lifecycle uses + * this to clean up the source message's refs an edit staged when that edit + * is cancelled, under both the source and the branch child keys (#5274 + * review). */ return { pendingAttachments, pendingDirectories, From f531f7368033aec62f36e04313e566e359e2414a Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 29 Sep 2026 00:10:08 +0800 Subject: [PATCH 2/4] fix(desktop,ui): preserve the quote plate across the in-flight revision send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revision send awaited prepareRevisionSend before reading the plate, and the lifecycle re-keys it mid-send: the plate's current quotes are copied onto the branch child and the source bucket is emptied in place. The still-running sendWithAttachments invocation holds quotesForSend from the render that started the send, so it read the emptied source bucket, got undefined, and submitted the edited replacement without its quotes or their edited annotations (#5274 review). Read the plate through the re-keyed owner explicitly: quotesForSend now resolves the bucket by owner key (defaulting to the live draft key for the non-revision send paths), and the revision send passes the prepared draft's session id. The resumed send therefore reads the child bucket the lifecycle just wrote — removals and re-annotations included. The regression test drives beginEditUserMessage → an in-render quotesForSend capture → prepareRevisionSend and asserts the re-keyed plate with its edited annotation still reaches the send; it fails on the previous head with `actual: undefined`. Generated-by: GLM-5.3-Flash (ZCode) --- apps/desktop/renderer-architecture.json | 2 +- .../use-composer-quotes-revision-send.test.ts | 155 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 8 +- .../controller/use-composer-quotes.ts | 11 +- 4 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/use-composer-quotes-revision-send.test.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 7c5d9e8677..dd4dcac070 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -783,7 +783,7 @@ "react": 1 }, "importSpecifiers": 79, - "nonTriviaTokens": 12088 + "nonTriviaTokens": 12091 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/use-composer-quotes-revision-send.test.ts b/apps/desktop/src/main/__tests__/use-composer-quotes-revision-send.test.ts new file mode 100644 index 0000000000..8107330ade --- /dev/null +++ b/apps/desktop/src/main/__tests__/use-composer-quotes-revision-send.test.ts @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import type { QuoteRef } from '@maka/core/events'; +import type { StoredMessage } from '@maka/core/session'; +import { useComposerQuotes } from '../../renderer/features/conversation/index.js'; +import { + createAppShellRevisionActions, + type TurnRevisionDraft, +} from '../../renderer/app-shell-revision-actions.js'; +import { installWindow } from './app-shell-chat-actions-fixture.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +const SESSION_1 = JSON.stringify(['host-1', 'session-1']); +const SESSION_2 = JSON.stringify(['host-1', 'session-2']); + +function userMessage( + turnId: string, + text: string, + extra: Record = {}, +): StoredMessage { + return { + id: `msg-${turnId}`, + type: 'user', + turnId, + ts: 1, + text, + ...extra, + } as StoredMessage; +} + +afterEach(cleanupFakeDom); + +test('the in-flight revision send still reads the re-keyed plate (#5274 review)', async () => { + // The AppShell send is an in-flight closure: it captures quotesForSend from + // the render that started the send (draft key = the source Session), then + // awaits prepareRevisionSend, which copies the plate onto the branch child + // and empties the source bucket in place. The resumed send must therefore + // read the plate through the re-keyed owner key, or the replacement goes + // out without its quotes and their edited annotations. + const quotedQuote: QuoteRef = { text: 'a large pasted excerpt', sourceTurnId: 'turn-0' }; + const { root } = installReactRenderer(); + const activeIdRef: { current: string | undefined } = { current: SESSION_1 }; + const revisionDraftRef: { current: TurnRevisionDraft | null } = { current: null }; + const composerText = { current: '' }; + + let surface!: ReturnType; + function Probe() { + surface = useComposerQuotes({ draftKey: activeIdRef.current ?? SESSION_1 }); + return null; + } + await act(async () => root.render(createElement(Probe))); + + const actions = createAppShellRevisionActions({ + uiLocale: 'en' as never, + activeIdRef, + captureSelection: () => () => true, + composerRef: { + current: { + getText: () => composerText.current, + setText: (text: string) => { + composerText.current = text; + }, + focus: () => {}, + setDraft: (_sessionId: string, text: string) => { + composerText.current = text; + }, + clearDraft: () => {}, + } as never, + }, + messages: [userMessage('turn-1', 'explain this', { quotes: [quotedQuote] })], + hasPendingAttachments: () => false, + stagedContext: () => ({ + quotes: surface.pendingQuotes, + attachments: [], + restoreQuotes: surface.restoreQuotes, + clearQuotes: surface.clearQuotes, + }), + openSessionInChat: (sessionId: string) => { + activeIdRef.current = sessionId; + }, + refreshSessions: async () => [], + commitRevisionDraft: (draft: TurnRevisionDraft | null) => { + revisionDraftRef.current = draft; + }, + revisionDraftRef, + toastApi: { info: () => {}, error: () => {} }, + } as never); + + const restoreWindow = installWindow({ + sessions: { + reviseBeforeTurn: async () => ({ id: SESSION_2 }), + abandonSessionCopy: async () => {}, + }, + }); + try { + // Edit click stages the source message's quote under the source key. + await act(async () => actions.beginEditUserMessage('turn-1')); + assert.deepEqual(surface.pendingQuotes, [quotedQuote]); + + // During the edit the user re-annotates the excerpt — the plate is the + // truth the replacement must carry (#5274 review). + await act(async () => surface.updateQuoteComment(0, 'the edited annotation')); + + // The send captures quotesForSend from this render, then awaits the + // revision lifecycle — exactly the app-shell send's shape. + const quotesForSendAtSendStart: (ownerKey?: string) => readonly QuoteRef[] | undefined = + surface.quotesForSend; + assert.deepEqual(quotesForSendAtSendStart(), [ + { text: 'a large pasted excerpt', sourceTurnId: 'turn-0', comment: 'the edited annotation' }, + ]); + + await act(async () => { + assert.equal(await actions.prepareRevisionSend('edited text'), true); + }); + const expectedRevisionDraft = revisionDraftRef.current; + assert.equal(expectedRevisionDraft?.draftSessionId, SESSION_2); + + // The resumed send reads the plate through the re-keyed owner: the + // render-time closure still points at the emptied source bucket. + const quotes = quotesForSendAtSendStart(expectedRevisionDraft?.draftSessionId); + assert.deepEqual( + quotes, + [ + { + text: 'a large pasted excerpt', + sourceTurnId: 'turn-0', + comment: 'the edited annotation', + }, + ], + 'the in-flight send must deliver the re-keyed plate, edits included', + ); + } finally { + restoreWindow(); + } +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5cf7fc7ce4..9ed1d20828 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1583,10 +1583,10 @@ function AppShellContent({ : undefined; const pending = submittableAttachments; // The re-key lands the plate's current quotes on the branch child before - // this send, so the live read carries exactly what the user staged — - // including removals and re-annotations made during the edit (#5274 - // review). - const quotes = quotesForSend(); + // this send, while this invocation's closure still points at the emptied + // source bucket — read the re-keyed owner explicitly so the replacement + // carries exactly what the user staged, edits included (#5274 review). + const quotes = quotesForSend(expectedRevisionDraft?.draftSessionId); const ok = await send(text, pending, { waitForHostAdmission: revisionSend, targetSessionId: expectedRevisionDraft?.draftSessionId, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts index 94e510e76b..c7a6e4c83f 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -105,8 +105,15 @@ export function useComposerQuotes(options: { readonly draftKey: string }) { const tryAnnotateQuote = (index: number): boolean => chatViewRef.current?.openQuoteAnnotation(index) ?? false; - const quotesForSend = (): QuoteRef[] | undefined => - bucket.length ? bucket : undefined; + // An in-flight send must not be bound to the render that started it: the + // revision lifecycle re-keys the plate onto the branch child mid-send and + // empties the source bucket in place (#5274 review), so the resumed send + // reads through the re-keyed owner key. The ref is stable across renders + // and buckets are mutated in place, never replaced. + const quotesForSend = (ownerKey = options.draftKey): QuoteRef[] | undefined => { + const target = pendingByKeyRef.current[ownerKey]; + return target?.length ? target : undefined; + }; return { pendingQuotes: bucket, From ddd4c952267443e1d18b8255497e3f54d0b0c06c Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 29 Sep 2026 06:31:58 +0800 Subject: [PATCH 3/4] fix(desktop,ui): keep quotes added during a cancelled edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling a revision draft cleared both draft keys wholesale, so quotes the user staged during the edit were lost together with the edit's own restaged set (#5274 review: "Cancelling an edit deletes quotes added during it" — on main they stayed in the composer, and adding quotes during an edit is supported). clearQuotes now returns the entries it removed, and clearRevisionStagedContext re-stages whatever lies beyond the edit's beginEdit snapshot (matched per QuoteRef field set, counted as a multiset) onto the source Session the cancel returns to — whether the user added them before or after the branch child was prepared. The edit's own items are still dropped wherever the commit left them. The regression tests drive a cancelled edit with a user-added quote on each side of the prepare step and assert the addition survives on the source key while the composer text rolls back; both fail on the previous head, the first with the edit's restaged quote returned instead of the user's own, the second with no plate restore at all. Generated-by: GLM-5.3-Flash (ZCode) --- .../app-shell-revision-actions.test.ts | 4 +- .../controller/use-composer-quotes.ts | 9 +- .../__tests__/revision-staged-context.test.ts | 93 ++++++++++++++++++- packages/ui/src/revision-staged-context.ts | 47 +++++++--- 4 files changed, 132 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts index 8ab2a8ad99..e57b9cc595 100644 --- a/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-revision-actions.test.ts @@ -89,7 +89,7 @@ function createActions(input: { messages: StoredMessage[]; failRefresh?: boolean }, clearQuotes: (ownerKey: string) => { staged.clearedKeys.push(ownerKey); - staged.quotes.length = 0; + return staged.quotes.splice(0, staged.quotes.length); }, }), openSessionInChat: (sessionId: string) => { @@ -323,7 +323,7 @@ describe('revision draft lifecycle over a prepared send', () => { quotes: [], attachments: [], restoreQuotes: (_ownerKey: string, _quotes: unknown[]) => {}, - clearQuotes: (_ownerKey: string) => {}, + clearQuotes: (_ownerKey: string) => [], }), openSessionInChat: (sessionId: string) => { selectionRevision += 1; diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts index c7a6e4c83f..80f2386438 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-quotes.ts @@ -81,11 +81,14 @@ export function useComposerQuotes(options: { readonly draftKey: string }) { // An explicit owner key clears another draft's bucket — the revision // lifecycle re-keys its restored quotes across the commit and clears both // the source and the branch-child keys (#5109 review); the live draft is - // the default for composer flows. - const clearQuotes = useCallback((ownerKey = options.draftKey): void => { + // the default for composer flows. The removed entries come back so the + // cancel path can re-stage the ones the edit did not stage itself + // (#5274 review). + const clearQuotes = useCallback((ownerKey = options.draftKey): QuoteRef[] => { const target = pendingByKeyRef.current[ownerKey]; - if (target) target.splice(0, target.length); + const removed = target ? target.splice(0, target.length) : []; publish(); + return removed; }, [options.draftKey, publish]); // The revision lifecycle re-keys the selected message's quotes onto the diff --git a/packages/ui/src/__tests__/revision-staged-context.test.ts b/packages/ui/src/__tests__/revision-staged-context.test.ts index a4bc65a725..1b3ca353c9 100644 --- a/packages/ui/src/__tests__/revision-staged-context.test.ts +++ b/packages/ui/src/__tests__/revision-staged-context.test.ts @@ -59,24 +59,39 @@ function userMessage(turnId: string, text: string, extra: Record; cleared: string[]; + /** The initial active Session's bucket — the plate the hooks bind to. */ quotes: QuoteRef[]; + /** Per-owner buckets, so a clear only ever empties the key it names. */ + buckets: Map; }; function emptyStagedLog(): StagedLog { - return { restored: [], cleared: [], quotes: [] }; + const buckets = new Map(); + const quotes: QuoteRef[] = []; + buckets.set('session-1', quotes); + return { restored: [], cleared: [], quotes, buckets }; } function fakeStaged(log: StagedLog): RevisionStagedContext { + const bucketOf = (ownerKey: string): QuoteRef[] => { + let bucket = log.buckets.get(ownerKey); + if (!bucket) { + bucket = []; + log.buckets.set(ownerKey, bucket); + } + return bucket; + }; return { quotes: log.quotes, attachments: [], restoreQuotes: (ownerKey, quotes) => { + if (quotes.length === 0) return; log.restored.push({ ownerKey, quotes: [...quotes] }); - log.quotes.push(...quotes); + bucketOf(ownerKey).push(...quotes); }, clearQuotes: (ownerKey) => { log.cleared.push(ownerKey); - log.quotes.length = 0; + return bucketOf(ownerKey).splice(0); }, }; } @@ -295,6 +310,59 @@ describe('revision lifecycle (#5109)', () => { assert.equal(staged.quotes.length, 0, 'nothing stays staged after the cancel'); assert.equal(h.revisionDraftRef.current, null); }); + + it('keeps quotes the user added when an edit is cancelled (#5274 review)', async () => { + const staged = emptyStagedLog(); + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this', { quotes: [quotedQuote] })], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + // Adding quotes during an edit is supported, so cancelling the edit + // undoes the edit — not the user's own staging (#5274 review). + const added: QuoteRef = { text: 'my own excerpt' }; + staged.quotes.push(added); + await actions.cancelRevisionDraft(); + + assert.equal(h.revisionDraftRef.current, null); + assert.equal(h.env.composerRef.current?.getText(), '', 'the composer text rolls back'); + assert.deepEqual( + staged.restored.at(-1), + { ownerKey: 'session-1', quotes: [added] }, + 'the user-added quote survives the cancel on the source key', + ); + }); + + it('keeps a quote added on the branch child when a prepared edit is cancelled', async () => { + const staged = emptyStagedLog(); + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this')], + staged, + }); + const actions = createRevisionActions(h.env); + + actions.beginEditUserMessage('turn-1'); + assert.equal(await actions.prepareRevisionSend('edited text'), true); + const added: QuoteRef = { text: 'added while ready to send' }; + // The plate re-keyed onto the branch child, so the composer the user + // stages into now binds to the branch-child bucket. + let branchBucket = staged.buckets.get('session-2'); + if (!branchBucket) { + branchBucket = []; + staged.buckets.set('session-2', branchBucket); + } + branchBucket.push(added); + await actions.cancelRevisionDraft(); + + assert.equal(h.revisionDraftRef.current, null); + assert.deepEqual( + staged.restored.at(-1), + { ownerKey: 'session-1', quotes: [added] }, + 'the branch-child addition is re-keyed onto the source session', + ); + }); }); describe('revision send gate', () => { @@ -430,13 +498,28 @@ describe('revision staged-context helpers', () => { ); }); - it('clears the staged quotes under every owner key once', () => { + it('clears every owner key once and keeps what the edit did not stage', () => { const cleared: string[] = []; + const restored: Array<{ ownerKey: string; quotes: readonly QuoteRef[] }> = []; + const restoredByEdit = { text: 'restored by the edit' }; + const addedByUser = { text: 'added during the edit' }; clearRevisionStagedContext( - { clearQuotes: (ownerKey) => cleared.push(ownerKey) }, + { + restoreQuotes: (ownerKey, quotes) => restored.push({ ownerKey, quotes }), + clearQuotes: (ownerKey) => { + cleared.push(ownerKey); + return ownerKey === 'session-1' ? [restoredByEdit] : [addedByUser]; + }, + }, ['session-1', 'session-2', 'session-1'], + [restoredByEdit], ); assert.deepEqual(cleared, ['session-1', 'session-2']); + assert.deepEqual( + restored, + [{ ownerKey: 'session-1', quotes: [addedByUser] }], + 'the user-added quote re-keys onto the source Session', + ); }); it('compares text and quotes for the unchanged retry', () => { diff --git a/packages/ui/src/revision-staged-context.ts b/packages/ui/src/revision-staged-context.ts index ddab7f6efe..8a7fa66c26 100644 --- a/packages/ui/src/revision-staged-context.ts +++ b/packages/ui/src/revision-staged-context.ts @@ -36,7 +36,9 @@ export type RevisionStagedContext = { quotes: readonly QuoteRef[]; attachments: readonly PendingAttachment[]; restoreQuotes(ownerKey: string, quotes: readonly QuoteRef[]): void; - clearQuotes(ownerKey: string): void; + /** Removes and returns what an owner key holds, so the cancel path can + * re-stage the entries the edit did not stage itself (#5274 review). */ + clearQuotes(ownerKey: string): readonly QuoteRef[]; }; /** The edit-and-resend source context a staged plate must match verbatim. */ @@ -167,16 +169,35 @@ export function revisionSendGate( } /** - * Unstage everything the edit staged, wherever the commit left it — the - * cancel path. The plates hold only the edit's items under the two draft - * keys (source before the commit, branch child after), because editing is - * refused while the user has own context staged. + * Unstage the edit's staged context from under every draft key — the cancel + * path. Entries the edit staged itself (the beginEdit snapshot) are dropped + * wherever the commit left them (source key before it, branch child after); + * anything else on those plates is the user's own staging, added during the + * edit, so it survives — re-keyed onto the first owner key, the source + * Session the cancel returns to (#5274 review). */ export function clearRevisionStagedContext( - staged: Pick, + staged: Pick, ownerKeys: readonly string[], + editOwnedQuotes: readonly QuoteRef[], ): void { - for (const ownerKey of new Set(ownerKeys)) staged.clearQuotes(ownerKey); + const keys = [...new Set(ownerKeys)]; + const owned = new Map(); + for (const quote of editOwnedQuotes) { + const key = quoteKey(quote); + owned.set(key, (owned.get(key) ?? 0) + 1); + } + const removed: QuoteRef[] = []; + for (const ownerKey of keys) removed.push(...staged.clearQuotes(ownerKey)); + const kept: QuoteRef[] = []; + for (const quote of removed) { + // Past the edit's own per-quote count, every entry is the user's own. + const key = quoteKey(quote); + const ownedCount = owned.get(key) ?? 0; + owned.set(key, ownedCount - 1); + if (ownedCount === 0) kept.push(quote); + } + if (keys.length > 0 && kept.length > 0) staged.restoreQuotes(keys[0], kept); } /** Localized strings an edit-and-resend surface needs from its own catalog. */ @@ -573,10 +594,14 @@ export function createRevisionActions< if (cleanupSessionId) await abandonRevisionCopy(draft); else env.completeCopyAttempt(revisionCopyKey(draft.sourceSessionId, draft.sourceTurnId), draft.copyId); commitRevisionDraft(null); - // Unstage everything the edit staged (#5109), under both draft keys: the - // plate starts on the source key and the commit re-keys it onto the - // branch child. The edit refuses while the user has own context staged. - clearRevisionStagedContext(stagedContext(), [draft.sourceSessionId, draft.draftSessionId]); + // Unstage the edit's staged context under both draft keys (source before + // the commit, branch child after); quotes the user added during the edit + // are not the edit's, so they survive on the source key (#5274 review). + clearRevisionStagedContext( + stagedContext(), + [draft.sourceSessionId, draft.draftSessionId], + draft.originalQuotes, + ); composerRef.current?.setDraft(draft.sourceSessionId, draft.previousComposerText); if (draft.draftSessionId !== draft.sourceSessionId) { composerRef.current?.clearDraft(draft.draftSessionId); From e39891bf400806cea9175eb403ff1eaf12b6e258 Mon Sep 17 00:00:00 2001 From: ggbdpq Date: Tue, 29 Sep 2026 07:43:44 +0800 Subject: [PATCH 4/4] fix(ui): treat negative owned counts as fully user-owned clearRevisionStagedContext drops the edit's own quotes by per-key count: the first N entries matching the beginEdit snapshot are the edit's, and everything past that count is the user's own staging. The kept check compared the remaining count with `=== 0`, so once the counter ran negative the surplus stopped matching: when the user added a second copy of a quote the edit had also staged, the third identical plate entry was dropped despite being the user's. Treat any non-positive count as fully user-owned (`<= 0`), so every entry past the edit's own count survives the cancel. Fixes a P3 raised inline on apache/maka#5274. Generated-by: GLM-5.3-Flash (ZCode) --- .../__tests__/revision-staged-context.test.ts | 22 +++++++++++++++++++ packages/ui/src/revision-staged-context.ts | 6 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/__tests__/revision-staged-context.test.ts b/packages/ui/src/__tests__/revision-staged-context.test.ts index 1b3ca353c9..83389b48e7 100644 --- a/packages/ui/src/__tests__/revision-staged-context.test.ts +++ b/packages/ui/src/__tests__/revision-staged-context.test.ts @@ -522,6 +522,28 @@ describe('revision staged-context helpers', () => { ); }); + it('keeps every user copy of a quote the edit also staged (#5274 review)', () => { + // The edit stages the source excerpt once and the user adds the identical + // excerpt twice during the edit: per-key counting means entries past the + // edit's own count are the user's, so the counter running negative must + // not drop the second copy — `=== 0` missed it (#5274 review). + const restored: Array<{ ownerKey: string; quotes: readonly QuoteRef[] }> = []; + const identical: QuoteRef = { text: 'the same excerpt' }; + clearRevisionStagedContext( + { + restoreQuotes: (ownerKey, quotes) => restored.push({ ownerKey, quotes }), + clearQuotes: (ownerKey) => (ownerKey === 'session-1' ? [identical, identical, identical] : []), + }, + ['session-1'], + [identical], + ); + assert.deepEqual( + restored, + [{ ownerKey: 'session-1', quotes: [identical, identical] }], + 'only the edit-owned entry is dropped; both user copies survive', + ); + }); + it('compares text and quotes for the unchanged retry', () => { const source: RevisionStagedSource = { originalQuotes: [quotedQuote], originalAttachments: [] }; assert.equal( diff --git a/packages/ui/src/revision-staged-context.ts b/packages/ui/src/revision-staged-context.ts index 8a7fa66c26..a6bbae2f9d 100644 --- a/packages/ui/src/revision-staged-context.ts +++ b/packages/ui/src/revision-staged-context.ts @@ -191,11 +191,13 @@ export function clearRevisionStagedContext( for (const ownerKey of keys) removed.push(...staged.clearQuotes(ownerKey)); const kept: QuoteRef[] = []; for (const quote of removed) { - // Past the edit's own per-quote count, every entry is the user's own. + // Past the edit's own per-quote count, every entry is the user's own — + // including once the counter has run negative past zero, because a user + // addition can duplicate a quote the edit staged itself (#5274 review). const key = quoteKey(quote); const ownedCount = owned.get(key) ?? 0; owned.set(key, ownedCount - 1); - if (ownedCount === 0) kept.push(quote); + if (ownedCount <= 0) kept.push(quote); } if (keys.length > 0 && kept.length > 0) staged.restoreQuotes(keys[0], kept); }