diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32a1001091..a7845826c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - run: bun install - run: bun typecheck - run: bunx playwright install --with-deps chromium - - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts composer-copy.spec.ts + - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts composer-copy.spec.ts fork-preview.spec.ts - run: bun run test # Serial runner-integration suite: starts real detached runner/session diff --git a/e2e/fork-preview.spec.ts b/e2e/fork-preview.spec.ts new file mode 100644 index 0000000000..ef9788360c --- /dev/null +++ b/e2e/fork-preview.spec.ts @@ -0,0 +1,53 @@ +/* + * End-to-end coverage for the fork preview confirm dialog. Drives the + * production ForkPreviewDialog via the standalone fixture page (no hub): + * the stub harness on `window.__forkPreviewE2E` counts cancel/confirm + * callbacks, standing in for the real fork API call. + */ + +import { expect, test, type Page } from '@playwright/test' + +async function openFixture(page: Page): Promise { + await page.goto('/e2e-fixtures/fork-preview-fixture.html') + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() +} + +test('shows the kept turns above the fork boundary and the new-session start below', async ({ page }) => { + await openFixture(page) + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByText('first question about pagination')).toBeVisible() + await expect(dialog.getByText('second question about forking')).toBeVisible() + await expect(dialog.getByTestId('fork-preview-boundary')).toBeVisible() + await expect(dialog.getByTestId('fork-preview-boundary-message')).toContainText('third question') +}) + +test('cancel closes the dialog without confirming the fork', async ({ page }) => { + await openFixture(page) + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByRole('dialog')).toHaveCount(0) + const harness = await page.evaluate(() => window.__forkPreviewE2E) + expect(harness?.cancelled).toBe(1) + expect(harness?.confirmed).toBe(0) +}) + +test('confirm runs the fork and closes the dialog', async ({ page }) => { + await openFixture(page) + const dialog = page.getByRole('dialog') + await dialog.getByTestId('fork-preview-confirm').click() + await expect(page.getByRole('dialog')).toHaveCount(0) + const harness = await page.evaluate(() => window.__forkPreviewE2E) + expect(harness?.confirmed).toBe(1) + expect(harness?.cancelled).toBe(0) +}) + +test('localizes the dialog in Chinese', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('hapi-lang', 'zh-CN')) + await openFixture(page) + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: '从这里开始一个新会话' })).toBeVisible() + await expect(dialog.getByText('↑ 会复制到新会话中')).toBeVisible() + await expect(dialog.getByRole('button', { name: '在此分叉' })).toBeVisible() +}) diff --git a/web/e2e-fixtures/fork-preview-fixture.html b/web/e2e-fixtures/fork-preview-fixture.html new file mode 100644 index 0000000000..6e4351e89a --- /dev/null +++ b/web/e2e-fixtures/fork-preview-fixture.html @@ -0,0 +1,16 @@ + + + + + + HAPI fork preview e2e fixture + + + +
+ + + diff --git a/web/e2e-fixtures/fork-preview-fixture.tsx b/web/e2e-fixtures/fork-preview-fixture.tsx new file mode 100644 index 0000000000..baec74e3a6 --- /dev/null +++ b/web/e2e-fixtures/fork-preview-fixture.tsx @@ -0,0 +1,57 @@ +/* + * Standalone Vite-served fixture for the fork preview Playwright spec + * (scratchlist pattern). Mounts the production ForkPreviewDialog inside + * an I18nProvider with a stub confirm callback on `window.__forkPreviewE2E` + * so the spec can assert dialog rendering, the boundary marker, and that + * cancel/confirm route to the right callback without the hub stack. + */ + +import React, { useState } from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { I18nProvider } from '../src/lib/i18n-context' +import { ForkPreviewDialog } from '../src/components/AssistantChat/ForkPreviewDialog' +import type { ForkPreviewTurn } from '../src/lib/forkPreview' + +declare global { + interface Window { + __forkPreviewE2E?: { + confirmed: number + cancelled: number + } + } +} + +const KEPT_TURNS: ForkPreviewTurn[] = [ + { role: 'user', text: 'first question about pagination' }, + { role: 'assistant', text: 'first answer explaining the boundary' }, + { role: 'user', text: 'second question about forking' }, +] + +function Fixture() { + const [open, setOpen] = useState(true) + return ( + { + window.__forkPreviewE2E!.cancelled += 1 + setOpen(false) + }} + onConfirm={() => { + window.__forkPreviewE2E!.confirmed += 1 + setOpen(false) + }} + /> + ) +} + +window.__forkPreviewE2E = { confirmed: 0, cancelled: 0 } +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/web/src/components/AssistantChat/ForkPreviewDialog.tsx b/web/src/components/AssistantChat/ForkPreviewDialog.tsx new file mode 100644 index 0000000000..821f4af0b3 --- /dev/null +++ b/web/src/components/AssistantChat/ForkPreviewDialog.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useTranslation } from '@/lib/use-translation' +import type { ForkPreviewKind, ForkPreviewTurn } from '@/lib/forkPreview' + +type ForkPreviewDialogProps = { + isOpen: boolean + kind: ForkPreviewKind + keptTurns: ForkPreviewTurn[] + boundaryText: string | null + /** True when older messages exist beyond the loaded window, so an empty + * prefix does not mean the child starts empty. */ + prefixMayHaveMore?: boolean + onCancel: () => void + onConfirm: () => Promise +} + +export function ForkPreviewDialog({ isOpen, kind, keptTurns, boundaryText, prefixMayHaveMore = false, onCancel, onConfirm }: ForkPreviewDialogProps) { + const { t } = useTranslation() + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + + const handleConfirm = async () => { + setError(null) + setPending(true) + try { + await onConfirm() + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t('dialog.error.default')) + } finally { + setPending(false) + } + } + + return ( + { if (!open && !pending) onCancel() }}> + + + {t('forkPreview.title')} + +
+ {keptTurns.length > 0 ? ( +
+ {keptTurns.map((turn, index) => ( +
+ + {t(turn.role === 'user' ? 'forkPreview.roleUser' : 'forkPreview.roleAssistant')} + + {turn.text} +
+ ))} +
+ {t('forkPreview.keptAbove')} +
+
+ ) : ( +
+ {t(prefixMayHaveMore ? 'forkPreview.noTextPreview' : 'forkPreview.emptyPrefix')} +
+ )} + {kind === 'historical' ? ( + <> +
+ + + {t('forkPreview.boundaryBadge')} + + +
+ {boundaryText ? ( +
+ + {t('forkPreview.newSessionStart')} + + {boundaryText} +
+ ) : null} + + ) : null} +
+

+ {t(kind === 'historical' ? 'forkPreview.below' : 'forkPreview.currentTail')} +

+ {error ? ( +
+ {error} +
+ ) : null} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx index 87a8bca3f7..e662184b88 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.test.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -209,12 +209,9 @@ describe('MessageActions', () => { expect(screen.getByRole('button', { name: '分叉' })).toHaveAttribute('title', '分叉') }) - it('localizes the Fork confirmation dialog in Simplified Chinese', async () => { + it('invokes Fork directly without a confirmation dialog', async () => { localStorage.setItem('hapi-lang', 'zh-CN') - let resolveFork: (() => void) | undefined - const onFork = vi.fn(() => new Promise((resolve) => { - resolveFork = resolve - })) + const onFork = vi.fn(async () => {}) renderActions({ align: 'end', @@ -224,17 +221,8 @@ describe('MessageActions', () => { }) fireEvent.click(screen.getByRole('button', { name: '分叉' })) - const dialog = screen.getByRole('dialog') - expect(dialog.textContent).toContain('分叉对话') - expect(dialog.textContent).toContain('从此处创建新会话?') - expect(dialog.textContent).toContain('当前会话不会被修改。') - - fireEvent.click(within(dialog).getByRole('button', { name: '分叉' })) - expect(onFork).toHaveBeenCalledTimes(1) - expect(within(dialog).getByRole('button', { name: '分叉中…' })).not.toBeNull() - - resolveFork?.() - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + await waitFor(() => expect(onFork).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('dialog')).toBeNull() }) it('localizes the Rewind confirmation dialog in Simplified Chinese', async () => { @@ -315,9 +303,9 @@ describe('MessageActions', () => { }) it('hides all history actions while a confirmation is pending', async () => { - let resolveFork: (() => void) | undefined - const onFork = vi.fn(() => new Promise((resolve) => { - resolveFork = resolve + let resolveRewind: (() => void) | undefined + const onRewind = vi.fn(() => new Promise((resolve) => { + resolveRewind = resolve })) renderActions({ @@ -325,20 +313,20 @@ describe('MessageActions', () => { copyText: 'body', showFork: true, showRewind: true, - onFork, - onRewind: async () => {} + onFork: async () => {}, + onRewind }) - fireEvent.click(screen.getByRole('button', { name: 'Fork' })) - fireEvent.click(screen.getAllByRole('button', { name: 'Fork' }).at(-1)!) + fireEvent.click(screen.getByRole('button', { name: 'Rewind' })) + fireEvent.click(screen.getAllByRole('button', { name: 'Rewind' }).at(-1)!) await waitFor(() => { expect(document.querySelector('.happy-message-actions')?.querySelectorAll('button')).toHaveLength(1) }) - expect(screen.queryByRole('button', { name: 'Rewind' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Fork' })).toBeNull() - resolveFork?.() - await waitFor(() => expect(onFork).toHaveBeenCalledTimes(1)) + resolveRewind?.() + await waitFor(() => expect(onRewind).toHaveBeenCalledTimes(1)) }) it('orders user actions as Share, Rewind, Fork, Copy', () => { @@ -401,20 +389,13 @@ describe('MessageActions', () => { } }) - it('shows Fork confirm dialog and calls onFork only after confirm', async () => { + it('calls onFork immediately when the Fork action is clicked', async () => { const onFork = vi.fn(async () => {}) renderActions({ align: 'start', copyText: 'body', showFork: true, onFork }) fireEvent.click(screen.getByRole('button', { name: 'Fork' })) - expect(onFork).not.toHaveBeenCalled() - expect(screen.getByText('Fork conversation')).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) - expect(onFork).not.toHaveBeenCalled() - - fireEvent.click(screen.getByRole('button', { name: 'Fork' })) - fireEvent.click(screen.getAllByRole('button', { name: 'Fork' }).at(-1)!) - expect(onFork).toHaveBeenCalledTimes(1) + await waitFor(() => expect(onFork).toHaveBeenCalledTimes(1)) + expect(screen.queryByText('Fork conversation')).toBeNull() }) it('shows Rewind destructive confirm and calls onRewind only after confirm', async () => { diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx index 8d750f61dd..85518e9d42 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -61,11 +61,9 @@ export function MessageActions({ const threadIsRunning = useAuiState((state) => selectThreadIsRunning(state)) const canCopy = Boolean(copyText) const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false - const [forkOpen, setForkOpen] = useState(false) const [rewindOpen, setRewindOpen] = useState(false) - const [forkPending, setForkPending] = useState(false) const [rewindPending, setRewindPending] = useState(false) - const actionsLocked = historyActionPending || forkPending || rewindPending || threadIsRunning + const actionsLocked = historyActionPending || rewindPending || threadIsRunning const shareButton = messageElementId ? ( setForkOpen(true)} + onClick={() => { void onFork() }} > @@ -124,28 +122,6 @@ export function MessageActions({ {align === 'start' ? : null} - { - if (!forkPending) setForkOpen(false) - }} - title={t('message.fork.confirmTitle')} - description={t('message.fork.confirmDescription')} - confirmLabel={t('message.fork')} - confirmingLabel={t('message.fork.confirming')} - isPending={forkPending} - onConfirm={async () => { - if (!onFork) return - setForkPending(true) - try { - await onFork() - setForkOpen(false) - } finally { - setForkPending(false) - } - }} - /> - { diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index d57f18e7ae..429ee2a5f8 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -46,6 +46,8 @@ import { classifySessionAttention, getSessionAttentionLabelKey } from '@/lib/ses import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { formatRelativeTime } from '@/lib/relativeTime' import { ScratchlistMigrationBanner } from '@/components/AssistantChat/ScratchlistMigrationBanner' +import { ForkPreviewDialog } from '@/components/AssistantChat/ForkPreviewDialog' +import { buildForkPreview } from '@/lib/forkPreview' import { findLatestCompletedBoundaryId, useHappyRuntime } from '@/lib/assistant-runtime' import { getRestoredComposerSendIntent, @@ -561,11 +563,13 @@ function SessionChatInner(props: SessionChatProps) { const { codexExplorationCollapsed } = useCodexExplorationCollapse() const navigate = useNavigate() const [historyActionPending, setHistoryActionPending] = useState(false) + const [forkPreviewRequest, setForkPreviewRequest] = useState<{ messageLocalId?: string } | null>(null) - const onForkConversation = useCallback(async (messageLocalId?: string) => { + const executeForkConversation = useCallback(async (messageLocalId?: string) => { setHistoryActionPending(true) try { const result = await props.api.forkConversation(props.session.id, messageLocalId) + setForkPreviewRequest(null) await navigate({ to: '/sessions/$sessionId', params: { sessionId: result.sessionId }, @@ -576,6 +580,10 @@ function SessionChatInner(props: SessionChatProps) { } }, [navigate, props.api, props.session.id]) + const onForkConversation = useCallback(async (messageLocalId?: string) => { + setForkPreviewRequest({ messageLocalId }) + }, []) + const onRewindConversation = useCallback(async (messageLocalId: string) => { setHistoryActionPending(true) try { @@ -1350,6 +1358,11 @@ function SessionChatInner(props: SessionChatProps) { props.tailRevision ) + const forkPreview = useMemo( + () => forkPreviewRequest ? buildForkPreview(reconciled.blocks, forkPreviewRequest.messageLocalId) : null, + [forkPreviewRequest, reconciled.blocks] + ) + const isLatestCompletedBoundary = useCallback((messageId: string) => { return latestCompletedBoundaryId === messageId }, [latestCompletedBoundaryId]) @@ -1764,6 +1777,17 @@ function SessionChatInner(props: SessionChatProps) { + {forkPreviewRequest && forkPreview ? ( + setForkPreviewRequest(null)} + onConfirm={() => executeForkConversation(forkPreviewRequest.messageLocalId)} + /> + ) : null} {})} />
diff --git a/web/src/lib/forkPreview.test.ts b/web/src/lib/forkPreview.test.ts new file mode 100644 index 0000000000..8ff0260876 --- /dev/null +++ b/web/src/lib/forkPreview.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { buildForkPreview } from './forkPreview' +import type { VisibleChatBlock } from '@/chat/toolGroups' + +function user(text: string, localId: string): VisibleChatBlock { + return { + kind: 'user-text', + id: localId, + localId, + createdAt: 0, + invokedAt: 0, + text, + } +} + +function agent(text: string): VisibleChatBlock { + return { + kind: 'agent-text', + id: `a-${text}`, + localId: null, + createdAt: 1, + text, + } +} + +describe('buildForkPreview', () => { + const blocks: VisibleChatBlock[] = [ + user('first question', 'u1'), + agent('first answer'), + user('second question', 'u2'), + agent('second answer'), + user('third question', 'u3'), + agent('third answer'), + ] + + it('keeps everything before the boundary and quotes the boundary message', () => { + const preview = buildForkPreview(blocks, 'u3') + expect(preview.keptTurns).toHaveLength(3) + expect(preview.keptTurns.map((turn) => turn.role)).toEqual(['assistant', 'user', 'assistant']) + expect(preview.keptTurns[0].text).toBe('first answer') + expect(preview.keptTurns[1].text).toBe('second question') + expect(preview.boundaryText).toBe('third question') + }) + + it('treats a missing boundary id as an empty preview', () => { + const preview = buildForkPreview(blocks, 'missing') + expect(preview.keptTurns).toEqual([]) + expect(preview.boundaryText).toBeNull() + }) + + it('keeps the whole transcript for a current fork with no boundary message', () => { + const preview = buildForkPreview(blocks) + expect(preview.keptTurns).toHaveLength(3) + expect(preview.keptTurns[2].text).toBe('third answer') + expect(preview.boundaryText).toBeNull() + }) + + it('merges consecutive same-role blocks into one turn and truncates long text', () => { + const merged: VisibleChatBlock[] = [ + agent(`"${'x'.repeat(300)}"`), + agent('more of the same answer'), + user('q', 'u9'), + ] + const preview = buildForkPreview(merged, 'u9') + expect(preview.keptTurns).toHaveLength(1) + expect(preview.keptTurns[0].role).toBe('assistant') + expect(preview.keptTurns[0].text.startsWith('"xxx')).toBe(true) + expect(preview.keptTurns[0].text.endsWith('…')).toBe(true) + expect(preview.boundaryText).toBe('q') + }) + + it('skips system and empty-text blocks', () => { + const noisy: VisibleChatBlock[] = [ + { kind: 'agent-event', id: 'e1', createdAt: 0, event: { type: 'status' } } as unknown as VisibleChatBlock, + user(' ', 'u-empty'), + user('real question', 'u-real'), + ] + const preview = buildForkPreview(noisy, 'u-real') + expect(preview.keptTurns).toEqual([]) + expect(preview.boundaryText).toBe('real question') + }) + + it('omits queued (never-invoked) user blocks that the hub does not copy', () => { + const withQueued: VisibleChatBlock[] = [ + user('answered question', 'u1'), + agent('answer'), + { ...user('queued prompt', 'u2'), invokedAt: null }, + ] + const preview = buildForkPreview(withQueued) + expect(preview.keptTurns.map((turn) => turn.text)).toEqual(['answered question', 'answer']) + }) + + it('reports the fork kind for historical and current forks', () => { + expect(buildForkPreview(blocks, 'u3').kind).toBe('historical') + expect(buildForkPreview(blocks).kind).toBe('current') + }) + + it('includes attachment-only user messages by their filenames', () => { + const attachmentOnly: VisibleChatBlock[] = [ + { ...user('', 'u-file'), attachments: [{ id: 'a1', filename: 'report.pdf', mimeType: 'application/pdf', size: 10, path: '/tmp/report.pdf' }] } as VisibleChatBlock, + user('next question', 'u2'), + ] + const preview = buildForkPreview(attachmentOnly, 'u2') + expect(preview.keptTurns.map((turn) => turn.text)).toEqual(['report.pdf']) + expect(preview.boundaryText).toBe('next question') + }) +}) diff --git a/web/src/lib/forkPreview.ts b/web/src/lib/forkPreview.ts new file mode 100644 index 0000000000..9cd7fafad3 --- /dev/null +++ b/web/src/lib/forkPreview.ts @@ -0,0 +1,75 @@ +import { visibleBlockRole, type VisibleChatBlock } from '@/chat/toolGroups' + +export type ForkPreviewTurn = { + role: 'user' | 'assistant' + text: string +} + +export type ForkPreviewKind = 'current' | 'historical' + +export type ForkPreview = { + /** How the fork maps onto the transcript. */ + kind: ForkPreviewKind + /** Turns copied into the new session (shown above the fork point). */ + keptTurns: ForkPreviewTurn[] + /** Text of the selected cutoff message, which is NOT copied into the + * new session. Null for a current-tail fork where nothing is excluded. */ + boundaryText: string | null +} + +const MAX_KEPT_TURNS = 3 +const MAX_TURN_CHARS = 240 + +function truncate(text: string): string { + const collapsed = text.replace(/\s+/g, ' ').trim() + return collapsed.length > MAX_TURN_CHARS ? `${collapsed.slice(0, MAX_TURN_CHARS)}…` : collapsed +} + +function blockPreviewText(block: VisibleChatBlock): { role: 'user' | 'assistant'; text: string } | null { + const role = visibleBlockRole(block) + if (role === 'system') return null + let text: string | undefined + if (block.kind === 'user-text') { + // Attachment-only messages have empty text; surface their filenames so + // they are not silently dropped from the preview. + const attachments = block.attachments?.map(({ filename }) => filename).join(', ') + text = [block.text, attachments].filter(Boolean).join(' ') + } else if (block.kind === 'agent-text' || block.kind === 'cli-output') { + text = block.text + } + if (!text || text.trim().length === 0) return null + return { role, text } +} + +/** + * Mirrors `hub/src/sync/forkTranscript.ts#selectForkTranscriptPrefix`: + * a historical fork copies everything BEFORE the boundary message into the + * new session — the boundary message itself and anything after it stay out + * of the child; a current fork (no `messageLocalId`) copies the whole + * transcript. Queued rows (`invokedAt == null`) are never copied, matching + * the hub filter. + */ +export function buildForkPreview(blocks: readonly VisibleChatBlock[], messageLocalId?: string): ForkPreview { + let cutoff = blocks.length + let boundaryText: string | null = null + if (messageLocalId) { + cutoff = blocks.findLastIndex((block) => block.kind !== 'agent-event' && block.kind !== 'tool-group' && block.localId === messageLocalId) + if (cutoff < 0) return { kind: 'historical', keptTurns: [], boundaryText: null } + const selected = blockPreviewText(blocks[cutoff]) + boundaryText = selected ? truncate(selected.text) : null + } + + const turns: ForkPreviewTurn[] = [] + for (const block of blocks.slice(0, cutoff)) { + if (block.kind === 'user-text' && (block.invokedAt ?? null) === null) continue + const entry = blockPreviewText(block) + if (!entry) continue + const previous = turns[turns.length - 1] + if (previous && previous.role === entry.role) { + previous.text = truncate(`${previous.text} ${entry.text}`) + } else { + turns.push({ role: entry.role, text: truncate(entry.text) }) + } + } + return { kind: messageLocalId ? 'historical' : 'current', keptTurns: turns.slice(-MAX_KEPT_TURNS), boundaryText } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index e1e51a96fc..7938ea7e08 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -13,9 +13,6 @@ export default { 'message.info': 'Message details', 'message.fork': 'Fork', 'message.rewind': 'Rewind', - 'message.fork.confirmTitle': 'Fork conversation', - 'message.fork.confirmDescription': 'Create a new session from this point?\nThe current session will not be changed.', - 'message.fork.confirming': 'Forking…', 'message.rewind.confirmTitle': 'Rewind conversation', 'message.rewind.confirmDescription': 'Rewind this session to this point?\nLater conversation history will be permanently removed. Files will not be changed.', 'message.rewind.confirming': 'Rewinding…', @@ -650,6 +647,20 @@ export default { 'queuedMessages.steeredBadge': '↳ Steered', 'queuedMessages.steeredBadgeTitle': 'Steered into the active turn', + // Fork preview (confirm dialog before forking a conversation) + 'forkPreview.title': 'Start a new session from here', + 'forkPreview.roleUser': 'You', + 'forkPreview.roleAssistant': 'Agent', + 'forkPreview.keptAbove': '↑ Copied into the new session', + 'forkPreview.emptyPrefix': 'The new session starts empty.', + 'forkPreview.noTextPreview': 'Older messages are copied into the new session too · Not all of them are shown here', + 'forkPreview.boundaryBadge': 'Fork point', + 'forkPreview.newSessionStart': 'Not included in the new session', + 'forkPreview.below': 'The new session resumes just before this message · The original session is unchanged', + 'forkPreview.currentTail': 'A copy of this conversation becomes a new session · The original session is unchanged', + 'forkPreview.cancel': 'Cancel', + 'forkPreview.confirm': 'Fork here', + // Scratchlist (per-session workbench, issue #11) 'scratchlist.title': 'Scratchlist', 'scratchlist.heldLabel': 'held — not sent', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 94ffbad9dc..112f7dafc7 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -13,9 +13,6 @@ export default { 'message.info': '消息详情', 'message.fork': '分叉', 'message.rewind': '回退', - 'message.fork.confirmTitle': '分叉对话', - 'message.fork.confirmDescription': '从此处创建新会话?\n当前会话不会被修改。', - 'message.fork.confirming': '分叉中…', 'message.rewind.confirmTitle': '回退对话', 'message.rewind.confirmDescription': '将此会话回退到此处?\n之后的对话历史将永久移除。文件不会被修改。', 'message.rewind.confirming': '回退中…', @@ -649,6 +646,20 @@ export default { 'queuedMessages.steeredBadge': '↳ 已介入', 'queuedMessages.steeredBadgeTitle': '已介入当前进行中的回合', + // Fork preview (confirm dialog before forking a conversation) + 'forkPreview.title': '从这里开始一个新会话', + 'forkPreview.roleUser': '你', + 'forkPreview.roleAssistant': '代理', + 'forkPreview.keptAbove': '↑ 会复制到新会话中', + 'forkPreview.emptyPrefix': '新会话将从空会话开始。', + 'forkPreview.noTextPreview': '更早的消息也会复制到新会话中 · 此处未全部显示', + 'forkPreview.boundaryBadge': '分叉点', + 'forkPreview.newSessionStart': '不会包含在新会话中', + 'forkPreview.below': '新会话将在这条消息之前继续 · 原会话保持不变', + 'forkPreview.currentTail': '此会话的副本将成为新会话 · 原会话保持不变', + 'forkPreview.cancel': '取消', + 'forkPreview.confirm': '在此分叉', + // Scratchlist (per-session workbench, issue #11) 'scratchlist.title': '草稿夹', 'scratchlist.heldLabel': '暂存 · 未发送',