diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 63b17192dd..8a96cc61af 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -310,7 +310,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, @@ -336,11 +336,11 @@ "@maka/core/session-name": 1, "@maka/ui": 1 }, - "importSpecifiers": 10, + "importSpecifiers": 9, "nonTriviaTokens": 3603 }, "src/renderer/app-shell-chrome-actions.tsx": { - "importDeclarations": 4, + "importDeclarations": 2, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -356,7 +356,7 @@ "@maka/ui": 1, "@maka/ui/icons": 1 }, - "importSpecifiers": 7, + "importSpecifiers": 2, "nonTriviaTokens": 408 }, "src/renderer/app-shell-command-actions.ts": { @@ -506,7 +506,7 @@ "nonTriviaTokens": 3677 }, "src/renderer/app-shell-overlays.tsx": { - "importDeclarations": 5, + "importDeclarations": 4, "bridgePaths": {}, "environmentCapabilities": { "window.addEventListener": 1, @@ -532,7 +532,7 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 8, + "importSpecifiers": 7, "nonTriviaTokens": 860 }, "src/renderer/app-shell-project-actions.ts": { @@ -566,7 +566,7 @@ "nonTriviaTokens": 2284 }, "src/renderer/app-shell-revision-actions.ts": { - "importDeclarations": 4, + "importDeclarations": 3, "bridgePaths": { "window.maka.sessions.abandonSessionCopy": 2, "window.maka.sessions.reviseBeforeTurn": 1 @@ -584,13 +584,13 @@ "./platform/desktop/session-message-settlement.js": 1, "./session-copy-attempt.js": 1, "./session-workspace-errors.js": 1, - "@maka/core/session": 1 + "@maka/ui": 1 }, - "importSpecifiers": 8, - "nonTriviaTokens": 2155 + "importSpecifiers": 4, + "nonTriviaTokens": 537 }, "src/renderer/app-shell-session-events.ts": { - "importDeclarations": 2, + "importDeclarations": 1, "bridgePaths": {}, "environmentCapabilities": { "window.setTimeout": 1 @@ -609,7 +609,7 @@ "./model-connection-errors.js": 1, "@maka/ui": 1 }, - "importSpecifiers": 7, + "importSpecifiers": 1, "nonTriviaTokens": 2687 }, "src/renderer/app-shell-session-start-actions.ts": { @@ -689,7 +689,7 @@ "nonTriviaTokens": 620 }, "src/renderer/app-shell-turn-view-model.ts": { - "importDeclarations": 6, + "importDeclarations": 5, "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { @@ -708,11 +708,11 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 10, + "importSpecifiers": 7, "nonTriviaTokens": 1273 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 64, + "importDeclarations": 63, "bridgePaths": { "window.maka.attachments": 1, "window.maka.attachments.readBytes": 1, @@ -861,8 +861,8 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 100, - "nonTriviaTokens": 13135 + "importSpecifiers": 88, + "nonTriviaTokens": 13089 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, @@ -879,7 +879,7 @@ "nonTriviaTokens": 24 }, "src/renderer/use-app-shell-session-list.ts": { - "importDeclarations": 6, + "importDeclarations": 5, "bridgePaths": { "window.maka.sessions.list": 1 }, @@ -903,7 +903,7 @@ "@maka/ui": 1, "react": 1 }, - "importSpecifiers": 9, + "importSpecifiers": 8, "nonTriviaTokens": 486 }, "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 378e706d5e..e8e23c92da 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', @@ -3101,9 +3105,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 5114c6ac88..95d130b228 100644 --- a/apps/desktop/scripts/check-renderer-architecture.test.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.test.mjs @@ -3454,6 +3454,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 d7f35be7f7..db5b631e43 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 @@ -37,6 +37,11 @@ function userMessage(turnId: string, text: string, extra: Record false, + stagedContext: () => ({ + quotes: staged.quotes, + attachments: [], + restoreQuotes: (_ownerKey: string, quotes: unknown[]) => { + staged.restoredQuotes.push(quotes); + staged.quotes.push(...quotes); + }, + clearQuotes: (ownerKey: string) => { + staged.clearedKeys.push(ownerKey); + staged.quotes.length = 0; + }, + }), openSessionInChat: () => {}, refreshMessages: async () => true, refreshSessions: async () => [], @@ -70,7 +87,11 @@ function createActions(input: { messages: StoredMessage[] }) { error: () => {}, }, } as never); - return Object.assign(actions, { drafts, composerState: { get text(): string { return composerText; } } }); + return Object.assign(actions, { + drafts, + staged, + composerState: { get text(): string { return composerText; } }, + }); } describe('app-shell revision actions with structured context (#5109)', () => { @@ -98,7 +119,7 @@ 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 (#5109 review)', () => { const h = createActions({ messages: [ userMessage('turn-1', 'with image', { @@ -117,6 +138,29 @@ 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', + ); }); }); diff --git a/apps/desktop/src/renderer/app-shell-revision-actions.ts b/apps/desktop/src/renderer/app-shell-revision-actions.ts index 9325d66de7..3b6b0d08b0 100644 --- a/apps/desktop/src/renderer/app-shell-revision-actions.ts +++ b/apps/desktop/src/renderer/app-shell-revision-actions.ts @@ -17,57 +17,49 @@ * 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 { readSettledMessages } from './platform/desktop/session-message-settlement.js'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; import { localizedShellErrorMessage } from './locales/shell-copy.js'; -import { - isSessionWorkspaceUnavailableError, - showSessionWorkspaceUnavailableToast, -} from './session-workspace-errors.js'; -import { - acquireSessionCopyAttempt, - abandonSessionCopyAttempt, - completeSessionCopyAttempt, - startSessionCopyAttempt, - type SessionCopyAttemptPhase, - type SessionCopyAttemptKey, -} from './session-copy-attempt.js'; -import { readSettledMessages } from './platform/desktop/session-message-settlement.js'; import type { MessageListUpdater } from './session-workspace-actions.js'; +import { isSessionWorkspaceUnavailableError, showSessionWorkspaceUnavailableToast } from './session-workspace-errors.js'; +import { + createRevisionActions, + createTurnRevisionCopyHelpers, + type RevisionActionsEnv, + type TurnRevisionDraftBase, +} from '@maka/ui'; -type RefBox = { current: T }; - -type ToastApi = { - info(title: string, description?: string): void; - error( - title: string, - description?: string, - diagnosticDetails?: string, - diagnosticTarget?: { sessionId: string }, - ): void; -}; - -/** 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' + | 'readSettledMessages' + | 'reportSessionWorkspaceUnavailable' + | 'localizedShellErrorMessage' + | 'acquireCopyAttempt' + | 'startCopyAttempt' + | 'abandonCopyAttempt' + | 'completeCopyAttempt' + | 'commitRevisionDraft' + | 'setMessages' +> & { + /** The shell's draft state is bound to the concrete desktop draft type. */ + commitRevisionDraft(draft: TurnRevisionDraft | null): void; + /** The shell's updater accepts a reactive `next` form; the lifecycle only + * ever passes a settled readonly array. */ + setMessages: MessageListUpdater; }; export interface AppShellRevisionActions { beginEditUserMessage(turnId: string): void; - /** Lazily create the before-turn branch immediately before normal send. */ prepareRevisionSend(text: string): Promise; cancelRevisionDraft(): Promise; } @@ -81,341 +73,58 @@ 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; - setMessages: MessageListUpdater; - commitRevisionDraft: (draft: TurnRevisionDraft | null) => void; - revisionDraftRef: RefBox; - toastApi: ToastApi; -}): AppShellRevisionActions { - const { - uiLocale, - activeIdRef, - captureSelection, - composerRef, - messages, - hasPendingAttachments, - openSessionInChat, - refreshSessions, - setMessages, - commitRevisionDraft, - revisionDraftRef, - toastApi, - } = deps; - const copy = getDesktopConversationCopy(uiLocale).actions; - let revisionPreparationAbort: AbortController | undefined; - - 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; - const preparationAbort = new AbortController(); - revisionPreparationAbort?.abort(); - revisionPreparationAbort = preparationAbort; - 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(); - const { messages: preparedMessages, settled } = await readSettledMessages(newSession.id, { - signal: preparationAbort.signal, - }); - if (!settled) throw new Error('Revised Session transcript did not become ready'); - if ( - !selectionIsCurrent() || activeIdRef.current !== newSession.id || - revisionDraftRef.current !== prepared - ) { - await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); - return false; - } - setMessages(preparedMessages); - composerRef.current?.focus(); - toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription); - await refreshSessions(); +export function createAppShellRevisionActions( + deps: DesktopRevisionActionsDeps, +): AppShellRevisionActions { + const actions = createRevisionActions({ + ...deps, + commitRevisionDraft: (draft) => deps.commitRevisionDraft(draft as TurnRevisionDraft), + setMessages: (messages) => deps.setMessages([...messages]), + copy: getDesktopConversationCopy(deps.uiLocale).actions, + reviseBeforeTurn: (sourceSessionId, input) => + window.maka.sessions.reviseBeforeTurn(sourceSessionId, input), + abandonSessionCopy: (sourceSessionId, copyId) => + window.maka.sessions.abandonSessionCopy(sourceSessionId, copyId), + readSettledMessages: (sessionId, options) => readSettledMessages(sessionId, options), + 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) { - if (preparationAbort.signal.aborted) return false; - if (preparedSessionId) { - await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent); - } - if (!selectionIsCurrent()) return false; - if (isSessionWorkspaceUnavailableError(error)) { - showSessionWorkspaceUnavailableToast(toastApi, uiLocale, { - sessionId: sourceSessionId, - }); - } else { - toastApi.error( - copy.operationFailedTitle, - localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), - undefined, - { sessionId: sourceSessionId }, - ); - } - return false; - } finally { - if (revisionPreparationAbort === preparationAbort) revisionPreparationAbort = undefined; - } - } - - async function cancelRevisionDraft(): Promise { - let selectionIsCurrent = captureSelection(); - revisionPreparationAbort?.abort(); - 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 a1d809e558..26808984e0 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1492,6 +1492,12 @@ function AppShellContent({ composerRef, messages, hasPendingAttachments: () => hasPendingContext, + stagedContext: () => ({ + quotes: pendingQuotes, + attachments: submittableAttachments ?? [], + restoreQuotes, + clearQuotes, + }), openSessionInChat, refreshSessions, setMessages, @@ -1581,26 +1587,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') { 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 c6bc2c93a5..6cea77f616 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 @@ -72,10 +72,15 @@ 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]); const clearAllQuotes = useCallback((): void => { for (const quotes of Object.values(pendingByKeyRef.current)) quotes.splice(0, quotes.length); diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 2301501e1a..d12c020647 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -44,6 +44,8 @@ export interface DesktopConversationCopy { revisionReadyDescription: string; revisionUnavailableTitle: string; revisionAttachmentsUnsupported: string; + revisionDraftQuoteConflict: string; + revisionMixedContextUnsupported: string; revisionTransformedTextUnsupported: string; revisionDraftAttachmentConflict: string; revisionCommandUnsupported: string; @@ -332,7 +334,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { 'zh-CN': { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '这条消息自带的附件不参与编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '携带附件的消息暂不支持编辑重发,附件无法跟随进入新版本。', revisionDraftQuoteConflict: 'Composer 中已有暂存引用,请先发送或移除引用,再编辑历史消息。', revisionMixedContextUnsupported: '编辑期间新增的暂存内容不能与恢复的引用一起发送,请取消本次编辑后重试。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', imageAttachmentNotDirectTitle: '图片已作为附件添加', imageAttachmentNotDirectDescription: '当前模型不会直接接收图片。图片已作为附件提供给模型。', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', scrollMainToBottom: '滚动主对话到底部' }, model: { fakeBackendLabel: '本地模拟连接', setupTitle: '等待配置真实模型', @@ -575,7 +577,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: '任務執行失敗,請稍後重試。', regenerateStartedTitle: '已發起重新生成', regenerateStartedDescription: '正在生成新的一輪迴答', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '這條訊息自帶的附件不參與編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。如需重新回答,請使用“重新生成”。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, + actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', regenerateStartedTitle: '已發起重新生成', regenerateStartedDescription: '正在生成新的一輪迴答', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '攜帶附件的訊息暫不支援編輯重發,附件無法跟隨進入新版本。', revisionDraftQuoteConflict: 'Composer 中已有暫存引用,請先發送或移除引用,再編輯歷史訊息。', revisionMixedContextUnsupported: '編輯期間新增的暫存內容不能與恢復的引用一起傳送,請取消本次編輯後重試。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。如需重新回答,請使用“重新生成”。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, model: { fakeBackendLabel: '本地模擬連線', setupTitle: '等待設定真實模型', @@ -809,7 +811,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.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', 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. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', 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.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', 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: 'Messages with attachments cannot be edited yet: attachments cannot follow into the new version.', 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.', 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. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', 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', 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 bbd2c55eb5..856e90a530 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -408,6 +408,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..c0283c76f2 --- /dev/null +++ b/packages/ui/src/__tests__/revision-staged-context.test.ts @@ -0,0 +1,339 @@ +/* + * 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 { 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', + revisionAlreadyActive: 'already-active', + revisionDraftAttachmentConflict: 'draft-attachment-conflict', + revisionDraftQuoteConflict: 'draft-quote-conflict', + revisionAttachmentsUnsupported: 'source-attachments-unsupported', + 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; + preparedMessages?: StoredMessage[]; +}) { + const activeIdRef = { current: 'session-1' }; + const revisionDraftRef: { current: TurnRevisionDraftBase | null } = { current: null }; + const toasts: Array<{ kind: 'info' | 'error'; title: string; description?: string }> = []; + const readSettledCalls: 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 () => [], + setMessages: () => {}, + 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 () => {}, + readSettledMessages: async (sessionId) => { + readSettledCalls.push(sessionId); + return { messages: input.preparedMessages ?? [], settled: true }; + }, + 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, readSettledCalls }; +} + +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 must read the draft snapshot, not the + // transcript (#5109 review). + const h = createEnv({ + messages: [userMessage('turn-1', 'explain this', { quotes: [quotedQuote] })], + staged, + preparedMessages: [userMessage('turn-0', 'earlier question')], + }); + 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(h.readSettledCalls, ['session-2']); + 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('refuses to edit a message that carries attachments', () => { + 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, + preparedMessages: [], + }); + 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('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, + [], + 'attachments fail closed: no target-owned refs exist to stage (#5109 review)', + ); + }); + + 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 2e75773213..2c1874172d 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -597,28 +597,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 c2fbf53d9e..9c51566f0f 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -311,9 +311,7 @@ export interface ConversationCopy { copy: string; editMessage: string; editMessageDisabledRunning: string; - editMessageDisabledAttachments: string; editMessageDisabledDirectoryReferences: string; - editMessageDisabledQuotes: string; editMessageDisabledTransformedText: string; userAriaLabel: string; systemAriaLabel: string; @@ -567,7 +565,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], processDetails: '执行过程', processDuration: (minutes, seconds) => `用时 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, 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: '包含已展开上下文的历史消息暂不支持编辑并重发', + you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], processDetails: '执行过程', processDuration: (minutes, seconds) => `用时 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, 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}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', sessionSnapshotLabel: (name) => `会话:${name}`, sessionSnapshotPending: '发送时截取快照', sessionSnapshotCaptured: (iso, truncated) => `快照时间 ${iso}${truncated ? ' · 内容已截断' : ''}`, quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发', @@ -725,7 +723,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `選擇專案:${label},目前分支 ${branch}` : `選擇專案:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盤算…', '正在鑽研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓搗…', '正在醞釀…', '正在攻堅…', '正在權衡…', '正在拾掇…'], processDetails: '執行過程', processDuration: (minutes, seconds) => `用時 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, 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: '包含已展開上下文的歷史訊息暫不支援編輯並重發', + you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盤算…', '正在鑽研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓搗…', '正在醞釀…', '正在攻堅…', '正在權衡…', '正在拾掇…'], processDetails: '執行過程', processDuration: (minutes, seconds) => `用時 ${minutes > 0 ? `${minutes} 分 ` : ''}${seconds} 秒`, 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}`, agentGraphTriggered: 'Agent Graph 自動繼續', agentGraphTitle: (graphId) => `由 Agent Graph 排程器觸發 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截斷;顯示的是最近的內容', outputTruncatedTitle: '助手輸出已超過單次回合上限,超出部分未渲染。如需完整內容請重新生成或檢視持久化的任務記錄。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', sessionSnapshotLabel: (name) => `作業階段:${name}`, sessionSnapshotPending: '傳送時擷取快照', sessionSnapshotCaptured: (iso, truncated) => `快照時間 ${iso}${truncated ? ' · 內容已截斷' : ''}`, quoteExpandAriaLabel: '展開引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中斷)', abortedByStop: '(已中斷 · 由停止按鈕觸發)', @@ -909,7 +907,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`, }, messages: { - you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', 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`, 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…', continuing: 'Continuing…', 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`, 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}`, 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}`, quoteLabel: 'Quote', sessionSnapshotLabel: (name) => `Session: ${name}`, sessionSnapshotPending: 'snapshot captured when sent', sessionSnapshotCaptured: (iso, truncated) => `captured ${iso}${truncated ? ' · truncated' : ''}`, quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: 'Interrupted', abortedByStop: 'Interrupted · Stop button', diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bcc5ec32c6..900c79e31d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -194,3 +194,4 @@ export { export { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js'; export { ChoicePanel, type ChoicePanelOption } from './choice-panel.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..9fc3ed83ee --- /dev/null +++ b/packages/ui/src/revision-staged-context.ts @@ -0,0 +1,640 @@ +/* + * 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 never enter the plates — a revision + * copy excludes the revised turn, so no target-owned refs exist to stage + * (#5109 review) and the lifecycle reads the plate only for conflict gates. + */ +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 { + return JSON.stringify([quote.text, quote.label ?? null, quote.sourceTurnId ?? 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; + revisionDraftAttachmentConflict: string; + revisionDraftQuoteConflict: string; + revisionAttachmentsUnsupported: 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; + setMessages(messages: readonly StoredMessage[]): void; + 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; + readSettledMessages( + sessionId: string, + options: { signal: AbortSignal }, + ): Promise<{ messages: readonly StoredMessage[]; settled: boolean }>; + 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, + setMessages, + commitRevisionDraft, + revisionDraftRef, + toastApi, + copy, + } = env; + let revisionPreparationAbort: AbortController | undefined; + + 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) { + // Attachments are session-owned refs, and a revision copy excludes the + // revised turn, so no target-owned rewrite exists to restage — the + // replacement would claim files the branch child does not own (#5109 + // review). The edit refuses instead of promising a restage that cannot + // happen. + toastApi.info(copy.revisionUnavailableTitle, copy.revisionAttachmentsUnsupported); + return; + } + + // The selected message's quotes restage into the composer plate (#5109): + // the plate makes the carried context visible and explicitly removable, + // and the commit re-keys it 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; + const preparationAbort = new AbortController(); + revisionPreparationAbort?.abort(); + revisionPreparationAbort = preparationAbort; + 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(); + const { messages: preparedMessages, settled } = await env.readSettledMessages( + newSession.id, + { signal: preparationAbort.signal }, + ); + if (!settled) throw new Error('Revised Session transcript did not become ready'); + if ( + !selectionIsCurrent() || activeIdRef.current !== newSession.id || + revisionDraftRef.current !== prepared + ) { + await rollbackPreparedRevision(startedDraft, newSession.id, text, selectionIsCurrent); + return false; + } + // Re-key the restored quotes onto the branch child: the plates read the + // active session's draft key, and the replacement send reads them live + // (#5109 review). The refs are pure data staged from the draft snapshot + // — a revision copy excludes the revised turn, so the copied transcript + // cannot be their source. Re-keyed only after every rollback check has + // passed, so a failed preparation leaves the plate on the source key. + staged.restoreQuotes(newSession.id, startedDraft.originalQuotes); + staged.clearQuotes(startedDraft.sourceSessionId); + setMessages(preparedMessages); + composerRef.current?.focus(); + toastApi.info(copy.revisionReadyTitle, copy.revisionReadyDescription); + await refreshSessions(); + return true; + } catch (error) { + if (preparationAbort.signal.aborted) return false; + if (preparedSessionId) { + await rollbackPreparedRevision(startedDraft, preparedSessionId, text, selectionIsCurrent); + } + if (!selectionIsCurrent()) return false; + if (env.reportSessionWorkspaceUnavailable(error, sourceSessionId)) { + return false; + } else { + toastApi.error( + copy.operationFailedTitle, + env.localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), + undefined, + { sessionId: sourceSessionId }, + ); + } + return false; + } finally { + if (revisionPreparationAbort === preparationAbort) revisionPreparationAbort = undefined; + } + } + + async function cancelRevisionDraft(): Promise { + let selectionIsCurrent = captureSelection(); + revisionPreparationAbort?.abort(); + 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 }; +}