🐛 fix(vscode): 代码阅读对话里的回答悬停链接时卡片一直闪、盖住链接:回答每次重画都新建 {__html},React 换了对象就整段重设 innerHTML,弹出定义卡片引起的重画把悬停着的链接换成新元素,卡片按脱离页面的旧链接落到左上角盖住链接,指针下的新链接又重新问卡片、再被换掉,来回闪;回答改用 MarkdownPreview,同一段 HTML 用同一个对象,链接不再被换

This commit is contained in:
cruldra committed 2026-10-14 23:16:20 +11:00
1 parent d6960b894c
commit 2841e2b477
3 files changed
+62 -7

No files matched your search

@@ -237,3 +237,56 @@ describe('复制回答', () => {
expect(writeText).toHaveBeenCalledExactlyOnceWith('找到了 **3 条**路径') expect(writeText).toHaveBeenCalledExactlyOnceWith('找到了 **3 条**路径')
}) })
}) })
describe('回答里的链接', () => {
let container: HTMLDivElement
let root: Root
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
const ANSWER = '- [HomeShell.startSessionWith](src/app/home/HomeShell.tsx#HomeShell.startSessionWith):把首句暂存后跳到编程聊天页'
async function mount(props: Partial<AskThreadProps>): Promise<void> {
await act(async () => root.render(<AskThread history={[]} asks={[]} open={false} profiles={PROFILES} now={NOW} onSelectProfile={() => {}} onAsk={() => {}} onStop={() => {}} onDelete={() => {}} onClose={() => {}} {...props} />))
}
function link(): HTMLAnchorElement {
const found = container.querySelector<HTMLAnchorElement>('.cr-qa-answer a')
if (!found)
throw new Error('回答里没有链接')
return found
}
it('展开的历史回答:页面为别的事重画(如弹出定义卡片)、传进等值的新对象时,还是原来那个链接,悬停的那处不被换掉', async () => {
const history = (): QaEntryView[] => [{ ...HISTORY[0]!, answer: ANSWER }]
await mount({ history: history() })
await act(async () => container.querySelector<HTMLButtonElement>('[data-history] .cr-qa-question')!.click())
const original = link()
for (const now of [NOW + 1, NOW + 2])
await mount({ history: history(), now })
expect(link()).toBe(original)
expect(original.isConnected).toBe(true)
})
it('答完的当前一轮同样;回答的字变了才换成新的链接', async () => {
await mount({ open: true, asks: [ask({ status: 'done', text: ANSWER })] })
const original = link()
await mount({ open: true, asks: [ask({ status: 'done', text: ANSWER })], now: NOW + 1 })
expect(link()).toBe(original)
expect(original.isConnected).toBe(true)
await mount({ open: true, asks: [ask({ status: 'done', text: `${ANSWER}\n- 再往下` })] })
expect(original.isConnected).toBe(false)
expect(link().textContent).toBe('HomeShell.startSessionWith')
})
})
@@ -12,6 +12,7 @@ import { useCopyText } from '@/hooks/useCopyText'
import { renderMarkdown, renderMarkdownOnce } from './markdown' import { renderMarkdown, renderMarkdownOnce } from './markdown'
import { answerPreview, relativeTime, unfinished } from './qa' import { answerPreview, relativeTime, unfinished } from './qa'
import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages' import { MAX_IMAGES, pickImages, readImage, transferFiles } from './qaImages'
import { MarkdownPreview } from './TextPreview'
import { showInViewport } from './useReveal' import { showInViewport } from './useReveal'
/** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */ /** 一种对话在界面上怎么称呼:区域的名字、图标、「问过 N 次」、删一条、输入框的提示和名字、回答长出来时的状态 */
@@ -157,7 +158,7 @@ function HistoryItem({ entry, wording, now, onDelete, onOpenImage }: { entry: Qa
</div> </div>
<Thumbs urls={entry.images ?? []} onOpen={onOpenImage} /> <Thumbs urls={entry.images ?? []} onOpen={onOpenImage} />
{expanded {expanded
? <div className="cr-markdown cr-qa-answer" dangerouslySetInnerHTML={{ __html: renderMarkdown(entry.answer, 'answer') }} /> ? <MarkdownPreview html={renderMarkdown(entry.answer, 'answer')} className="cr-qa-answer" />
: ( : (
<button type="button" className="cr-qa-preview" onClick={toggle}> <button type="button" className="cr-qa-preview" onClick={toggle}>
{answerPreview(entry.answer)} {answerPreview(entry.answer)}
@@ -187,10 +188,10 @@ function CurrentItem({ ask, wording, onOpenImage }: { ask: Ask, wording: ThreadW
</div> </div>
<Thumbs urls={ask.images ?? []} onOpen={onOpenImage} /> <Thumbs urls={ask.images ?? []} onOpen={onOpenImage} />
{ask.text !== '' && ( {ask.text !== '' && (
<div <MarkdownPreview
className="cr-markdown cr-qa-answer" html={running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text, 'answer')}
data-streaming={running ? '' : undefined} className="cr-qa-answer"
dangerouslySetInnerHTML={{ __html: running ? renderMarkdownOnce(ask.text) : renderMarkdown(ask.text, 'answer') }} streaming={running}
/> />
)} )}
{running && ( {running && (
@@ -25,7 +25,8 @@ export const TextPreview = memo(({ text, className, find }: { text: PreviewText,
}) })
TextPreview.displayName = 'TextPreview' TextPreview.displayName = 'TextPreview'
export function MarkdownPreview({ html, className, find }: { html: string, className?: string, find?: string }): ReactElement { export function MarkdownPreview({ html, className, find, streaming }: { html: string, className?: string, find?: string, streaming?: boolean }): ReactElement {
// 同一段 HTML 用同一个对象:React 见到新对象就整段重设 innerHTML,里面的链接都换成新元素,悬停着的那个就脱离了页面
const content = useMemo(() => ({ __html: html }), [html]) const content = useMemo(() => ({ __html: html }), [html])
return <div className={cn('cr-markdown', className)} data-find={find} dangerouslySetInnerHTML={content} /> return <div className={cn('cr-markdown', className)} data-find={find} data-streaming={streaming ? '' : undefined} dangerouslySetInnerHTML={content} />
} }