mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
50dbb6c822
commit
0035e7df3f
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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(/^\/+/, '/')}`
|
||||
}
|
||||
|
||||
|
||||
53
desktop/src/lib/openWithContextForHref.test.ts
Normal file
53
desktop/src/lib/openWithContextForHref.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
25
desktop/src/lib/openWithContextForHref.ts
Normal file
25
desktop/src/lib/openWithContextForHref.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user