From fb4f744721ba6f927923754fc69a2add5c2d53df Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 7 Sep 2026 00:17:22 -0700 Subject: [PATCH 01/22] feat(native-chat): confirm destructive conversation rewind from user messages --- .../native-chat/NativeChatMessageList.tsx | 6 +- .../native-chat/NativeChatMessageRow.tsx | 13 +- .../NativeChatRewindAction.test.tsx | 65 +++++ .../native-chat/NativeChatRewindAction.tsx | 33 +++ ...NativeChatStructuredSession.epoch.test.tsx | 86 +++++++ .../NativeChatStructuredSession.test.tsx | 8 + .../NativeChatStructuredSession.tsx | 42 +++- .../native-chat/native-chat-rewind-copy.ts | 66 +++++ .../use-native-chat-rewind.test.tsx | 238 ++++++++++++++++++ .../native-chat/use-native-chat-rewind.ts | 193 ++++++++++++++ .../use-structured-agent-session-mutation.ts | 104 ++++++++ .../use-structured-agent-session-read.ts | 1 + .../use-structured-agent-session.test.tsx | 118 ++++++++- .../use-structured-agent-session.ts | 131 ++++------ src/renderer/src/i18n/locales/en.json | 22 +- 15 files changed, 1036 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatRewindAction.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.epoch.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-rewind-copy.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-rewind.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-mutation.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index b406f0c3e502..9a422d7607af 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -1,3 +1,4 @@ +import type { NativeChatRewindSurface } from './use-native-chat-rewind' import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ArrowDown } from 'lucide-react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' @@ -35,7 +36,8 @@ export function NativeChatMessageList({ failedDeliveryMessageIds, showTurnStatus = true, turnActivity, - runtimeContext + runtimeContext, + rewind }: { session: NativeChatLiveSession isWorking: boolean @@ -50,6 +52,7 @@ export function NativeChatMessageList({ /** Turn timing and disclosure are available on structured agent sessions. */ showTurnStatus?: boolean turnActivity?: NativeChatTurnActivity | null + rewind?: NativeChatRewindSurface runtimeContext?: RuntimeFileOperationArgs | null }): React.JSX.Element { const scrollRef = useRef(null) @@ -231,6 +234,7 @@ export function NativeChatMessageList({ (null) @@ -83,7 +87,7 @@ export const MessageRow = memo(function MessageRow({ if (isUser) { return ( -
+
{/* User turns get a distinct muted fill (not the card/canvas color) so the prompt reads apart from the assistant's body copy. */}
@@ -110,6 +114,11 @@ export const MessageRow = memo(function MessageRow({ /> )}
+ {rewind ? ( +
+ +
+ ) : null} {deliveryFailed ? (
{translate( diff --git a/src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx b/src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx new file mode 100644 index 000000000000..11773ef0e99d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MessageRow } from './NativeChatMessageRow' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +afterEach(cleanup) +function row( + role: NativeChatMessage['role'], + disabledReason: string | null = null, + enabled = true +) { + const request = vi.fn() + render( + + ) + return request +} +describe('user-row rewind affordance', () => { + it('is keyboard reachable and calls the selected user item from the hover strip', () => { + const request = row('user') + const button = screen.getByRole('button', { name: 'Revert to here' }) + button.focus() + expect(button).toHaveFocus() + expect(button.parentElement).toHaveClass( + 'group-hover:opacity-100', + 'group-focus-within:opacity-100' + ) + fireEvent.click(button) + expect(request).toHaveBeenCalledWith('user-1') + }) + it('exposes the disabled reason to keyboard users and cannot invoke rewind', () => { + const request = row('user', 'This older Codex conversation does not support rewinding.') + const button = screen.getByRole('button', { name: 'Revert to here' }) + expect(button).toHaveAttribute('aria-disabled', 'true') + expect(button).toHaveAttribute( + 'aria-description', + 'This older Codex conversation does not support rewinding.' + ) + button.focus() + expect(button).toHaveFocus() + fireEvent.click(button) + expect(request).not.toHaveBeenCalled() + }) + it.each(['assistant', 'reasoning', 'system'] as const)('omits rewind for %s', (role) => { + row(role) + expect(screen.queryByRole('button', { name: 'Revert to here' })).toBeNull() + }) + it('leaves legacy user rows without rewind', () => { + row('user', null, false) + expect(screen.queryByRole('button', { name: 'Revert to here' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatRewindAction.tsx b/src/renderer/src/components/native-chat/NativeChatRewindAction.tsx new file mode 100644 index 000000000000..5b71b558e520 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatRewindAction.tsx @@ -0,0 +1,33 @@ +import { Undo2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { NativeChatRewindSurface } from './use-native-chat-rewind' + +export function NativeChatRewindAction({ + itemId, + rewind +}: { + itemId: string + rewind: NativeChatRewindSurface +}) { + const label = translate('components.native-chat.rewind.action', 'Revert to here') + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.epoch.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.epoch.test.tsx new file mode 100644 index 000000000000..509d9458b123 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.epoch.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { forwardRef } from 'react' +import { afterEach, it, vi } from 'vitest' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { expect } from 'vitest' + +const mocks = vi.hoisted(() => ({ + epoch: 'epoch-1', + composerProps: null as { paneKey: string } | null +})) +vi.mock('./use-structured-agent-session', () => ({ + useStructuredAgentSession: () => ({ + epoch: mocks.epoch, + messages: [{ id: 'message', role: 'user', blocks: [{ type: 'text', text: 'Prompt' }] }], + status: 'ready', + isWorking: false, + prompts: [], + outbox: [], + rewind: { pending: false, disabledReason: null, request: vi.fn() } + }) +})) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn() +})) +vi.mock('./use-native-chat-font-scale', () => ({ useNativeChatFontScale: () => ({ scale: 1 }) })) +vi.mock('./use-native-chat-file-link-context', () => ({ useNativeChatFileLinkContext: () => null })) +vi.mock('./native-chat-image-runtime-context', () => ({ + useNativeChatImageRuntimeContext: () => null +})) +vi.mock('./use-native-chat-link-actions', () => ({ + useNativeChatLinkActions: () => ({ + onLinkClick: vi.fn(), + linkActionRequest: null, + closeLinkActions: vi.fn() + }) +})) +vi.mock('./use-structured-native-chat-pane-commands', () => ({ + useStructuredNativeChatPaneCommands: () => ({ menu: null }) +})) +vi.mock('./NativeChatMessageList', () => ({ + NativeChatMessageList: () =>
+})) +vi.mock('./NativeChatComposer', async () => { + const { useNativeChatDraft } = await import('./use-native-chat-draft') + return { + NativeChatComposer: forwardRef((_props: { paneKey: string }, _ref) => { + mocks.composerProps = _props + const { draft, setDraft } = useNativeChatDraft(_props.paneKey) + return ( +