Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions e2e/fork-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
Comment thread
junmo-kim marked this conversation as resolved.
* 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<void> {
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()
})
16 changes: 16 additions & 0 deletions web/e2e-fixtures/fork-preview-fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HAPI fork preview e2e fixture</title>
<style>
html { background: #fff }
body { margin: 0; padding: 16px; font-family: system-ui, sans-serif }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./fork-preview-fixture.tsx"></script>
</body>
</html>
57 changes: 57 additions & 0 deletions web/e2e-fixtures/fork-preview-fixture.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ForkPreviewDialog
isOpen={open}
kind="historical"
keptTurns={KEPT_TURNS}
boundaryText="third question — not copied into the new session"
pending={false}
onCancel={() => {
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(
<I18nProvider>
<Fixture />
</I18nProvider>
)
111 changes: 111 additions & 0 deletions web/src/components/AssistantChat/ForkPreviewDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
}

export function ForkPreviewDialog({ isOpen, kind, keptTurns, boundaryText, prefixMayHaveMore = false, onCancel, onConfirm }: ForkPreviewDialogProps) {
const { t } = useTranslation()
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(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 (
<Dialog open={isOpen} onOpenChange={(open) => { if (!open && !pending) onCancel() }}>
<DialogContent aria-describedby={undefined} className="flex max-h-[85vh] flex-col gap-3">
<DialogHeader>
<DialogTitle>{t('forkPreview.title')}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-[var(--app-subtle-bg)] p-3" data-testid="fork-preview-thread">
{keptTurns.length > 0 ? (
<div className="flex flex-col gap-2">
{keptTurns.map((turn, index) => (
<div key={index} className={turn.role === 'user' ? 'self-end rounded-xl bg-[var(--app-subtle-bg)] px-3 py-2 text-sm' : 'self-start text-sm'}>
<span className="mb-0.5 block text-[10px] uppercase tracking-wide text-[var(--app-hint)]">
{t(turn.role === 'user' ? 'forkPreview.roleUser' : 'forkPreview.roleAssistant')}
</span>
<span className="line-clamp-3">{turn.text}</span>
</div>
))}
<div className="text-center text-[10px] text-[var(--app-hint)]">
{t('forkPreview.keptAbove')}
</div>
</div>
) : (
<div className="text-center text-xs text-[var(--app-hint)]">
{t(prefixMayHaveMore ? 'forkPreview.noTextPreview' : 'forkPreview.emptyPrefix')}
</div>
)}
{kind === 'historical' ? (
<>
<div className="my-3 flex items-center gap-2" data-testid="fork-preview-boundary">
<span className="h-px flex-1 bg-[var(--app-link)]" />
<span className="rounded-full bg-[var(--app-link)] px-2 py-0.5 text-[10px] font-medium text-white">
{t('forkPreview.boundaryBadge')}
</span>
<span className="h-px flex-1 bg-[var(--app-link)]" />
</div>
{boundaryText ? (
<div className="rounded-xl border-2 border-dashed border-[var(--app-link)] px-3 py-2 text-sm" data-testid="fork-preview-boundary-message">
<span className="mb-0.5 block text-[10px] uppercase tracking-wide text-[var(--app-link)]">
{t('forkPreview.newSessionStart')}
</span>
<span className="line-clamp-2">{boundaryText}</span>
</div>
) : null}
</>
) : null}
</div>
<p className="text-xs text-[var(--app-hint)]">
{t(kind === 'historical' ? 'forkPreview.below' : 'forkPreview.currentTail')}
</p>
{error ? (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400" data-testid="fork-preview-error">
{error}
</div>
) : null}
<div className={`flex gap-2 ${pending ? 'opacity-60' : ''}`}>
<button
type="button"
onClick={onCancel}
disabled={pending}
className="flex-1 rounded-lg border border-[var(--app-subtle-bg)] px-3 py-2 text-sm hover:bg-[var(--app-subtle-bg)]"
>
{t('forkPreview.cancel')}
</button>
<button
type="button"
onClick={() => { void handleConfirm() }}
disabled={pending}
data-testid="fork-preview-confirm"
className="flex-1 rounded-lg bg-[var(--app-link)] px-3 py-2 text-sm font-medium text-white hover:opacity-90"
>
{t('forkPreview.confirm')}
</button>
</div>
</DialogContent>
</Dialog>
)
}
53 changes: 17 additions & 36 deletions web/src/components/AssistantChat/messages/MessageActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
resolveFork = resolve
}))
const onFork = vi.fn(async () => {})

renderActions({
align: 'end',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -315,30 +303,30 @@ describe('MessageActions', () => {
})

it('hides all history actions while a confirmation is pending', async () => {
let resolveFork: (() => void) | undefined
const onFork = vi.fn(() => new Promise<void>((resolve) => {
resolveFork = resolve
let resolveRewind: (() => void) | undefined
const onRewind = vi.fn(() => new Promise<void>((resolve) => {
resolveRewind = resolve
}))

renderActions({
align: 'end',
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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading