diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index f627e12..bf4c4d2 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -73,6 +73,7 @@ export class KanbanWebviewPanel { private me: string | undefined /** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */ private webviewReady = false + private pendingSelectOnReady?: { issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' } private loadInFlight: Promise | undefined private lastIssuesUpdate: Extract | undefined /** @@ -334,10 +335,10 @@ export class KanbanWebviewPanel { this.handleActiveTerminalChanged(activated) } - static createOrShow(context: ExtensionContext): void { + static createOrShow(context: ExtensionContext): KanbanWebviewPanel { if (KanbanWebviewPanel.current) { KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active) - return + return KanbanWebviewPanel.current } const panel = window.createWebviewPanel( KanbanWebviewPanel.viewType, @@ -352,6 +353,20 @@ export class KanbanWebviewPanel { }, ) KanbanWebviewPanel.current = new KanbanWebviewPanel(context, panel) + return KanbanWebviewPanel.current + } + + reveal(viewColumn: ViewColumn = ViewColumn.Active): void { + this.panel.reveal(viewColumn) + } + + selectIssueAndTab(issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'): void { + if (this.webviewReady) { + this.postMessage({ type: 'issue/select-by-number', issueNumber, tab }) + } + else { + this.pendingSelectOnReady = { issueNumber, tab } + } } /** Triggers a fresh load on the currently open panel, if any. */ @@ -399,6 +414,11 @@ export class KanbanWebviewPanel { if (first && this.lastIssuesUpdate) { this.postMessage(this.lastIssuesUpdate) this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges }) + if (this.pendingSelectOnReady) { + const { issueNumber, tab } = this.pendingSelectOnReady + this.pendingSelectOnReady = undefined + this.postMessage({ type: 'issue/select-by-number', issueNumber, tab }) + } return } void this.loadAndPush() @@ -1116,6 +1136,11 @@ export class KanbanWebviewPanel { // refresh after git events already fired) still picks up the latest // value. Cheap — the webview discards if equal. this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges }) + if (this.pendingSelectOnReady) { + const { issueNumber, tab } = this.pendingSelectOnReady + this.pendingSelectOnReady = undefined + this.postMessage({ type: 'issue/select-by-number', issueNumber, tab }) + } } } catch (err) { diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 661baf7..9833ee9 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -64,7 +64,7 @@ export type ExtensionToWebview | { type: 'session-handoff/pending', items: SessionHandoffPendingItem[] } | { type: 'session-handoff/done', sessionId: string } | { 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 diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 16bf51b..52fc88b 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -10,23 +10,25 @@ */ import type { ExtensionContext } from 'vscode' -import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { KanbanScope } from '../panel/kanbanScope' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' +import { execFile } from 'node:child_process' import { promises as fsp } from 'node:fs' -import { env, Uri, window, workspace } from 'vscode' +import { env, Uri, ViewColumn, window, workspace } from 'vscode' import { loginForToken } from '../auth/identity' import { getToken } from '../auth/secrets' import { renderReviewPrompt } from '../cc/prompts' import { isMailboxIssue } from '../cc/sessionHandoff' import { detectRepo } from '../git/remote' import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' +import { systemCommentText } from '../gitea/issueComment' 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 { KanbanWebviewPanel } from '../panel/KanbanPanel' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getSettings } from '../settings/store' @@ -1423,49 +1425,7 @@ 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 评论,直接排除。 - if (event.prNumber === undefined && hasStateColumn(body)) { - logger.add({ - level: 'info', - source: 'webhook', - message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`, - }) - await this.refreshIssueCard({ - issueNumber: event.issueNumber, - raw: event.raw, - reason: '列变化', - }) - return - } - - if (!//i.test(body)) { - logger.add({ - level: 'info', - source: 'webhook', - message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`, - }) - return - } - const text = body.replace(/\s*/i, '').trim() - if (!text) { - logger.add({ - level: 'warn', - source: 'webhook', - message: `审查评论正文为空 #${event.issueNumber}`, - }) - return - } - - // resolveRepoContext is keyed by pendingHooks/workspace, not by issue - // number — it just needs *some* number to log against. We pass the - // gitea-reported number here (which is the PR number when this comment - // is on a PR) and reverse-resolve to the real issue number below. const ctx = await this.resolveRepoContext(event.issueNumber) if (!ctx) { logger.add({ @@ -1489,7 +1449,7 @@ class WebhookCoordinator { repo: ctx.repo, index: event.prNumber, }) - const m = (pr.body ?? '').match(/\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i) + const m = (pr.body ?? '').match(/\\b(?:closes|fixes|resolves|close|fix|resolve)\\s+#(\\d+)/i) if (m) { realIssueNumber = Number.parseInt(m[1], 10) logger.add({ @@ -1497,9 +1457,6 @@ class WebhookCoordinator { source: 'webhook', message: `issue_comment 评论在 PR #${event.prNumber},反查到工单 #${realIssueNumber}`, }) - if (this.activePanel) { - void issueChat.handleIssueComments(this.activePanel, realIssueNumber) - } } else { logger.add({ @@ -1519,6 +1476,80 @@ class WebhookCoordinator { } } + // 若看板面板正打开且当前选中的正是该工单,自动刷新讨论列表 + if (this.activePanel) { + void issueChat.handleIssueComments(this.activePanel, realIssueNumber) + } + + // 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡 + // 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张 + // 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。 + if (event.prNumber === undefined && hasStateColumn(body)) { + logger.add({ + level: 'info', + source: 'webhook', + message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`, + }) + await this.refreshIssueCard({ + issueNumber: event.issueNumber, + raw: event.raw, + reason: '列变化', + }) + return + } + + // 判定发言人:如果发言人不是当前用户(不是 me),且不是插件状态机自动评论(systemCommentText(body) 为空,即真人评论): + const author = event.commentAuthor + const me = await loginForToken(ctx.host, ctx.token) + const isSystemComment = !!systemCommentText(body) + const isMine = !!(me && author === me) + + if (author && !isMine && !isSystemComment) { + // 触发 macOS 系统原生右上角横幅通知 + execFile('osascript', [ + '-e', + `display notification ${JSON.stringify(body)} with title ${JSON.stringify(`工单 #${realIssueNumber} 新讨论`)} subtitle ${JSON.stringify(author)}`, + ], (err) => { + if (err) { + logger.add({ + level: 'warn', + source: 'webhook', + message: `macOS 原生通知发送失败 #${realIssueNumber}`, + details: err.message, + }) + } + }) + + // 同时在 VS Code 内部弹出信息框 + void window.showInformationMessage(`[#${realIssueNumber}] ${author}: ${body}`, '查看').then((action) => { + if (action === '查看') { + const panel = this.activePanel ?? (this.ctx ? KanbanWebviewPanel.createOrShow(this.ctx) : undefined) + if (panel) { + panel.reveal(ViewColumn.Active) + panel.selectIssueAndTab(realIssueNumber, 'chat') + } + } + }) + } + + if (!//i.test(body)) { + logger.add({ + level: 'info', + source: 'webhook', + message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`, + }) + return + } + const text = body.replace(/\\s*/i, '').trim() + if (!text) { + logger.add({ + level: 'warn', + source: 'webhook', + message: `审查评论正文为空 #${event.issueNumber}`, + }) + return + } + if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') { // 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到 // 该终端,提前拦截省掉后面的评论计数 API 往返。 @@ -1539,7 +1570,7 @@ class WebhookCoordinator { repo: ctx.repo, index: commentsIndex, }) - reviewCount = comments.filter(c => //i.test(c.body ?? '')).length + reviewCount = comments.filter(c => //i.test(c.body ?? '')).length } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/vscode/src/webhook/eventParser.test.ts b/vscode/src/webhook/eventParser.test.ts new file mode 100644 index 0000000..1db1035 --- /dev/null +++ b/vscode/src/webhook/eventParser.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { parseEvent } from './eventParser' + +describe('parseEvent', () => { + it('parses issue_comment event and extracts commentAuthor from comment.user.login', () => { + const raw = { + action: 'created', + issue: { number: 42 }, + comment: { + body: 'hello world', + html_url: 'https://gitea.example.com/repo/issues/42#issuecomment-1', + user: { login: 'alice' }, + }, + } + const event = parseEvent('issue_comment', undefined, raw) + expect(event).toEqual({ + kind: 'issue_comment', + action: 'created', + issueNumber: 42, + prNumber: undefined, + commentAuthor: 'alice', + commentBody: 'hello world', + commentHtmlUrl: 'https://gitea.example.com/repo/issues/42#issuecomment-1', + raw, + }) + }) + + it('falls back to sender.login when comment.user is missing', () => { + const raw = { + action: 'created', + issue: { number: 42 }, + comment: { + body: 'from sender', + html_url: 'https://gitea.example.com/repo/issues/42#issuecomment-2', + }, + sender: { login: 'bob' }, + } + const event = parseEvent('issue_comment', undefined, raw) + expect(event).toMatchObject({ + kind: 'issue_comment', + action: 'created', + commentAuthor: 'bob', + commentBody: 'from sender', + }) + }) + + it('parses pull_request comment correctly', () => { + const raw = { + action: 'created', + issue: { number: 10, pull_request: {} }, + comment: { + body: 'review comment', + html_url: 'https://gitea.example.com/repo/pulls/10#issuecomment-3', + user: { login: 'reviewer' }, + }, + } + const event = parseEvent('issue_comment', undefined, raw) + expect(event).toMatchObject({ + kind: 'issue_comment', + action: 'created', + issueNumber: 10, + prNumber: 10, + commentAuthor: 'reviewer', + commentBody: 'review comment', + }) + }) +}) diff --git a/vscode/src/webhook/eventParser.ts b/vscode/src/webhook/eventParser.ts new file mode 100644 index 0000000..ed3ba24 --- /dev/null +++ b/vscode/src/webhook/eventParser.ts @@ -0,0 +1,177 @@ +export interface PrWebhookEvent { + kind: 'pr' + issueNumber: number | undefined + action: string + pr: string + branch: string + htmlUrl: string + title: string + body: string + raw: unknown +} + +export interface IssueWebhookEvent { + kind: 'issue' + action: string + issueNumber: number + title: string + body: string + htmlUrl: string + raw: unknown +} + +export interface IssueCommentWebhookEvent { + kind: 'issue_comment' + action: string + issueNumber: number + prNumber: number | undefined + commentBody: string + commentAuthor: string + commentHtmlUrl: string + raw: unknown +} + +export interface PushWebhookEvent { + kind: 'push' + branch: string + raw: unknown +} + +export type WebhookEvent = PrWebhookEvent | IssueWebhookEvent | IssueCommentWebhookEvent | PushWebhookEvent + +export function parseEvent( + eventHeader: string, + issueNumber: number | undefined, + raw: unknown, +): WebhookEvent | null { + if (!raw || typeof raw !== 'object') + return null + const obj = raw as { + action?: unknown + pull_request?: unknown + issue?: unknown + comment?: unknown + ref?: unknown + sender?: unknown + } + + if (eventHeader === 'push') { + // gitea push 事件无 `action`,顶层带 `ref: "refs/heads/"`。 + if (typeof obj.ref !== 'string') + return null + const branch = obj.ref.replace(/^refs\/heads\//, '') + if (!branch) + return null + return { kind: 'push', branch, raw } + } + + if (typeof obj.action !== 'string') + return null + + if (eventHeader === 'issue_comment') { + const issue = obj.issue + const comment = obj.comment + if (!issue || typeof issue !== 'object') + return null + if (!comment || typeof comment !== 'object') + return null + const issueObj = issue as { + number?: unknown + pull_request?: unknown + } + const commentObj = comment as { + body?: unknown + html_url?: unknown + user?: unknown + } + const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number) + if (!Number.isFinite(num)) + return null + // gitea sets issue.pull_request (non-null object) when the issue is a PR. + const prNumber = issueObj.pull_request && typeof issueObj.pull_request === 'object' + ? num + : undefined + const commentBody = typeof commentObj.body === 'string' ? commentObj.body : '' + const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : '' + const commentUser = commentObj.user as { login?: unknown, username?: unknown } | undefined + const sender = obj.sender as { login?: unknown, username?: unknown } | undefined + const commentAuthor = (typeof commentUser?.login === 'string' && commentUser.login) + || (typeof commentUser?.username === 'string' && commentUser.username) + || (typeof sender?.login === 'string' && sender.login) + || (typeof sender?.username === 'string' && sender.username) + || '' + return { + kind: 'issue_comment', + action: obj.action, + issueNumber: num, + prNumber, + commentAuthor, + commentBody, + commentHtmlUrl, + raw, + } + } + + if (eventHeader === 'issues') { + const issue = obj.issue + if (!issue || typeof issue !== 'object') + return null + const issueObj = issue as { + number?: unknown + html_url?: unknown + title?: unknown + body?: unknown + } + const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number) + if (!Number.isFinite(num)) + return null + const htmlUrl = typeof issueObj.html_url === 'string' ? issueObj.html_url : '' + const title = typeof issueObj.title === 'string' ? issueObj.title : '' + const body = typeof issueObj.body === 'string' ? issueObj.body : '' + return { + kind: 'issue', + action: obj.action, + issueNumber: num, + title, + body, + htmlUrl, + raw, + } + } + + // Default path: pull_request payload (preserves legacy behaviour for any + // event header value, since gitea historically may not set the header). + const pr = obj.pull_request + if (!pr || typeof pr !== 'object') + return null + const prObj = pr as { + number?: unknown + html_url?: unknown + head?: unknown + title?: unknown + body?: unknown + } + const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number) + if (!Number.isFinite(num)) + return null + const htmlUrl = typeof prObj.html_url === 'string' ? prObj.html_url : '' + const head = prObj.head as { ref?: unknown } | undefined + const branch = head && typeof head.ref === 'string' ? head.ref : '' + const title = typeof prObj.title === 'string' ? prObj.title : '' + const body = typeof prObj.body === 'string' ? prObj.body : '' + + if (!branch || !htmlUrl) + return null + + return { + kind: 'pr', + issueNumber, + action: obj.action, + pr: String(num), + branch, + htmlUrl, + title, + body, + raw, + } +} diff --git a/vscode/src/webhook/server.ts b/vscode/src/webhook/server.ts index fecd86e..2ad61d9 100644 --- a/vscode/src/webhook/server.ts +++ b/vscode/src/webhook/server.ts @@ -24,91 +24,23 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http' import type { Event } from 'vscode' +import type { WebhookEvent } from './eventParser' import { Buffer } from 'node:buffer' import { createHmac, timingSafeEqual } from 'node:crypto' import { createServer } from 'node:http' import { EventEmitter } from 'vscode' + import { logger } from '../logging/logger' +import { parseEvent } from './eventParser' -export type WebhookEvent = PrWebhookEvent | IssueWebhookEvent | IssueCommentWebhookEvent | PushWebhookEvent - -export interface PrWebhookEvent { - /** Discriminator: this event came from a `pull_request` payload. */ - kind: 'pr' - /** - * Issue number from the legacy `/webhook/:n` path. `undefined` when the - * canonical `/webhook` route was used — the coordinator then resolves the - * issue via PR body / branch heuristics. - */ - issueNumber: number | undefined - /** - * The `action` field from the `pull_request` payload (e.g. `opened`, - * `reopened`, `synchronize`, `closed`, `edited`). The server fires this - * event on every `pull_request` action whose payload parses correctly; - * downstream code dispatches on `action` to decide what to do. - */ - action: string - /** PR number as string. */ - pr: string - /** Head branch ref. */ - branch: string - /** Browser URL to the PR. */ - htmlUrl: string - /** PR title (best-effort; empty string if missing). */ - title: string - /** PR body / description — used by the coordinator for `Closes #N` parsing. */ - body: string - /** Raw JSON payload, in case downstream needs more fields. */ - raw: unknown -} - -export interface IssueWebhookEvent { - /** Discriminator: this event came from an `issues` payload. */ - kind: 'issue' - /** - * The `action` field from the `issues` payload (`opened` / `closed` / - * `edited` / `reopened` / `deleted`...). The server fires for every - * action; the coordinator decides which ones it actually handles. - */ - action: string - /** Issue number. */ - issueNumber: number - /** Issue title (best-effort). */ - title: string - /** Issue body — used to extract the `` marker. */ - body: string - /** Browser URL to the issue. */ - htmlUrl: string - /** Raw JSON payload. */ - raw: unknown -} - -export interface IssueCommentWebhookEvent { - /** Discriminator: this event came from an `issue_comment` payload. */ - kind: 'issue_comment' - /** Action: 'created' / 'edited' / 'deleted'. */ - action: string - /** - * Issue number — gitea uses the same number for issues and PRs. If the - * issue is actually a PR, {@link prNumber} is also set. - */ - issueNumber: number - /** PR number when the comment is on a PR; otherwise undefined. */ - prNumber: number | undefined - /** Comment body — caller scans for the `` marker. */ - commentBody: string - /** Browser URL to the comment. */ - commentHtmlUrl: string - /** Raw JSON payload. */ - raw: unknown -} - -export interface PushWebhookEvent { - kind: 'push' - /** 被推送的分支名(从 ref `refs/heads/` 提取)。 */ - branch: string - raw: unknown -} +export type { + IssueCommentWebhookEvent, + IssueWebhookEvent, + PrWebhookEvent, + PushWebhookEvent, + WebhookEvent, +} from './eventParser' +export { parseEvent } from './eventParser' export class WebhookServer { private server?: Server @@ -345,137 +277,3 @@ export class WebhookServer { } } } - -/** - * Validate and normalise a gitea pull_request webhook payload. Returns null - * for missing fields or non-actionable actions; the HTTP handler still - * responds 200 in both cases. `issueNumber` is forwarded as-is — it's - * `undefined` when the request came in on the canonical `/webhook` route - * (the coordinator resolves it from `body` / `branch` in that case). - */ -function parseEvent( - eventHeader: string, - issueNumber: number | undefined, - raw: unknown, -): WebhookEvent | null { - if (!raw || typeof raw !== 'object') - return null - const obj = raw as { - action?: unknown - pull_request?: unknown - issue?: unknown - comment?: unknown - ref?: unknown - } - - if (eventHeader === 'push') { - // gitea push 事件无 `action`,顶层带 `ref: "refs/heads/"`。 - if (typeof obj.ref !== 'string') - return null - const branch = obj.ref.replace(/^refs\/heads\//, '') - if (!branch) - return null - return { kind: 'push', branch, raw } - } - - if (typeof obj.action !== 'string') - return null - - if (eventHeader === 'issue_comment') { - const issue = obj.issue - const comment = obj.comment - if (!issue || typeof issue !== 'object') - return null - if (!comment || typeof comment !== 'object') - return null - const issueObj = issue as { - number?: unknown - pull_request?: unknown - } - const commentObj = comment as { - body?: unknown - html_url?: unknown - } - const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number) - if (!Number.isFinite(num)) - return null - // gitea sets issue.pull_request (non-null object) when the issue is a PR. - const prNumber = issueObj.pull_request && typeof issueObj.pull_request === 'object' - ? num - : undefined - const commentBody = typeof commentObj.body === 'string' ? commentObj.body : '' - const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : '' - return { - kind: 'issue_comment', - action: obj.action, - issueNumber: num, - prNumber, - commentBody, - commentHtmlUrl, - raw, - } - } - - if (eventHeader === 'issues') { - const issue = obj.issue - if (!issue || typeof issue !== 'object') - return null - const issueObj = issue as { - number?: unknown - html_url?: unknown - title?: unknown - body?: unknown - } - const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number) - if (!Number.isFinite(num)) - return null - const htmlUrl = typeof issueObj.html_url === 'string' ? issueObj.html_url : '' - const title = typeof issueObj.title === 'string' ? issueObj.title : '' - const body = typeof issueObj.body === 'string' ? issueObj.body : '' - return { - kind: 'issue', - action: obj.action, - issueNumber: num, - title, - body, - htmlUrl, - raw, - } - } - - // Default path: pull_request payload (preserves legacy behaviour for any - // event header value, since gitea historically may not set the header). - const pr = obj.pull_request - if (!pr || typeof pr !== 'object') - return null - const prObj = pr as { - number?: unknown - html_url?: unknown - head?: unknown - title?: unknown - body?: unknown - } - const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number) - if (!Number.isFinite(num)) - return null - const htmlUrl = typeof prObj.html_url === 'string' ? prObj.html_url : '' - const head = prObj.head as { ref?: unknown } | undefined - const branch = head && typeof head.ref === 'string' ? head.ref : '' - const title = typeof prObj.title === 'string' ? prObj.title : '' - const body = typeof prObj.body === 'string' ? prObj.body : '' - - if (!branch || !htmlUrl) - return null - - return { - kind: 'pr', - issueNumber, - action: obj.action, - pr: String(num), - branch, - htmlUrl, - title, - body, - raw, - } -} diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 8f19ec3..133fbf6 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -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(null) const [handoffSession, setHandoffSession] = useState<{ id: string, name: string } | null>(null) const [selectedId, setSelectedId] = useState(null) + const [bottomTab, setBottomTab] = useState('issue') // 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦 // effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。 const lastProgrammaticSelectRef = useRef(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() {
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('issue') + const [internalTab, setInternalTab] = useState('issue') + const tab = props.tab ?? internalTab + const setTab = props.onTabChange ?? setInternalTab return (
diff --git a/vscode/webview-ui/src/components/IssueChatPanel.tsx b/vscode/webview-ui/src/components/IssueChatPanel.tsx index b511f8a..d37a388 100644 --- a/vscode/webview-ui/src/components/IssueChatPanel.tsx +++ b/vscode/webview-ui/src/components/IssueChatPanel.tsx @@ -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 && ( +
+ + {quote.author ? `${quote.author}: ` : ''} + + + {quote.text} + +
+ ) + + const bodyBlock = ( +
+ {replyText || (!quote ? comment.body : '')} +
+ ) if (isMine) { return ( -
+
+ {timeStr} {comment.author} @@ -71,7 +109,8 @@ function ChatMessageBubble({
- {comment.body} + {quoteBlock} + {bodyBlock}
@@ -88,7 +127,7 @@ function ChatMessageBubble({ } return ( -
+
{avatarSrc ? ( @@ -104,9 +143,19 @@ function ChatMessageBubble({ {comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author} {timeStr} +
- {comment.body} + {quoteBlock} + {bodyBlock}
@@ -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(null) const streamEndRef = useRef(null) + const textareaRef = useRef(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) => { @@ -258,6 +327,7 @@ export function IssueChatPanel({ comment={comment} isMine={isMine} avatarSrc={avatarSrc} + onQuote={handleQuote} /> ) })} @@ -265,30 +335,59 @@ export function IssueChatPanel({
{/* 底部输入栏 */} -
-