Files
rfc-app/frontend/src/components/RFCView.test.jsx
T
Ben Stull 24596842ea feat(§8.12/§8.3): main-view Ask cuts an edit branch and runs the question there (v0.54.0)
The AI "Ask" affordance (selection tooltip + prompt bar) had no response
surface on an entry's canonical `main` view: RFCView renders the human-
discussion panel there, not the chat panel, so a chat turn posted from the
reading view had nowhere to render — asking-while-reading silently did
nothing. AI chat is an editing activity (a turn can emit <change> proposals)
and only runs on an edit branch.

Option B: when Ask is invoked from main, transparently cut an edit branch via
the same dispatch as Start Contributing (promote-to-branch for active,
start-edit-branch for super-draft; idempotent — reuse an existing edit
branch), navigate onto it so the chat panel mounts, and run the question
(text + selected quote) as the branch's first chat turn once its view and
main_thread_id resolve. The turn fires from the message-load effect's
continuation (not a parallel effect) so a late message load can't clobber the
optimistic turn; a live ref keeps the latest submitChatTurn closure in reach.

Degrades gracefully: signed-out → login; a viewer who can't contribute has the
cut rejected server-side and the error surfaced (no spurious branch); asking
from a branch already, and Flag on main (human discussion thread), unchanged.

Frontend-only; the branch chat endpoints already work on a branch. SPEC §8.3
records the behavior; new RFCView unit tests + an e2e spec cover the flow.
backend 677 / frontend 66 / e2e 5 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:19:20 -07:00

221 lines
9.0 KiB
React

import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
// §8.12 Option B — asking-while-reading on the canonical `main` view. The AI
// "Ask" affordance (selection tooltip + prompt bar) has no chat surface on
// main (RFCDiscussionPanel renders there, not ChatPanel). These tests cover the
// transparent branch-cut: Ask on main cuts an edit branch, navigates to it, and
// runs the question there; Ask on a branch is unchanged; a rejected cut degrades
// gracefully; Flag on main still opens a discussion thread.
const getRFC = vi.fn()
const getRFCMain = vi.fn()
const getBranch = vi.fn()
const listModels = vi.fn()
const getCollection = vi.fn()
const getThreadMessages = vi.fn()
const promoteToBranch = vi.fn()
const startEditBranch = vi.fn()
const streamChatTurn = vi.fn()
const createThread = vi.fn()
vi.mock('../api', () => ({
getRFC: (...a) => getRFC(...a),
getRFCMain: (...a) => getRFCMain(...a),
getBranch: (...a) => getBranch(...a),
listModels: (...a) => listModels(...a),
getCollection: (...a) => getCollection(...a),
getThreadMessages: (...a) => getThreadMessages(...a),
promoteToBranch: (...a) => promoteToBranch(...a),
startEditBranch: (...a) => startEditBranch(...a),
streamChatTurn: (...a) => streamChatTurn(...a),
createThread: (...a) => createThread(...a),
acceptChange: vi.fn(), declineChange: vi.fn(), editMetadata: vi.fn(),
manualFlush: vi.fn(), reaskChange: vi.fn(), resolveThread: vi.fn(),
setBranchVisibility: vi.fn(), claimOwnership: vi.fn(),
retireRFC: vi.fn(), unretireRFC: vi.fn(),
}))
vi.mock('../lib/entryPaths', () => ({
entryPath: () => '/p/ohm/c/rfc-app/e/the-entry',
entryPrPath: () => '/pr',
useProjectId: () => 'ohm',
useCollectionId: () => 'rfc-app',
}))
vi.mock('../lib/analytics', () => ({ EVENTS: { RFC_VIEWED: 'rfc_viewed' }, track: vi.fn() }))
// Stub the heavy children; expose just the callbacks/data the flow needs.
vi.mock('./MarkdownSourceEditor.jsx', () => ({ default: () => null }))
vi.mock('./MarkdownPreview.jsx', () => ({ default: () => <div data-testid="preview" /> }))
vi.mock('./RFCDiscussionPanel.jsx', () => ({ default: () => <div data-testid="discussion-panel" /> }))
vi.mock('./ChatPanel.jsx', () => ({
default: ({ messages }) => (
<div data-testid="chat-panel">
{(messages || []).map((m, i) => (
<div key={i} data-role={m.role}>{m.text}</div>
))}
</div>
),
}))
vi.mock('./SelectionTooltip.jsx', () => ({
default: ({ onAsk, onFlag, disabled }) => (
<div data-testid="tooltip">
<button data-testid="tooltip-ask" disabled={disabled}
onClick={() => onAsk('Why this approach?', 'the selected quote')}>ask</button>
<button data-testid="tooltip-flag"
onClick={() => onFlag('confusing', 'the selected quote')}>flag</button>
</div>
),
}))
vi.mock('./PromptBar.jsx', () => ({
default: ({ onSubmit, disabled }) => (
<button data-testid="prompt-submit" disabled={disabled}
onClick={() => onSubmit('Why this approach?', { text: 'the selected quote' })}>send</button>
),
}))
vi.mock('./ChangePanel.jsx', () => ({ default: () => null, diffWords: () => [] }))
vi.mock('./PRModal.jsx', () => ({ default: () => null }))
vi.mock('./GraduateDialog.jsx', () => ({ default: () => null }))
vi.mock('./InvitationsModal.jsx', () => ({ default: () => null }))
vi.mock('./MetadataFieldsPanel.jsx', () => ({ default: () => null }))
import RFCView from './RFCView.jsx'
const ACTIVE_ENTRY = {
id: 'RFC-0001', title: 'The Entry', state: 'active',
owners: [], meta: {}, proposed_use_case: '', tags: [],
}
const MAIN_VIEW = { slug: 'the-entry', branches: [], open_prs: [] }
function branchPayload(branch, { main_thread_id = 't-1' } = {}) {
return {
slug: 'the-entry', title: 'The Entry', branch_name: branch, body: '# Body\n',
body_sha: 'abc', main_thread_id, threads: [], changes: [],
visibility: {}, grants: [], creator: 'me',
capabilities: { can_contribute: true, can_change_branch_settings: true },
}
}
const VIEWER = { gitea_login: 'me', role: 'contributor' }
function renderView(viewer = VIEWER, initialBranch = null) {
const path = initialBranch ? `/e/the-entry?branch=${initialBranch}` : '/e/the-entry'
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/e/:slug" element={<RFCView viewer={viewer} />} />
</Routes>
</MemoryRouter>,
)
}
describe('RFCView — §8.12 main-view Ask (Option B)', () => {
beforeEach(() => {
vi.clearAllMocks()
getRFC.mockResolvedValue(ACTIVE_ENTRY)
getRFCMain.mockResolvedValue(MAIN_VIEW)
listModels.mockResolvedValue({ models: [{ id: 'claude' }], default: 'claude' })
getCollection.mockResolvedValue({ fields: null })
getThreadMessages.mockResolvedValue({ messages: [] })
getBranch.mockImplementation((_slug, branch) => Promise.resolve(branchPayload(branch)))
promoteToBranch.mockResolvedValue({ branch_name: 'edit-me-1' })
startEditBranch.mockResolvedValue({ branch_name: 'edit-the-entry' })
createThread.mockResolvedValue({ thread_id: 1, message_id: 1 })
streamChatTurn.mockImplementation(async (_slug, _branch, _threadId, { text, quote }, cb) => {
cb.onChunk?.('Here is the answer.')
cb.onChanges?.({ message_id: 42 })
cb.onDone?.()
return { assistantId: 42, userMsgId: 41 }
})
})
it('cuts an edit branch and runs the question (with the quote) there', async () => {
renderView()
await screen.findByTestId('discussion-panel') // we start on main
fireEvent.click(screen.getByTestId('prompt-submit'))
// The active-RFC dispatch is promote-to-branch.
await waitFor(() => expect(promoteToBranch).toHaveBeenCalledWith('the-entry'))
// Once the new branch view loads, the turn runs there — not on main —
// with the selected quote intact.
await waitFor(() => expect(streamChatTurn).toHaveBeenCalled())
const [slug, branch, threadId, payload] = streamChatTurn.mock.calls[0]
expect(slug).toBe('the-entry')
expect(branch).toBe('edit-me-1')
expect(threadId).toBe('t-1')
expect(payload).toMatchObject({ text: 'Why this approach?', quote: 'the selected quote' })
// The chat panel is now mounted (we left main) and shows the streamed answer.
const panel = await screen.findByTestId('chat-panel')
await waitFor(() => expect(panel).toHaveTextContent('Here is the answer.'))
})
it('uses start-edit-branch for a super-draft entry', async () => {
getRFC.mockResolvedValue({ ...ACTIVE_ENTRY, state: 'super-draft' })
getBranch.mockImplementation((_slug, branch) =>
Promise.resolve(branchPayload(branch === 'main' ? 'main' : 'edit-the-entry')))
renderView()
await screen.findByTestId('discussion-panel')
fireEvent.click(screen.getByTestId('tooltip-ask'))
await waitFor(() => expect(startEditBranch).toHaveBeenCalledWith('the-entry'))
expect(promoteToBranch).not.toHaveBeenCalled()
await waitFor(() => expect(streamChatTurn).toHaveBeenCalled())
expect(streamChatTurn.mock.calls[0][1]).toBe('edit-the-entry')
})
it('asking from an existing branch runs directly, without cutting a new one', async () => {
renderView(VIEWER, 'edit-me-1')
await screen.findByTestId('chat-panel')
fireEvent.click(screen.getByTestId('prompt-submit'))
await waitFor(() => expect(streamChatTurn).toHaveBeenCalled())
expect(promoteToBranch).not.toHaveBeenCalled()
expect(startEditBranch).not.toHaveBeenCalled()
expect(streamChatTurn.mock.calls[0][1]).toBe('edit-me-1')
})
it('surfaces an error and does not chat when the branch cut is rejected', async () => {
promoteToBranch.mockRejectedValue(new Error('not a contributor'))
renderView()
await screen.findByTestId('discussion-panel')
fireEvent.click(screen.getByTestId('prompt-submit'))
await waitFor(() => expect(promoteToBranch).toHaveBeenCalled())
// Explicit error path — not a silent no-op — and no spurious chat turn.
expect(await screen.findByText(/not a contributor/)).toBeInTheDocument()
expect(streamChatTurn).not.toHaveBeenCalled()
})
it('Flag on the canonical view still creates a discussion thread (no branch cut)', async () => {
renderView()
await screen.findByTestId('discussion-panel')
fireEvent.click(screen.getByTestId('tooltip-flag'))
await waitFor(() => expect(createThread).toHaveBeenCalled())
const [, branch, body] = createThread.mock.calls[0]
expect(branch).toBe('main')
expect(body).toMatchObject({ thread_kind: 'flag' })
expect(promoteToBranch).not.toHaveBeenCalled()
expect(streamChatTurn).not.toHaveBeenCalled()
})
it('signed-out viewer gets a read-only path on main — no prompt bar, Ask disabled, no cut', async () => {
renderView(null)
await screen.findByTestId('discussion-panel')
expect(screen.queryByTestId('prompt-submit')).toBeNull()
expect(screen.getByTestId('tooltip-ask')).toBeDisabled()
expect(promoteToBranch).not.toHaveBeenCalled()
expect(startEditBranch).not.toHaveBeenCalled()
})
})