✨ feat(vscode): 支持讨论评论引用与 macOS 系统级新评论桌面提醒

This commit is contained in:
cruldra committed 2026-09-29 04:23:57 +08:00
1 parent 540c212bad
commit a2c3c1f919
13 files changed
+638 -302

No files matched your search

+11 -1
View File
@@ -1,3 +1,4 @@
import type { TabKey } from './components/BottomTabs'
import type { PastedImage } from './components/NewIssueModal'
import type { Issue } from './types'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -76,6 +77,8 @@ export function App() {
clearLogs,
pendingSelectId,
clearPendingSelect,
pendingSelectTab,
clearPendingSelectTab,
commitRunning,
runCommit,
hasChanges,
@@ -123,6 +126,7 @@ export function App() {
const [handoffIssue, setHandoffIssue] = useState<number | null>(null)
const [handoffSession, setHandoffSession] = useState<{ id: string, name: string } | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [bottomTab, setBottomTab] = useState<TabKey>('issue')
// 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦
// effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。
const lastProgrammaticSelectRef = useRef<string | null>(null)
@@ -157,8 +161,12 @@ export function App() {
return
lastProgrammaticSelectRef.current = pendingSelectId
setSelectedId(pendingSelectId)
if (pendingSelectTab) {
setBottomTab(pendingSelectTab)
clearPendingSelectTab()
}
clearPendingSelect()
}, [pendingSelectId, readyIssues, clearPendingSelect])
}, [pendingSelectId, pendingSelectTab, readyIssues, clearPendingSelect, clearPendingSelectTab])
// Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation.
// 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。
@@ -397,6 +405,8 @@ export function App() {
</div>
<div className="h-[40vh] shrink-0">
<BottomTabs
tab={bottomTab}
onTabChange={setBottomTab}
issue={selectedIssue}
allIssues={state.issues}
globalAutoReview={globalAutoReview}
@@ -21,9 +21,11 @@ import { ManagedSessionsPanel } from './ManagedSessionsPanel'
import { PrFilesPanel } from './PrFilesPanel'
import { ProfileGrid } from './ProfileGrid'
type TabKey = 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'
export type TabKey = 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'
interface BottomTabsProps {
export interface BottomTabsProps {
tab?: TabKey
onTabChange?: (tab: TabKey) => void
// Issue tab props (pass-through to IssueDetailPanel)
issue: Issue | null
allIssues: Issue[]
@@ -92,7 +94,9 @@ interface BottomTabsProps {
}
export function BottomTabs(props: BottomTabsProps) {
const [tab, setTab] = useState<TabKey>('issue')
const [internalTab, setInternalTab] = useState<TabKey>('issue')
const tab = props.tab ?? internalTab
const setTab = props.onTabChange ?? setInternalTab
return (
<div className="flex h-full w-full bg-[var(--vscode-editor-background)]">
@@ -1,8 +1,11 @@
import type { IssueChatComment } from '../lib/messages'
import type { Issue } from '../types'
import { ExternalLink, Loader2, MessageCircle, RotateCw, Send } from 'lucide-react'
import type { ParsedCommentQuote } from './commentQuote'
import { ExternalLink, Loader2, MessageCircle, Quote, RotateCw, Send, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { onMessage, postMessage } from '../lib/vscode'
import { parseCommentQuote } from './commentQuote'
export interface IssueChatPanelProps {
issue: Issue | null
@@ -51,18 +54,53 @@ function ChatMessageBubble({
comment,
isMine,
avatarSrc,
onQuote,
}: {
comment: IssueChatComment
isMine: boolean
avatarSrc?: string
onQuote: (comment: IssueChatComment) => void
}) {
const timeStr = formatCommentTime(comment.createdAt)
const { quote, replyText } = parseCommentQuote(comment.body)
const quoteBlock = quote && (
<div
className={`mb-1.5 rounded-r border-l-2 px-2 py-1 text-[11px] leading-relaxed select-text ${
isMine
? 'border-l-blue-300 bg-white/10 text-white/90'
: 'border-l-[var(--vscode-focusBorder,#0078d4)] bg-black/15 dark:bg-white/5 text-[var(--vscode-descriptionForeground,#9d9d9d)]'
}`}
>
<span className="font-medium text-[var(--vscode-foreground)]">
{quote.author ? `${quote.author}: ` : ''}
</span>
<span className="whitespace-pre-wrap break-words opacity-85">
{quote.text}
</span>
</div>
)
const bodyBlock = (
<div className="whitespace-pre-wrap break-words">
{replyText || (!quote ? comment.body : '')}
</div>
)
if (isMine) {
return (
<div className="flex items-start justify-end gap-2 pl-8">
<div className="group 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">
<button
type="button"
data-tooltip="引用"
aria-label="引用"
onClick={() => onQuote(comment)}
className="inline-flex items-center justify-center rounded p-0.5 text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-black/10 hover:text-[var(--vscode-foreground)] group-hover:opacity-100 dark:hover:bg-white/10"
>
<Quote className="size-3" />
</button>
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
<span className="text-xs font-medium text-[var(--vscode-foreground)]">
{comment.author}
@@ -71,7 +109,8 @@ function ChatMessageBubble({
</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}
{quoteBlock}
{bodyBlock}
</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">
@@ -88,7 +127,7 @@ function ChatMessageBubble({
}
return (
<div className="flex items-start justify-start gap-2 pr-8">
<div className="group 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
? (
@@ -104,9 +143,19 @@ function ChatMessageBubble({
{comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author}
</span>
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
<button
type="button"
data-tooltip="引用"
aria-label="引用"
onClick={() => onQuote(comment)}
className="inline-flex items-center justify-center rounded p-0.5 text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-black/10 hover:text-[var(--vscode-foreground)] group-hover:opacity-100 dark:hover:bg-white/10"
>
<Quote className="size-3" />
</button>
</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}
{quoteBlock}
{bodyBlock}
</div>
</div>
</div>
@@ -123,11 +172,14 @@ export function IssueChatPanel({
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [input, setInput] = useState('')
const [quoting, setQuoting] = useState<ParsedCommentQuote | null>(null)
const streamEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const issueNumber = issue?.number
useEffect(() => {
setQuoting(null)
if (issueNumber == null) {
setComments([])
setLoading(false)
@@ -168,17 +220,34 @@ export function IssueChatPanel({
})
}
const handleQuote = (comment: IssueChatComment) => {
const { quote, replyText } = parseCommentQuote(comment.body)
const quoteText = (replyText || quote?.text || comment.body).trim()
setQuoting({
author: comment.author,
text: quoteText,
})
textareaRef.current?.focus()
}
const handleSend = () => {
const text = input.trim()
if (!text || issueNumber == null || sending)
return
let bodyToSend = text
if (quoting) {
const authorPart = quoting.author ? `@${quoting.author} ` : ''
const formattedQuote = quoting.text.split('\n').join('\n> ')
bodyToSend = `> ${authorPart}${formattedQuote}\n\n${text}`
}
setSending(true)
postMessage({
type: 'issue/comment-post',
issueNumber,
body: text,
body: bodyToSend,
})
setInput('')
setQuoting(null)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -258,6 +327,7 @@ export function IssueChatPanel({
comment={comment}
isMine={isMine}
avatarSrc={avatarSrc}
onQuote={handleQuote}
/>
)
})}
@@ -265,30 +335,59 @@ export function IssueChatPanel({
</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 className="flex flex-col border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-sideBar-background,var(--vscode-editor-background))] p-2.5">
{quoting && (
<div className="mb-2 flex items-center justify-between gap-2 rounded border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-editor-lineHighlightBackground,rgba(255,255,255,0.04))] px-2.5 py-1 text-xs text-[var(--vscode-foreground)]">
<div className="flex min-w-0 items-center gap-1.5 border-l-2 border-l-[var(--vscode-focusBorder,#0078d4)] pl-2">
<Quote className="size-3 shrink-0 opacity-60" />
<span className="truncate">
<span className="font-medium text-[var(--vscode-foreground)]">
引用
{' '}
{quoting.author ? `@${quoting.author}:` : ':'}
</span>
<span className="opacity-75">
{quoting.text.replace(/\s+/g, ' ')}
</span>
</span>
</div>
<button
type="button"
data-tooltip="取消引用"
aria-label="取消引用"
onClick={() => setQuoting(null)}
className="shrink-0 rounded p-0.5 opacity-60 hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
>
<X className="size-3.5" />
</button>
</div>
)}
<div className="flex items-end gap-2">
<textarea
ref={textareaRef}
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>
</div>
)
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { parseCommentQuote } from './commentQuote'
describe('parseCommentQuote', () => {
it('parses single-line quote with @author', () => {
const input = '> @cruldra 原文内容\n\n回复内容'
const result = parseCommentQuote(input)
expect(result).toEqual({
quote: {
author: 'cruldra',
text: '原文内容',
},
replyText: '回复内容',
})
})
it('parses multiline quote with @author', () => {
const input = '> @chehongweo 第一行原文\n> 第二行原文\n\n收到,正在处理'
const result = parseCommentQuote(input)
expect(result).toEqual({
quote: {
author: 'chehongweo',
text: '第一行原文\n第二行原文',
},
replyText: '收到,正在处理',
})
})
it('parses quote without @author', () => {
const input = '> 纯引用文本\n\n回复'
const result = parseCommentQuote(input)
expect(result).toEqual({
quote: {
text: '纯引用文本',
},
replyText: '回复',
})
})
it('returns plain text when there is no quote prefix', () => {
const input = '这是一条普通的回复,没有引用。'
const result = parseCommentQuote(input)
expect(result).toEqual({
replyText: input,
})
})
it('handles quote without reply body', () => {
const input = '> @alice 仅引用的情况'
const result = parseCommentQuote(input)
expect(result).toEqual({
quote: {
author: 'alice',
text: '仅引用的情况',
},
replyText: '',
})
})
})
@@ -0,0 +1,56 @@
export interface ParsedCommentQuote {
author?: string
text: string
}
export interface ParsedCommentBody {
quote?: ParsedCommentQuote
replyText: string
}
export function parseCommentQuote(body: string): ParsedCommentBody {
const trimmed = body.trim()
if (!trimmed.startsWith('>')) {
return { replyText: body }
}
const lines = trimmed.split('\n')
const quoteLines: string[] = []
let i = 0
while (i < lines.length && lines[i].trimStart().startsWith('>')) {
const rawLine = lines[i].trimStart()
quoteLines.push(rawLine.replace(/^>\s?/, ''))
i++
}
while (i < lines.length && lines[i].trim() === '') {
i++
}
const replyText = lines.slice(i).join('\n')
const fullQuoteText = quoteLines.join('\n').trim()
let author: string | undefined
let text = fullQuoteText
if (fullQuoteText.startsWith('@')) {
const spaceIdx = fullQuoteText.search(/\s/)
if (spaceIdx !== -1) {
author = fullQuoteText.slice(1, spaceIdx).trim()
text = fullQuoteText.slice(spaceIdx).trim()
}
else {
author = fullQuoteText.slice(1).trim()
text = ''
}
}
return {
quote: {
...(author ? { author } : {}),
text,
},
replyText,
}
}
+12 -2
View File
@@ -311,6 +311,8 @@ export interface UseIssuesResult {
*/
pendingSelectId: string | null
clearPendingSelect: () => void
pendingSelectTab: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' | null
clearPendingSelectTab: () => void
/** 当前 Gitea login,来自最近一次 `issues/update`;undefined = 身份未知。 */
me: string | undefined
/** 最近一次 `requestHandoffUsers` 的结果;`null` = 仍在加载或尚未请求。 */
@@ -384,6 +386,7 @@ export function useIssues(): UseIssuesResult {
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [pendingSelectId, setPendingSelectId] = useState<string | null>(null)
const [pendingSelectTab, setPendingSelectTab] = useState<'issue' | 'chat' | 'profile' | 'sessions' | 'changes' | null>(null)
const [commitRunning, setCommitRunning] = useState<boolean>(false)
const [hasChanges, setHasChanges] = useState<boolean>(false)
const [release, setRelease] = useState<ReleaseStatus>()
@@ -410,6 +413,10 @@ export function useIssues(): UseIssuesResult {
setPendingSelectId(null)
}, [])
const clearPendingSelectTab = useCallback((): void => {
setPendingSelectTab(null)
}, [])
const toggleScope = useCallback((): void => {
setState({ status: 'loading' })
setScope(prev => prev === 'mine' ? 'all' : 'mine')
@@ -927,8 +934,11 @@ export function useIssues(): UseIssuesResult {
if (prev.status !== 'ready')
return prev
const issue = prev.issues.find(i => i.number === msg.issueNumber)
if (issue)
if (issue) {
setPendingSelectId(issue.id)
if (msg.tab)
setPendingSelectTab(msg.tab)
}
return prev
})
break
@@ -1087,5 +1097,5 @@ export function useIssues(): UseIssuesResult {
])
}, [state, me])
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest }
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, pendingSelectTab, clearPendingSelectTab, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest }
}
+1 -1
View File
@@ -103,7 +103,7 @@ export type ExtensionToWebview
| { type: 'session-handoff/done', sessionId: string }
| { type: 'handoff/done', issueNumber: number }
| { type: 'issue/append', issue: Issue, select?: boolean }
| { type: 'issue/select-by-number', issueNumber: number }
| { type: 'issue/select-by-number', issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' }
| {
type: 'settings/show'
host: string