+ 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 (
+
+
+ {displayText}
+ {timeStr}
+
+
+ )
+}
+
+function ChatMessageBubble({
+ comment,
+ isMine,
+ avatarSrc,
+}: {
+ comment: IssueChatComment
+ isMine: boolean
+ avatarSrc?: string
+}) {
+ const timeStr = formatCommentTime(comment.createdAt)
+
+ if (isMine) {
+ return (
+
+
+
+ {timeStr}
+
+ {comment.author}
+ {' '}
+ (我)
+
+
+
+ {comment.body}
+
+
+
+ {avatarSrc
+ ? (
+

+ )
+ : (
+ '我'
+ )}
+
+
+ )
+ }
+
+ return (
+
+
+ {avatarSrc
+ ? (
+

+ )
+ : (
+ comment.author.slice(0, 1).toUpperCase()
+ )}
+
+
+
+
+ {comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author}
+
+ {timeStr}
+
+
+ {comment.body}
+
+
+
+ )
+}
+
+export function IssueChatPanel({
+ issue,
+ me,
+ avatars,
+ onOpenInBrowser,
+}: IssueChatPanelProps) {
+ const [comments, setComments] = useState
([])
+ const [loading, setLoading] = useState(false)
+ const [sending, setSending] = useState(false)
+ const [input, setInput] = useState('')
+ const streamEndRef = useRef(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) => {
+ if (e.key === 'Enter') {
+ if (e.metaKey || e.ctrlKey || !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+ }
+
+ if (!issue) {
+ return (
+
+ 按方向键或点击卡片查看讨论
+
+ )
+ }
+
+ return (
+
+ {/* 标题栏:对齐 IssueDetailPanel */}
+
+
+
+ {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
+
+ {issue.source === 'youtrack' && (
+
+ YouTrack
+
+ )}
+ {' '}
+ {issue.title}
+
+
+
+
+
+ {/* 评论消息流 */}
+
+ {comments.length === 0 && !loading && (
+
+
+ 还没有讨论
+ 在此发起对此工单的讨论,评论将双向同步至 Gitea
+
+ )}
+ {comments.map((comment) => {
+ if (comment.systemText) {
+ return
+ }
+ const isMine = !!(me && comment.author === me)
+ const avatarSrc = avatars?.[comment.author] || comment.avatarUrl
+ return (
+
+ )
+ })}
+
+
+
+ {/* 底部输入栏 */}
+
+
+
+ )
+}
diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts
index d99e138..b257d44 100644
--- a/vscode/webview-ui/src/lib/messages.ts
+++ b/vscode/webview-ui/src/lib/messages.ts
@@ -42,6 +42,16 @@ export interface ReleaseCommitGroup {
export type ToastLevel = 'info' | 'success' | 'error'
+export interface IssueChatComment {
+ id: number
+ author: string
+ authorName?: string
+ avatarUrl?: string
+ createdAt: string
+ body: string
+ systemText?: string
+}
+
export interface SessionHandoffPendingItem {
attachmentId: number
sid: string
@@ -176,6 +186,7 @@ export type ExtensionToWebview
// profile 值编辑器粘贴的图片:存盘/读盘的回执,requestId 关联并发请求。
| { type: 'profile-asset/saved', requestId: string, assetPath?: string, error?: string }
| { type: 'profile-asset/loaded', requestId: string, dataUrl?: string, error?: string }
+ | { type: 'issue/comments-result', issueNumber: number, comments: IssueChatComment[], error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
@@ -295,3 +306,5 @@ export type WebviewToExtension
// profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。
| { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string }
| { type: 'profile-asset/load', requestId: string, assetPath: string }
+ | { type: 'issue/comments-query', issueNumber: number }
+ | { type: 'issue/comment-post', issueNumber: number, body: string }