mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat(desktop): render message-complete previewable refs as rich cards (drop inline pill)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e49b9390b6
commit
95d1ba997a
@ -1,5 +1,5 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
|
||||
@ -11,7 +11,7 @@ vi.mock('../../lib/desktopRuntime', async (orig) => ({
|
||||
getServerBaseUrl: () => 'http://127.0.0.1:4321',
|
||||
}))
|
||||
|
||||
// Mock openTargetStore for the open-with menu
|
||||
// Mock openTargetStore for the open-with menu (used by the cards)
|
||||
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const openTargetFn = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../stores/openTargetStore', () => ({
|
||||
@ -20,29 +20,28 @@ vi.mock('../../stores/openTargetStore', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock workspacePanelStore — workDir returns undefined (no active workspace)
|
||||
// Mock workspacePanelStore — usable both as a hook selector and via getState().
|
||||
// workDir is undefined (no active workspace) so relative paths resolve as-is.
|
||||
const openPreviewFn = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
vi.mock('../../stores/workspacePanelStore', () => ({
|
||||
useWorkspacePanelStore: {
|
||||
getState: () => ({
|
||||
statusBySession: {},
|
||||
openPreview: openPreviewFn,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
vi.mock('../../stores/workspacePanelStore', () => {
|
||||
const state = { statusBySession: {} as Record<string, { workDir?: string } | undefined>, openPreview: openPreviewFn }
|
||||
const useWorkspacePanelStore = Object.assign(
|
||||
(selector: (s: typeof state) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
)
|
||||
return { useWorkspacePanelStore }
|
||||
})
|
||||
|
||||
// Mock tauri shell (used by openSystem inside handleContentClick)
|
||||
// Mock tauri shell (used by openSystem inside the card's open-with)
|
||||
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)
|
||||
// Mock settingsStore (safety net for transitive i18n usage)
|
||||
vi.mock('../../stores/settingsStore', () => ({
|
||||
useSettingsStore: Object.assign((sel: (s: { locale: string }) => unknown) => sel({ locale: 'en' }), {
|
||||
getState: () => ({ locale: 'en' }),
|
||||
@ -56,120 +55,74 @@ afterEach(() => {
|
||||
openBrowser.mockReset()
|
||||
ensureTargets.mockReset().mockResolvedValue(undefined)
|
||||
openTargetFn.mockReset()
|
||||
openPreviewFn.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('AssistantMessage link routing', () => {
|
||||
it('opens a localhost link in the in-app browser', () => {
|
||||
it('opens a localhost link in the in-app browser on left-click', () => {
|
||||
render(<AssistantMessage sessionId="s1" content={'打开 [预览](http://localhost:5173/)'} />)
|
||||
fireEvent.click(screen.getByText('预览'))
|
||||
// The clickable element is the rendered markdown anchor (the card title is a span).
|
||||
fireEvent.click(screen.getByRole('link', { name: '预览' }))
|
||||
expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssistantMessage open-with trigger injection', () => {
|
||||
it('injects a ▾ trigger for a previewable link after streaming ends', () => {
|
||||
describe('AssistantMessage output-target cards', () => {
|
||||
it('renders a card for a localhost URL after streaming ends', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'打开 [预览](http://localhost:5173/)'}
|
||||
content={'本地服务运行在 http://localhost:5173/ 上'}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByLabelText('打开方式')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('http://localhost:5173/').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does NOT inject a trigger while streaming', () => {
|
||||
it('renders a card for a markdown link with its Markdown badge', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'打开 [预览](http://localhost:5173/)'}
|
||||
content={'见 [说明文档](docs/readme.md)'}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
// Link text appears in both the bubble anchor and the card title; the badge is unique.
|
||||
expect(screen.getByRole('link', { name: '说明文档' })).toBeInTheDocument()
|
||||
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does NOT render cards while streaming', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'本地服务运行在 http://localhost:5173/ 上'}
|
||||
isStreaming={true}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByLabelText('打开方式')).toBeNull()
|
||||
expect(screen.queryByText('assistantOutputs.kind.localhost')).toBeNull()
|
||||
})
|
||||
|
||||
it('does NOT inject a trigger for an ignored link (#anchor)', () => {
|
||||
it('does NOT render cards when there are no previewable references', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'[anchor](#section)'}
|
||||
content={'装一下 `npm install` 然后看 [anchor](#section)'}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByLabelText('打开方式')).toBeNull()
|
||||
expect(screen.queryByText('assistantOutputs.kind.localhost')).toBeNull()
|
||||
expect(screen.queryByText('Markdown')).toBeNull()
|
||||
})
|
||||
|
||||
it('does NOT inject a trigger when sessionId is absent', () => {
|
||||
it('does NOT render cards when sessionId is absent', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
content={'打开 [预览](http://localhost:5173/)'}
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssistantMessage open-with trigger injection — inline code URLs', () => {
|
||||
it('injects a ▾ trigger next to an inline-code localhost URL', async () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'运行地址 `http://localhost:9527/`'}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
// The trigger comes from the <code> path (no <a> in this content)
|
||||
expect(screen.getByLabelText('打开方式')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clicking the trigger next to inline-code URL opens menu with in-app-browser and system-browser items', async () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'运行地址 `http://localhost:9527/`'}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByLabelText('打开方式')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('openWith.inAppBrowser')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('openWith.systemBrowser')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does NOT inject a trigger for non-URL inline code', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
sessionId="s1"
|
||||
content={'装一下 `npm install`'}
|
||||
isStreaming={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByLabelText('打开方式')).toBeNull()
|
||||
expect(screen.queryByText('assistantOutputs.kind.localhost')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
|
||||
import { handlePreviewLink } from '../../lib/handlePreviewLink'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
|
||||
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'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
@ -21,10 +18,11 @@ type Props = {
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
const MAX_CARDS = 3
|
||||
|
||||
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 workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
|
||||
@ -48,65 +46,10 @@ 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
|
||||
|
||||
function makeTrigger(href: string): HTMLButtonElement {
|
||||
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 = '打开方式 ⌄'
|
||||
// A clear, properly-sized pill (not a tiny bare caret): bigger hit target + discoverable.
|
||||
trigger.style.cssText = 'display:inline-flex;align-items:center;margin:0 3px;padding:1px 8px;border:1px solid var(--color-border);border-radius:9px;background:var(--color-surface);color:var(--color-text-secondary);cursor:pointer;font-size:11px;line-height:1.5;vertical-align:middle;white-space:nowrap'
|
||||
return trigger
|
||||
}
|
||||
|
||||
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
|
||||
link.after(makeTrigger(href))
|
||||
})
|
||||
|
||||
root.querySelectorAll<HTMLElement>('code').forEach((code) => {
|
||||
if (code.closest('pre')) return
|
||||
const text = (code.textContent ?? '').trim()
|
||||
const kind = classifyPreviewLink(text).kind
|
||||
if (kind !== 'browser-localhost' && kind !== 'remote') return
|
||||
if (code.nextElementSibling instanceof HTMLElement && code.nextElementSibling.classList.contains('md-open-with')) return
|
||||
code.after(makeTrigger(text))
|
||||
})
|
||||
}, [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])
|
||||
const outputTargets = useMemo(
|
||||
() => (isStreaming || !sessionId ? [] : extractAssistantOutputTargets(content, { workDir })),
|
||||
[content, isStreaming, sessionId, workDir],
|
||||
)
|
||||
|
||||
if (!content.trim()) return null
|
||||
|
||||
@ -126,20 +69,31 @@ 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'
|
||||
}`}>
|
||||
<div ref={contentRef} onClick={handleContentClick}>
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
variant={documentLayout ? 'document' : 'default'}
|
||||
streaming={isStreaming}
|
||||
onLinkClick={sessionId ? handleLinkClick : undefined}
|
||||
/>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
variant={documentLayout ? 'document' : 'default'}
|
||||
streaming={isStreaming}
|
||||
onLinkClick={sessionId ? handleLinkClick : undefined}
|
||||
/>
|
||||
{!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" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isStreaming && sessionId && outputTargets.length > 0 && (
|
||||
<div className="mt-1 flex w-full flex-col gap-2">
|
||||
{outputTargets.slice(0, MAX_CARDS).map((target) => (
|
||||
<AssistantOutputTargetCard key={target.id} target={target} sessionId={sessionId} workDir={workDir} />
|
||||
))}
|
||||
{outputTargets.length > MAX_CARDS && (
|
||||
<div className="px-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('assistantOutputs.moreOutputs', { count: String(outputTargets.length - MAX_CARDS) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageActionBar
|
||||
copyText={isStreaming ? undefined : content}
|
||||
copyLabel="Copy reply"
|
||||
@ -147,7 +101,6 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} onClose={() => setOpenWith(null)} />}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
110
desktop/src/components/chat/AssistantOutputTargetCard.test.tsx
Normal file
110
desktop/src/components/chat/AssistantOutputTargetCard.test.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
|
||||
vi.mock('../../stores/browserPanelStore', () => ({
|
||||
useBrowserPanelStore: { getState: () => ({ open: openBrowser }) },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/desktopRuntime', async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
getServerBaseUrl: () => 'http://127.0.0.1:4321',
|
||||
}))
|
||||
|
||||
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
const openTargetFn = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../stores/openTargetStore', () => ({
|
||||
useOpenTargetStore: {
|
||||
getState: () => ({ ensureTargets, targets: [], openTarget: openTargetFn }),
|
||||
},
|
||||
}))
|
||||
|
||||
const openPreviewFn = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
vi.mock('../../stores/workspacePanelStore', () => ({
|
||||
useWorkspacePanelStore: {
|
||||
getState: () => ({ statusBySession: {}, openPreview: openPreviewFn }),
|
||||
},
|
||||
}))
|
||||
|
||||
const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
vi.mock('@tauri-apps/plugin-shell', () => ({ open: shellOpen }))
|
||||
|
||||
// Mock i18n — return the key (plus interpolation) so we can assert on keys
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => (k: string, v?: Record<string, string>) => (v?.target ? `${k}:${v.target}` : k),
|
||||
}))
|
||||
|
||||
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
|
||||
import type { AssistantOutputTarget } from '../../lib/assistantOutputTargets'
|
||||
|
||||
const markdownTarget: AssistantOutputTarget = {
|
||||
id: 'markdown:docs/readme.md',
|
||||
kind: 'markdown',
|
||||
title: 'readme.md',
|
||||
subtitle: 'docs/readme.md',
|
||||
href: 'docs/readme.md',
|
||||
normalizedPath: 'docs/readme.md',
|
||||
confidence: 'high',
|
||||
source: 'markdown-link',
|
||||
}
|
||||
|
||||
const localhostTarget: AssistantOutputTarget = {
|
||||
id: 'localhost-url:http://localhost:5173/',
|
||||
kind: 'localhost-url',
|
||||
title: 'http://localhost:5173/',
|
||||
href: 'http://localhost:5173/',
|
||||
confidence: 'high',
|
||||
source: 'plain-url',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
openBrowser.mockReset()
|
||||
ensureTargets.mockReset().mockResolvedValue(undefined)
|
||||
openTargetFn.mockReset()
|
||||
openPreviewFn.mockReset().mockResolvedValue(undefined)
|
||||
shellOpen.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('AssistantOutputTargetCard', () => {
|
||||
it('renders a markdown target title + Markdown badge', () => {
|
||||
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
|
||||
expect(screen.getByText('readme.md')).toBeInTheDocument()
|
||||
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('docs/readme.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a localhost target title + Localhost badge', () => {
|
||||
render(<AssistantOutputTargetCard target={localhostTarget} sessionId="s1" />)
|
||||
// Title + subtitle both fall back to href, so it appears twice.
|
||||
expect(screen.getAllByText('http://localhost:5173/').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('routes Open to workspace preview for a markdown target', () => {
|
||||
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
|
||||
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'docs/readme.md', 'file')
|
||||
})
|
||||
|
||||
it('routes Open to the in-app browser for a localhost target', () => {
|
||||
render(<AssistantOutputTargetCard target={localhostTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
|
||||
expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/')
|
||||
})
|
||||
|
||||
it('copies the normalized path when Copy is clicked', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, { clipboard: { writeText } })
|
||||
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByLabelText('assistantOutputs.copy'))
|
||||
expect(writeText).toHaveBeenCalledWith('docs/readme.md')
|
||||
})
|
||||
|
||||
it('opens the open-with menu with URL items for a localhost target', async () => {
|
||||
render(<AssistantOutputTargetCard target={localhostTarget} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByLabelText('openWith.title'))
|
||||
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
|
||||
expect(screen.getByText('openWith.systemBrowser')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
140
desktop/src/components/chat/AssistantOutputTargetCard.tsx
Normal file
140
desktop/src/components/chat/AssistantOutputTargetCard.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { ChevronDown, Copy, ExternalLink, Globe } from 'lucide-react'
|
||||
import type { AssistantOutputTarget } from '../../lib/assistantOutputTargets'
|
||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||
import { OpenWithMenu } from '../common/OpenWithMenu'
|
||||
import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../lib/openWithItems'
|
||||
import { openWithContextForHref } from '../../lib/openWithContextForHref'
|
||||
import { handlePreviewLink } from '../../lib/handlePreviewLink'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
|
||||
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
|
||||
import { copyTextToClipboard } from './clipboard'
|
||||
|
||||
type Props = {
|
||||
target: AssistantOutputTarget
|
||||
sessionId: string
|
||||
workDir?: string
|
||||
}
|
||||
|
||||
export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props) {
|
||||
const t = useTranslation()
|
||||
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
|
||||
|
||||
const isLocalhost = target.kind === 'localhost-url'
|
||||
const typeInfo = describeFileType(target.normalizedPath ?? target.href)
|
||||
const icon = typeInfo.icon
|
||||
const badge = isLocalhost
|
||||
? t('assistantOutputs.kind.localhost')
|
||||
: target.kind === 'local-html'
|
||||
? t('assistantOutputs.kind.html')
|
||||
: target.kind === 'markdown'
|
||||
? t('assistantOutputs.kind.markdown')
|
||||
: t('assistantOutputs.kind.image')
|
||||
const subtitle = target.subtitle ?? target.normalizedPath ?? target.href
|
||||
|
||||
const handleOpen = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
handlePreviewLink(target.href, {
|
||||
sessionId,
|
||||
serverBaseUrl: getServerBaseUrl(),
|
||||
openBrowser: (id, url) => useBrowserPanelStore.getState().open(id, url),
|
||||
openFilePreview: (id, path) => {
|
||||
void useWorkspacePanelStore.getState().openPreview(id, path, 'file')
|
||||
},
|
||||
openExternal: (url) => {
|
||||
void import('@tauri-apps/plugin-shell')
|
||||
.then((m) => m.open(url))
|
||||
.catch(() => window.open(url, '_blank'))
|
||||
},
|
||||
})
|
||||
}, [sessionId, target.href])
|
||||
|
||||
const handleCopy = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
void copyTextToClipboard(target.normalizedPath ?? target.href)
|
||||
}, [target.href, target.normalizedPath])
|
||||
|
||||
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
void (async () => {
|
||||
await useOpenTargetStore.getState().ensureTargets()
|
||||
const targets = useOpenTargetStore.getState().targets
|
||||
const ctx = openWithContextForHref(target.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: (k, v) => t(k as TranslationKey, v),
|
||||
})
|
||||
setOpenWith({ items, anchor: rect })
|
||||
})()
|
||||
}, [sessionId, t, target.href, workDir])
|
||||
|
||||
return (
|
||||
<section className="flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-3 py-2.5 shadow-sm">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
|
||||
{isLocalhost ? (
|
||||
<Globe size={17} strokeWidth={2.1} aria-hidden="true" />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">{icon}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{target.title}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]" title={subtitle}>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
aria-label={t('assistantOutputs.open')}
|
||||
title={t('assistantOutputs.open')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<ExternalLink size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('assistantOutputs.copy')}
|
||||
title={t('assistantOutputs.copy')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
<Copy size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('openWith.title')}
|
||||
onClick={handleOpenWith}
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||
>
|
||||
{t('openWith.title')}
|
||||
<ChevronDown size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} onClose={() => setOpenWith(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -105,6 +105,15 @@ export const en = {
|
||||
'openWith.fileType.code': 'Code',
|
||||
'openWith.fileType.file': 'File',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
'assistantOutputs.kind.markdown': 'Markdown',
|
||||
'assistantOutputs.kind.html': 'HTML',
|
||||
'assistantOutputs.kind.image': 'Image',
|
||||
'assistantOutputs.kind.localhost': 'Localhost',
|
||||
'assistantOutputs.moreOutputs': '+{count} more',
|
||||
'assistantOutputs.open': 'Open',
|
||||
'assistantOutputs.copy': 'Copy path',
|
||||
|
||||
// ─── Workspace Panel ───────────────────────────────
|
||||
'workspace.changedFiles': 'Changed files',
|
||||
'workspace.allFiles': 'All files',
|
||||
|
||||
@ -107,6 +107,15 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'openWith.fileType.code': '代码',
|
||||
'openWith.fileType.file': '文件',
|
||||
|
||||
// ─── Assistant Output Targets ──────────────────────
|
||||
'assistantOutputs.kind.markdown': 'Markdown',
|
||||
'assistantOutputs.kind.html': 'HTML',
|
||||
'assistantOutputs.kind.image': '图片',
|
||||
'assistantOutputs.kind.localhost': '本地服务',
|
||||
'assistantOutputs.moreOutputs': '+{count} 个输出',
|
||||
'assistantOutputs.open': '打开',
|
||||
'assistantOutputs.copy': '复制路径',
|
||||
|
||||
// ─── Workspace Panel ───────────────────────────────
|
||||
'workspace.changedFiles': '已更改文件',
|
||||
'workspace.allFiles': '所有文件',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user