diff --git a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx index 3a7a00af..c0de7ea7 100644 --- a/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx +++ b/desktop/src/components/chat/AssistantMessage.linkrouting.test.tsx @@ -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) => (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( + , + ) + expect(screen.getByLabelText('打开方式')).toBeInTheDocument() + }) + + it('does NOT inject a trigger while streaming', () => { + render( + , + ) + expect(screen.queryByLabelText('打开方式')).toBeNull() + }) + + it('does NOT inject a trigger for an ignored link (#anchor)', () => { + render( + , + ) + expect(screen.queryByLabelText('打开方式')).toBeNull() + }) + + it('does NOT inject a trigger when sessionId is absent', () => { + render( + , + ) + expect(screen.queryByLabelText('打开方式')).toBeNull() + }) + + it('clicking the ▾ trigger opens a menu with in-app-browser and system-browser items for a URL', async () => { + render( + , + ) + + 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() + }) +}) diff --git a/desktop/src/components/chat/AssistantMessage.tsx b/desktop/src/components/chat/AssistantMessage.tsx index 3a8d057a..4b72cd72 100644 --- a/desktop/src/components/chat/AssistantMessage.tsx +++ b/desktop/src/components/chat/AssistantMessage.tsx @@ -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(null) + const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null) + const handleLinkClick = useCallback( (href: string, event: ReactMouseEvent): 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('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) => { + const trigger = (event.target as HTMLElement | null)?.closest('.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
- +
+ +
{!isStreaming && } {isStreaming && ( @@ -75,6 +132,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre align="start" />
+ {openWith && setOpenWith(null)} />} ) }) diff --git a/desktop/src/lib/handlePreviewLink.ts b/desktop/src/lib/handlePreviewLink.ts index 57e5d164..465be78f 100644 --- a/desktop/src/lib/handlePreviewLink.ts +++ b/desktop/src/lib/handlePreviewLink.ts @@ -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(/^\/+/, '/')}` } diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts new file mode 100644 index 00000000..85c579c0 --- /dev/null +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -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 }) + }) +}) diff --git a/desktop/src/lib/openWithContextForHref.ts b/desktop/src/lib/openWithContextForHref.ts new file mode 100644 index 00000000..b982afe6 --- /dev/null +++ b/desktop/src/lib/openWithContextForHref.ts @@ -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 +}