feat(desktop): add open-with menu on previewable links in completed AI messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 15:29:36 +08:00
parent 50dbb6c822
commit 0035e7df3f
5 changed files with 254 additions and 10 deletions

View File

@ -1,5 +1,5 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
@ -11,9 +11,52 @@ vi.mock('../../lib/desktopRuntime', async (orig) => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
// Mock openTargetStore for the open-with menu
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const openTargetFn = vi.hoisted(() => vi.fn())
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: {
getState: () => ({ ensureTargets, targets: [], openTarget: openTargetFn }),
},
}))
// Mock workspacePanelStore — workDir returns undefined (no active workspace)
const openPreviewFn = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('../../stores/workspacePanelStore', () => ({
useWorkspacePanelStore: {
getState: () => ({
statusBySession: {},
openPreview: openPreviewFn,
}),
},
}))
// Mock tauri shell (used by openSystem inside handleContentClick)
const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('@tauri-apps/plugin-shell', () => ({ open: shellOpen }))
// Mock i18n — return the key as the label so we can assert on keys
vi.mock('../../i18n', () => ({
useTranslation: () => (k: string, v?: Record<string, string>) => (v?.target ? `${k}:${v.target}` : k),
// TranslationKey is just a string-branded type; no runtime value needed
}))
// Mock settingsStore (pulled in transitively by i18n when NOT mocking i18n at module level)
// (already covered by the i18n mock above, but add a safety net)
vi.mock('../../stores/settingsStore', () => ({
useSettingsStore: Object.assign((sel: (s: { locale: string }) => unknown) => sel({ locale: 'en' }), {
getState: () => ({ locale: 'en' }),
subscribe: () => () => {},
}),
}))
import { AssistantMessage } from './AssistantMessage'
afterEach(() => openBrowser.mockReset())
afterEach(() => {
openBrowser.mockReset()
ensureTargets.mockReset().mockResolvedValue(undefined)
openTargetFn.mockReset()
})
describe('AssistantMessage link routing', () => {
it('opens a localhost link in the in-app browser', () => {
@ -22,3 +65,68 @@ describe('AssistantMessage link routing', () => {
expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/')
})
})
describe('AssistantMessage open-with trigger injection', () => {
it('injects a ▾ trigger for a previewable link after streaming ends', () => {
render(
<AssistantMessage
sessionId="s1"
content={'打开 [预览](http://localhost:5173/)'}
isStreaming={false}
/>,
)
expect(screen.getByLabelText('打开方式')).toBeInTheDocument()
})
it('does NOT inject a trigger while streaming', () => {
render(
<AssistantMessage
sessionId="s1"
content={'打开 [预览](http://localhost:5173/)'}
isStreaming={true}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
})
it('does NOT inject a trigger for an ignored link (#anchor)', () => {
render(
<AssistantMessage
sessionId="s1"
content={'[anchor](#section)'}
isStreaming={false}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
})
it('does NOT inject a trigger when sessionId is absent', () => {
render(
<AssistantMessage
content={'打开 [预览](http://localhost:5173/)'}
isStreaming={false}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
})
it('clicking the ▾ trigger opens a menu with in-app-browser and system-browser items for a URL', async () => {
render(
<AssistantMessage
sessionId="s1"
content={'打开 [预览](http://localhost:5173/)'}
isStreaming={false}
/>,
)
const trigger = screen.getByLabelText('打开方式')
fireEvent.click(trigger)
// Wait for the async ensureTargets chain to resolve and the menu to appear
await waitFor(() => {
// buildOpenWithItems for kind:'url' produces in-app + system keys
expect(screen.getByText('openWith.inAppBrowser')).toBeInTheDocument()
})
expect(screen.getByText('openWith.systemBrowser')).toBeInTheDocument()
})
})

View File

@ -1,4 +1,4 @@
import { memo, useCallback } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
@ -7,6 +7,12 @@ import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForHref } from '../../lib/openWithContextForHref'
import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { useTranslation, type TranslationKey } from '../../i18n'
type Props = {
content: string
@ -16,6 +22,10 @@ type Props = {
}
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId }: Props) {
const t = useTranslation()
const contentRef = useRef<HTMLDivElement>(null)
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
const handleLinkClick = useCallback(
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
if (!sessionId) return false
@ -38,6 +48,51 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
[sessionId],
)
// Inject ▾ triggers after streaming completes — gated on !isStreaming
useEffect(() => {
const root = contentRef.current
if (!root || !sessionId || isStreaming) return
root.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((link) => {
const href = link.getAttribute('href') ?? ''
if (classifyPreviewLink(href).kind === 'ignored') return
if (link.nextElementSibling instanceof HTMLElement && link.nextElementSibling.classList.contains('md-open-with')) return
const trigger = document.createElement('button')
trigger.type = 'button'
trigger.className = 'md-open-with'
trigger.dataset.openWithHref = href
trigger.setAttribute('aria-label', '打开方式')
trigger.tabIndex = -1
trigger.textContent = '▾'
trigger.style.cssText = 'margin-left:3px;padding:0 3px;border:none;background:transparent;color:var(--color-text-tertiary);cursor:pointer;font-size:11px;line-height:1;vertical-align:middle'
link.after(trigger)
})
}, [content, isStreaming, sessionId])
const handleContentClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
const trigger = (event.target as HTMLElement | null)?.closest<HTMLElement>('.md-open-with')
if (!trigger || !sessionId) return
event.preventDefault()
event.stopPropagation()
const href = trigger.dataset.openWithHref ?? ''
const rect = trigger.getBoundingClientRect()
void (async () => {
const store = useOpenTargetStore.getState()
await store.ensureTargets()
const targets = useOpenTargetStore.getState().targets
const workDir = useWorkspacePanelStore.getState().statusBySession[sessionId]?.workDir
const ctx = openWithContextForHref(href, { sessionId, serverBaseUrl: getServerBaseUrl(), workDir })
if (!ctx) return
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => window.open(p, '_blank')) },
openWorkspacePreview: (relPath) => { void useWorkspacePanelStore.getState().openPreview(sessionId, relPath, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (key, vars) => t(key as TranslationKey, vars),
})
setOpenWith({ items, anchor: rect })
})()
}, [sessionId, t])
if (!content.trim()) return null
const documentLayout = shouldUseDocumentLayout(content)
@ -56,12 +111,14 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<MarkdownRenderer
content={content}
variant={documentLayout ? 'document' : 'default'}
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
<div ref={contentRef} onClick={handleContentClick}>
<MarkdownRenderer
content={content}
variant={documentLayout ? 'document' : 'default'}
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
</div>
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
@ -75,6 +132,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
align="start"
/>
</div>
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} onClose={() => setOpenWith(null)} />}
</div>
)
})

View File

@ -17,7 +17,7 @@ export type PreviewLinkDeps = {
* runs `path.resolve(workDir, relPath)`, so an absolute path is resolved as an
* absolute-within-workspace path and sandbox-checked against the work dir root.
*/
function previewFsUrl(base: string, sessionId: string, filePath: string): string {
export function previewFsUrl(base: string, sessionId: string, filePath: string): string {
return `${base.replace(/\/$/, '')}/preview-fs/${encodeURIComponent(sessionId)}/${filePath.replace(/^\/+/, '/')}`
}

View File

@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest'
// Mock isLoopbackHostname so previewLinkRouter classifies localhost properly
vi.mock('./desktopRuntime', () => ({
isLoopbackHostname: (h: string) => h === 'localhost' || h === '127.0.0.1' || h === '::1',
}))
import { openWithContextForHref } from './openWithContextForHref'
import { previewFsUrl } from './handlePreviewLink'
const BASE = 'http://127.0.0.1:4321'
const SESSION = 's1'
describe('openWithContextForHref', () => {
it('localhost href → {kind:"url", url}', () => {
const result = openWithContextForHref('http://localhost:5173/', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({ kind: 'url', url: 'http://localhost:5173/' })
})
it('remote href → {kind:"url", url}', () => {
const result = openWithContextForHref('https://example.com/page', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({ kind: 'url', url: 'https://example.com/page' })
})
it('relative previewable path with workDir → absolutePath resolved', () => {
const result = openWithContextForHref('docs/a.md', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/w' })
expect(result).toEqual({ kind: 'file', absolutePath: '/w/docs/a.md', relPath: 'docs/a.md', previewable: true })
})
it('absolute path in browser-file → inAppBrowserUrl via previewFsUrl', () => {
const result = openWithContextForHref('/x/p.html', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({
kind: 'file',
absolutePath: '/x/p.html',
inAppBrowserUrl: previewFsUrl(BASE, SESSION, '/x/p.html'),
})
})
it('#anchor href → null (ignored)', () => {
const result = openWithContextForHref('#anchor', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toBeNull()
})
it('empty href → null', () => {
const result = openWithContextForHref('', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toBeNull()
})
it('relative path with trailing slash on workDir → correct absolutePath', () => {
const result = openWithContextForHref('src/index.ts', { sessionId: SESSION, serverBaseUrl: BASE, workDir: '/proj/' })
expect(result).toEqual({ kind: 'file', absolutePath: '/proj/src/index.ts', relPath: 'src/index.ts', previewable: true })
})
})

View File

@ -0,0 +1,25 @@
import { classifyPreviewLink } from './previewLinkRouter'
import { previewFsUrl } from './handlePreviewLink'
import type { OpenWithContext } from './openWithItems'
function resolveAbsolute(workDir: string | undefined, p: string): string {
if (!workDir || p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)) return p
return `${workDir.replace(/[\\/]+$/, '')}/${p.replace(/^[/\\]+/, '')}`
}
export function openWithContextForHref(
href: string,
opts: { sessionId: string; serverBaseUrl: string; workDir?: string },
): OpenWithContext | null {
const c = classifyPreviewLink(href)
if ((c.kind === 'browser-localhost' || c.kind === 'remote') && c.url) {
return { kind: 'url', url: c.url }
}
if (c.kind === 'file-preview' && c.path) {
return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), relPath: c.path, previewable: true }
}
if (c.kind === 'browser-file' && c.path) {
return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) }
}
return null
}