296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|