✨ feat(vscode): 看板底栏新增工单讨论 Tab 并支持系统气泡与 Gitea 评论同步
This commit is contained in:
1 parent
8ecb40b330
commit
df17821408
14 files changed
+1555
-2
No files matched your search
@@ -44,6 +44,12 @@ export interface GiteaComment {
|
||||
issue_url: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user?: {
|
||||
id: number
|
||||
login: string
|
||||
full_name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export class GiteaApiError extends Error {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { systemCommentText } from './issueComment'
|
||||
|
||||
describe('systemCommentText', () => {
|
||||
it('列评论变成一句系统话', () => {
|
||||
expect(systemCommentText('{"column":"todo"}')).toBe('把工单放到待办')
|
||||
})
|
||||
|
||||
it('带别的状态字段时仍只说列', () => {
|
||||
expect(systemCommentText('{"column":"review","color":"terminal.ansiBlue"}')).toBe('更新了工单状态,放到审查')
|
||||
})
|
||||
|
||||
it('普通评论不是系统消息', () => {
|
||||
expect(systemCommentText('这个方案可以')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('审查评论去掉标记,剩下的正文当系统消息', () => {
|
||||
expect(systemCommentText('<!-- spx:review=1 -->\n看过了')).toBe('看过了')
|
||||
expect(systemCommentText('<!-- spx:review=1 -->')).toBe('提交了审查')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
const COLUMN_LABEL: Record<string, string> = {
|
||||
'todo': '待办',
|
||||
'in-progress': '进行中',
|
||||
'review': '审查',
|
||||
'done': '完成',
|
||||
}
|
||||
|
||||
const STATE_KEYS = new Set([
|
||||
'column',
|
||||
'sessionId',
|
||||
'implementSessionId',
|
||||
'reviewSessionId',
|
||||
'testSessionId',
|
||||
'prototypeSessionId',
|
||||
'handoffAttachmentId',
|
||||
'handoffFrom',
|
||||
'handoffRequestFrom',
|
||||
'handoffRequestError',
|
||||
'pr',
|
||||
'branch',
|
||||
'worktreePath',
|
||||
'color',
|
||||
'autoReview',
|
||||
])
|
||||
|
||||
/** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */
|
||||
export function systemCommentText(body: string): string | undefined {
|
||||
const trimmed = body.trim()
|
||||
if (/<!--\s*spx:review=1\s*-->/i.test(trimmed)) {
|
||||
const text = trimmed.replace(/<!--\s*spx:review=1\s*-->/gi, '').trim()
|
||||
return text.length > 0 ? text : '提交了审查'
|
||||
}
|
||||
if (!trimmed.startsWith('{'))
|
||||
return undefined
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
return undefined
|
||||
const record = parsed as Record<string, unknown>
|
||||
if (!Object.keys(record).some(key => STATE_KEYS.has(key)))
|
||||
return undefined
|
||||
const column = typeof record.column === 'string' ? COLUMN_LABEL[record.column] : undefined
|
||||
if (Object.keys(record).length === 1 && column)
|
||||
return `把工单放到${column}`
|
||||
if (column)
|
||||
return `更新了工单状态,放到${column}`
|
||||
return '更新了工单状态'
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { webhookCoordinator } from '../webhook/coordinator'
|
||||
import { makeNonce, webviewHtml, webviewUiRoot } from '../webview/html'
|
||||
import { loadYouTrackIssues } from '../youtrack/issueLoader'
|
||||
import * as handoffFlow from './handlers/handoffFlow'
|
||||
import * as issueChat from './handlers/issueChat'
|
||||
import * as issues from './handlers/issues'
|
||||
import * as managedSessions from './handlers/managedSessions'
|
||||
import * as prFiles from './handlers/prFiles'
|
||||
@@ -728,6 +729,14 @@ export class KanbanWebviewPanel {
|
||||
}
|
||||
if (msg.type === 'pr-diff-mode/set') {
|
||||
void prFiles.handleSetPrDiffMode(this, msg.mode)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'issue/comments-query') {
|
||||
void issueChat.handleIssueComments(this, msg.issueNumber)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'issue/comment-post') {
|
||||
void issueChat.handleIssueCommentPost(this, msg.issueNumber, msg.body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { GiteaComment } from '../../gitea/api'
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import { listIssueComments, postIssueComment } from '../../gitea/api'
|
||||
import { systemCommentText } from '../../gitea/issueComment'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { resolveRepoCtx, toast } from './handoffFlow'
|
||||
|
||||
export interface IssueChatComment {
|
||||
id: number
|
||||
author: string
|
||||
authorName?: string
|
||||
avatarUrl?: string
|
||||
createdAt: string
|
||||
body: string
|
||||
systemText?: string
|
||||
}
|
||||
|
||||
function toChat(comment: GiteaComment): IssueChatComment {
|
||||
const systemText = systemCommentText(comment.body)
|
||||
return {
|
||||
id: comment.id,
|
||||
author: comment.user?.login ?? '未知',
|
||||
authorName: comment.user?.full_name || undefined,
|
||||
avatarUrl: comment.user?.avatar_url || undefined,
|
||||
createdAt: comment.created_at,
|
||||
body: comment.body,
|
||||
...(systemText ? { systemText } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function pushComments(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
const comments = await listIssueComments({
|
||||
host: ctx.host,
|
||||
token: ctx.token,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
index: issueNumber,
|
||||
})
|
||||
panel.postMessage({
|
||||
type: 'issue/comments-result',
|
||||
issueNumber,
|
||||
comments: comments.map(toChat),
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleIssueComments(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||
try {
|
||||
await pushComments(panel, issueNumber)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `读取工单 #${issueNumber} 评论失败`, details: message })
|
||||
panel.postMessage({ type: 'issue/comments-result', issueNumber, comments: [], error: message })
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleIssueCommentPost(panel: KanbanWebviewPanel, issueNumber: number, body: string): Promise<void> {
|
||||
const text = body.trim()
|
||||
if (!text)
|
||||
return
|
||||
const ctx = await resolveRepoCtx(panel)
|
||||
if (!ctx)
|
||||
return
|
||||
try {
|
||||
await postIssueComment({
|
||||
host: ctx.host,
|
||||
token: ctx.token,
|
||||
owner: ctx.owner,
|
||||
repo: ctx.repo,
|
||||
index: issueNumber,
|
||||
body: text,
|
||||
})
|
||||
await pushComments(panel, issueNumber)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({ level: 'error', source: 'panel', message: `发表工单 #${issueNumber} 评论失败`, details: message })
|
||||
toast(panel, 'error', `评论没发出去:${message}`)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type { Issue, IssueColumn } from '../gitea/types'
|
||||
import type { LogEntry } from '../logging/logger'
|
||||
import type { ProfilesData } from '../profiles/store'
|
||||
import type { ManagedSession } from '../sessions/managedStore'
|
||||
import type { IssueChatComment } from './handlers/issueChat'
|
||||
|
||||
export type { LogEntry } from '../logging/logger'
|
||||
export type { ProfileRow, ProfilesData } from '../profiles/store'
|
||||
export type { IssueChatComment } from './handlers/issueChat'
|
||||
|
||||
/**
|
||||
* `managed-sessions/show` 的 payload 类型:在持久化的 ManagedSession 基础上
|
||||
@@ -145,6 +147,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' }
|
||||
@@ -264,3 +267,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 }
|
||||
@@ -25,6 +25,7 @@ import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
|
||||
import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
|
||||
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
|
||||
import { logger } from '../logging/logger'
|
||||
import * as issueChat from '../panel/handlers/issueChat'
|
||||
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
|
||||
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
|
||||
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
|
||||
@@ -1422,6 +1423,10 @@ class WebhookCoordinator {
|
||||
message: `issue_comment body diag #${event.issueNumber} len=${body.length}`,
|
||||
details: body.slice(0, 200),
|
||||
})
|
||||
if (this.activePanel && typeof event.issueNumber === 'number') {
|
||||
void issueChat.handleIssueComments(this.activePanel, event.issueNumber)
|
||||
}
|
||||
|
||||
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
|
||||
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
|
||||
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
|
||||
@@ -1492,6 +1497,9 @@ class WebhookCoordinator {
|
||||
source: 'webhook',
|
||||
message: `issue_comment 评论在 PR #${event.prNumber},反查到工单 #${realIssueNumber}`,
|
||||
})
|
||||
if (this.activePanel) {
|
||||
void issueChat.handleIssueComments(this.activePanel, realIssueNumber)
|
||||
}
|
||||
}
|
||||
else {
|
||||
logger.add({
|
||||
|
||||
@@ -13,14 +13,15 @@
|
||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||
import type { ProfilesData, SessionHandoffPendingItem } from '../lib/messages'
|
||||
import type { Issue, ManagedSessionsData } from '../types'
|
||||
import { ClipboardList, FileDiff, Layers, MessagesSquare } from 'lucide-react'
|
||||
import { ClipboardList, FileDiff, Layers, MessageCircle, MessagesSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { IssueChatPanel } from './IssueChatPanel'
|
||||
import { IssueDetailPanel } from './IssueDetailPanel'
|
||||
import { ManagedSessionsPanel } from './ManagedSessionsPanel'
|
||||
import { PrFilesPanel } from './PrFilesPanel'
|
||||
import { ProfileGrid } from './ProfileGrid'
|
||||
|
||||
type TabKey = 'issue' | 'profile' | 'sessions' | 'changes'
|
||||
type TabKey = 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'
|
||||
|
||||
interface BottomTabsProps {
|
||||
// Issue tab props (pass-through to IssueDetailPanel)
|
||||
@@ -103,6 +104,12 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
icon={<ClipboardList className="size-4" />}
|
||||
title="工单"
|
||||
/>
|
||||
<TabButton
|
||||
active={tab === 'chat'}
|
||||
onClick={() => setTab('chat')}
|
||||
icon={<MessageCircle className="size-4" />}
|
||||
title="讨论"
|
||||
/>
|
||||
<TabButton
|
||||
active={tab === 'profile'}
|
||||
onClick={() => setTab('profile')}
|
||||
@@ -177,6 +184,18 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
isHandoffRunning={props.isHandoffRunning}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`h-full w-full ${tab === 'chat' ? '' : 'hidden'}`}
|
||||
aria-hidden={tab !== 'chat'}
|
||||
inert={tab !== 'chat' ? true : undefined}
|
||||
>
|
||||
<IssueChatPanel
|
||||
issue={props.issue}
|
||||
me={props.me}
|
||||
avatars={props.avatars}
|
||||
onOpenInBrowser={props.onOpenInBrowser}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`h-full w-full ${tab === 'profile' ? '' : 'hidden'}`}
|
||||
aria-hidden={tab !== 'profile'}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user