Skip to content
Open
Show file tree
Hide file tree
Changes from all 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-[var(--app-bg)]">
{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-[var(--app-bg)] 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