Files
superwork/vscode/webview-ui/src/components/IssueChatPanel.tsx
T

296 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IssueChatComment } from '../lib/messages'
import type { Issue } from '../types'
import { ExternalLink, Loader2, MessageCircle, RotateCw, Send } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { onMessage, postMessage } from '../lib/vscode'
export interface IssueChatPanelProps {
issue: Issue | null
me?: string
avatars?: Record<string, string>
onOpenInBrowser: (url: string) => void
}
function formatCommentTime(isoStr: string): string {
try {
const d = new Date(isoStr)
if (Number.isNaN(d.getTime()))
return isoStr
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
if (isToday)
return time
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
}
catch {
return isoStr
}
}
function SystemMessagePill({ comment }: { comment: IssueChatComment }) {
const timeStr = formatCommentTime(comment.createdAt)
const authorPrefix = comment.author && comment.author !== '未知' ? `${comment.author} ` : ''
const displayText = `${authorPrefix}${comment.systemText}`
return (
<div className="my-1 flex justify-center">
<div
className="inline-flex items-center gap-2 rounded-full border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-badge-background,#262626)] px-3 py-1 text-[11px] text-[var(--vscode-descriptionForeground,#9d9d9d)] shadow-sm"
data-tooltip={comment.body}
>
<span>{displayText}</span>
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
</div>
</div>
)
}
function ChatMessageBubble({
comment,
isMine,
avatarSrc,
}: {
comment: IssueChatComment
isMine: boolean
avatarSrc?: string
}) {
const timeStr = formatCommentTime(comment.createdAt)
if (isMine) {
return (
<div className="flex items-start justify-end gap-2 pl-8">
<div className="flex max-w-[85%] flex-col items-end">
<div className="mb-1 flex items-center gap-2">
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
<span className="text-xs font-medium text-[var(--vscode-foreground)]">
{comment.author}
{' '}
(我)
</span>
</div>
<div className="whitespace-pre-wrap break-words rounded-md border border-blue-500/50 bg-[#0e3753] px-3 py-2 text-xs leading-relaxed text-[var(--vscode-foreground)]">
{comment.body}
</div>
</div>
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--vscode-focusBorder,#0078d4)] text-[10px] font-medium text-white">
{avatarSrc
? (
<img src={avatarSrc} alt="" className="size-full object-cover" />
)
: (
'我'
)}
</div>
</div>
)
}
return (
<div className="flex items-start justify-start gap-2 pr-8">
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--vscode-button-secondaryBackground,#3c3c3c)] text-[10px] font-medium text-[var(--vscode-foreground)]">
{avatarSrc
? (
<img src={avatarSrc} alt="" className="size-full object-cover" />
)
: (
comment.author.slice(0, 1).toUpperCase()
)}
</div>
<div className="flex max-w-[85%] flex-col items-start">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-medium text-[var(--vscode-descriptionForeground)]">
{comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author}
</span>
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
</div>
<div className="whitespace-pre-wrap break-words rounded-md border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-sideBar-background,#262626)] px-3 py-2 text-xs leading-relaxed text-[var(--vscode-foreground)]">
{comment.body}
</div>
</div>
</div>
)
}
export function IssueChatPanel({
issue,
me,
avatars,
onOpenInBrowser,
}: IssueChatPanelProps) {
const [comments, setComments] = useState<IssueChatComment[]>([])
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [input, setInput] = useState('')
const streamEndRef = useRef<HTMLDivElement>(null)
const issueNumber = issue?.number
useEffect(() => {
if (issueNumber == null) {
setComments([])
setLoading(false)
return
}
setLoading(true)
postMessage({
type: 'issue/comments-query',
issueNumber,
})
}, [issueNumber])
useEffect(() => {
const dispose = onMessage((msg) => {
if (msg.type === 'issue/comments-result') {
if (issueNumber != null && msg.issueNumber === issueNumber) {
setLoading(false)
setSending(false)
setComments(msg.comments)
}
}
})
return dispose
}, [issueNumber])
useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [comments])
const handleRefresh = () => {
if (issueNumber == null || loading)
return
setLoading(true)
postMessage({
type: 'issue/comments-query',
issueNumber,
})
}
const handleSend = () => {
const text = input.trim()
if (!text || issueNumber == null || sending)
return
setSending(true)
postMessage({
type: 'issue/comment-post',
issueNumber,
body: text,
})
setInput('')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.metaKey || e.ctrlKey || !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
}
if (!issue) {
return (
<div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
按方向键或点击卡片查看讨论
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)]">
{/* 标题栏:对齐 IssueDetailPanel */}
<div className="flex items-center gap-3 border-b border-[var(--vscode-panel-border)] p-3">
<h3 className="flex-1 truncate text-sm font-medium">
<span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
</span>
{issue.source === 'youtrack' && (
<span
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
data-tooltip="此工单来自 YouTrack"
>
YouTrack
</span>
)}
{' '}
{issue.title}
</h3>
<button
type="button"
data-tooltip="刷新讨论"
aria-label="刷新讨论"
onClick={handleRefresh}
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<RotateCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
data-tooltip={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
aria-label={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
</button>
</div>
{/* 评论消息流 */}
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3">
{comments.length === 0 && !loading && (
<div className="flex h-full flex-1 flex-col items-center justify-center gap-2 p-6 text-[var(--vscode-descriptionForeground)]">
<MessageCircle className="size-8 opacity-40" />
<span className="text-sm font-medium">还没有讨论</span>
<span className="text-xs opacity-60">在此发起对此工单的讨论,评论将双向同步至 Gitea</span>
</div>
)}
{comments.map((comment) => {
if (comment.systemText) {
return <SystemMessagePill key={comment.id} comment={comment} />
}
const isMine = !!(me && comment.author === me)
const avatarSrc = avatars?.[comment.author] || comment.avatarUrl
return (
<ChatMessageBubble
key={comment.id}
comment={comment}
isMine={isMine}
avatarSrc={avatarSrc}
/>
)
})}
<div ref={streamEndRef} />
</div>
{/* 底部输入栏 */}
<div className="flex items-end gap-2 border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-sideBar-background,var(--vscode-editor-background))] p-2.5">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="发表对此工单的评论…(支持 Markdown,Enter 发送)"
rows={1}
className="max-h-24 min-h-[32px] flex-1 resize-none rounded border border-[var(--vscode-input-border)] bg-[var(--vscode-input-background)] px-2.5 py-1.5 text-xs leading-normal text-[var(--vscode-input-foreground)] placeholder:text-[var(--vscode-input-placeholderForeground)] focus:border-[var(--vscode-focusBorder)] focus:outline-none"
/>
<button
type="button"
onClick={handleSend}
disabled={!input.trim() || sending}
className="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded bg-[var(--vscode-button-background)] px-3 text-xs font-medium text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:opacity-50"
>
{sending
? (
<Loader2 className="size-3.5 animate-spin" />
)
: (
<Send className="size-3.5" />
)}
<span>发送</span>
</button>
</div>
</div>
)
}