diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index ccf7a90..a906191 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -696,7 +696,10 @@ export class KanbanWebviewPanel { * Persistence failures are logged and swallowed. */ // internal: handler 模块访问 - async resolveIssueIcon(issueNumber: number): Promise<{ themeColor: ThemeColor, iconUri: Uri }> { + async resolveIssueIcon( + issueNumber: number, + presetState?: Record | null, + ): Promise<{ themeColor: ThemeColor, iconUri: Uri }> { // Helper: produce both representations from a palette id. const pack = (id: string) => ({ themeColor: new ThemeColor(id), @@ -707,24 +710,34 @@ export class KanbanWebviewPanel { // pick so the icon is still stable across sessions. const fallbackId = PALETTE[((issueNumber % PALETTE.length) + PALETTE.length) % PALETTE.length] - const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceRoot) - return pack(fallbackId) - const remote = await detectRepo(workspaceRoot) - if (!remote) - return pack(fallbackId) - const token = await getToken(this.context, remote.host) - if (!token) - return pack(fallbackId) - let stored: string | undefined - try { - const state = await this.readIssueState(issueNumber) - if (typeof state.color === 'string' && state.color.length > 0) - stored = state.color + // 调用方已经读过 state(开会话热路径就一次)时直接复用,省一次远程往返; + // presetState 为 null 表示那次读取失败,按 tracker 不可达处理。 + if (presetState !== undefined) { + if (presetState === null) + return pack(fallbackId) + if (typeof presetState.color === 'string' && presetState.color.length > 0) + stored = presetState.color } - catch (err) { - console.warn('[superpowers] failed to read state JSON for color:', err) + else { + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceRoot) + return pack(fallbackId) + const remote = await detectRepo(workspaceRoot) + if (!remote) + return pack(fallbackId) + const token = await getToken(this.context, remote.host) + if (!token) + return pack(fallbackId) + + try { + const state = await this.readIssueState(issueNumber) + if (typeof state.color === 'string' && state.color.length > 0) + stored = state.color + } + catch (err) { + console.warn('[superpowers] failed to read state JSON for color:', err) + } } const { id, isNew } = resolveIssueColor(stored) @@ -871,6 +884,15 @@ export class KanbanWebviewPanel { * `{ locked: false }` so a transient Gitea hiccup can't lock the user out * of starting a session. */ + /** + * 主进程上一次算好的工单快照(issues/update 的 payload)。锁检查等只读 + * 判断直接用它,省掉一次全量 loadIssues 往返;面板还没加载过时为 undefined。 + */ + // internal: handler 模块访问 + snapshotIssues(): Issue[] | undefined { + return this.lastIssuesUpdate?.issues + } + // internal: handler 模块访问 async resolveLockedReason( issueNumber: number, diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index f7f306a..efacfdf 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -1,4 +1,5 @@ import type { IssueColumn } from '../../gitea/types' +import type { LockedReason } from '../issueLock' import type { KanbanWebviewPanel } from '../KanbanPanel' import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' @@ -35,6 +36,7 @@ import { logger } from '../../logging/logger' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor' +import { evaluateLockedReason } from '../issueLock' import { makeNonce } from '../KanbanPanel' import { cleanupFeatureBranch } from './branchCleanup' import * as sessions from './sessions' @@ -1572,12 +1574,10 @@ export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: { } /** - * Server-side lock check for prerequisite gating. Fetches a fresh issues - * snapshot (we don't trust webview state) and reports whether - * `issueNumber` is blocked by an unfinished prerequisite. Mirrors the - * webview-side `isIssueLocked` predicate: locked iff the issue has a - * prerequisite that exists in the current snapshot and is not yet in the - * `done` column. + * Server-side lock check for prerequisite gating(不信 webview 传来的状态)。 + * 优先用主进程自己算好的工单快照——开会话是热路径,全量 loadIssues 要拉工单 + * 列表、每张工单的评论、依赖和 PR,几秒起步;快照没有该工单(面板还没加载 + * 完)时才退回一次全量拉取。 * * Fail-open: any error fetching issues is logged and we return * `{ locked: false }` so a transient Gitea hiccup can't lock the user out @@ -1586,8 +1586,12 @@ export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: { export async function resolveLockedReason( panel: KanbanWebviewPanel, issueNumber: number, -): Promise<{ locked: boolean, prerequisiteNumber?: number, prerequisiteColumn?: string }> { +): Promise { try { + const snapshot = panel.snapshotIssues() + if (snapshot?.some(i => i.number === issueNumber)) + return evaluateLockedReason(snapshot, issueNumber) + const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return { locked: false } @@ -1604,21 +1608,7 @@ export async function resolveLockedReason( repo: remote.repo, workspaceRoot, }) - const issue = issues.find(i => i.number === issueNumber) - if (!issue) - return { locked: false } - if (issue.prerequisite === undefined) - return { locked: false } - const prereq = issues.find(i => i.number === issue.prerequisite) - if (!prereq) - return { locked: false } - if (prereq.column === 'done') - return { locked: false } - return { - locked: true, - prerequisiteNumber: prereq.number, - prerequisiteColumn: prereq.column, - } + return evaluateLockedReason(issues, issueNumber) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index f45b4f0..dc1cb49 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -73,6 +73,20 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: existing.show(false) return } + // state JSON 一次读、两处用(钩子的 branch + 终端图标颜色)。提前发起, + // 让这趟远程往返和后面的本地准备工作重叠;读失败为 null,两处各自退化。 + const statePromise: Promise | null> | undefined + = issueNumber !== undefined + ? panel.readIssueState(issueNumber).catch((err) => { + logger.add({ + level: 'warn', + source: 'panel', + message: `resume #${issueNumber} 读 state JSON 失败`, + details: err instanceof Error ? err.message : String(err), + }) + return null + }) + : undefined // 实施 / 头脑风暴各自独立:调用方传入对应字段,空 = DEFAULT。 const effectiveProfilePath = sessionKind === 'implement' @@ -127,28 +141,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: // (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。 if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) { const settingsForHook = getSettings(panel.context) - // 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里 - // BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。 - let branchForHook = '' - try { - const remote = await detectRepo(workspaceRoot) - if (remote) { - const token = await getToken(panel.context, remote.host) - if (token) { - const stateObj = await panel.readIssueState(issueNumber) - if (stateObj && typeof stateObj.branch === 'string') - branchForHook = stateObj.branch - } - } - } - catch (err) { - logger.add({ - level: 'warn', - source: 'panel', - message: `resume #${issueNumber} 读 state JSON 失败,钩子 branch 用空字符串`, - details: err instanceof Error ? err.message : String(err), - }) - } + // 从 state JSON 读 branch;读不到就用空字符串(脚本里 BRANCH env 仍能 + // 传,只是为空),不阻塞 resume 主流程。 + const stateObj = await statePromise + const branchForHook = typeof stateObj?.branch === 'string' ? stateObj.branch : '' await panel.dispatchWorktreeHook('impl-tab-pre-create', { workspaceRoot, worktreePath: effectiveCwd!, @@ -164,7 +160,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: // harmless in the editor tab, still useful if the same terminal ever // gets rendered in panel-view mode. const issueIcon = issueNumber !== undefined - ? await panel.resolveIssueIcon(issueNumber) + ? await panel.resolveIssueIcon(issueNumber, await statePromise) : undefined const terminal = window.createTerminal({ name: terminalName, @@ -198,6 +194,8 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: finally { if (lockKey !== undefined) panel.resumeInFlight.delete(lockKey) + // 无论开成、复用已有 tab 还是被前置锁拒绝,都要告诉 webview 收 spinner。 + panel.postMessage({ type: 'session/tab-settled', sessionId }) } } @@ -303,6 +301,7 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi } finally { panel.resumeInFlight.delete(lockKey) + panel.postMessage({ type: 'session/tab-settled', sessionId }) } } @@ -1379,6 +1378,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session } finally { panel.resumeInFlight.delete(lockKey) + panel.postMessage({ type: 'session/tab-settled', sessionId }) } } diff --git a/vscode/src/panel/issueLock.test.ts b/vscode/src/panel/issueLock.test.ts new file mode 100644 index 0000000..8e4683d --- /dev/null +++ b/vscode/src/panel/issueLock.test.ts @@ -0,0 +1,35 @@ +import type { Issue } from '../gitea/types' +import { describe, expect, it } from 'vitest' +import { evaluateLockedReason } from './issueLock' + +function issue(number: number, column: Issue['column'], prerequisite?: number): Issue { + return { number, title: `#${number}`, column, prerequisite } as Issue +} + +describe('evaluateLockedReason', () => { + it('没有前置 → 放行', () => { + expect(evaluateLockedReason([issue(1, 'todo')], 1)).toEqual({ locked: false }) + }) + + it('前置未完成 → 锁定并带上前置号和所在列', () => { + const issues = [issue(2, 'todo', 1), issue(1, 'in-progress')] + expect(evaluateLockedReason(issues, 2)).toEqual({ + locked: true, + prerequisiteNumber: 1, + prerequisiteColumn: 'in-progress', + }) + }) + + it('前置已完成 → 放行', () => { + const issues = [issue(2, 'todo', 1), issue(1, 'done')] + expect(evaluateLockedReason(issues, 2)).toEqual({ locked: false }) + }) + + it('快照里没有前置工单 → 放行(不误锁)', () => { + expect(evaluateLockedReason([issue(2, 'todo', 99)], 2)).toEqual({ locked: false }) + }) + + it('快照里没有这张工单 → 放行', () => { + expect(evaluateLockedReason([issue(1, 'todo')], 7)).toEqual({ locked: false }) + }) +}) diff --git a/vscode/src/panel/issueLock.ts b/vscode/src/panel/issueLock.ts new file mode 100644 index 0000000..4fcd409 --- /dev/null +++ b/vscode/src/panel/issueLock.ts @@ -0,0 +1,25 @@ +import type { Issue } from '../gitea/types' + +export interface LockedReason { + locked: boolean + prerequisiteNumber?: number + prerequisiteColumn?: string +} + +/** + * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行, + * 与 webview 的 `isIssueLocked` 保持同一语义。 + */ +export function evaluateLockedReason(issues: Issue[], issueNumber: number): LockedReason { + const issue = issues.find(i => i.number === issueNumber) + if (!issue || issue.prerequisite === undefined) + return { locked: false } + const prereq = issues.find(i => i.number === issue.prerequisite) + if (!prereq || prereq.column === 'done') + return { locked: false } + return { + locked: true, + prerequisiteNumber: prereq.number, + prerequisiteColumn: prereq.column, + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index b32e2f6..428abb5 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -53,6 +53,8 @@ export type ExtensionToWebview | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record } | { type: 'issues/error', message: string } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } + /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ + | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/done', issueNumber: number } diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 3a3da11..8c93299 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -40,6 +40,7 @@ export function App() { dismissToast, openUrl, resumeSession, + isSessionTabPending, resumeReviewSession, startTestSession, resumeTestSession, @@ -373,6 +374,7 @@ export function App() { globalAutoReview={globalAutoReview} onOpenInBrowser={openUrl} onResumeSession={resumeSession} + isSessionTabPending={isSessionTabPending} onResumeReviewSession={resumeReviewSession} onStartTestSession={startTestSession} onResumeTestSession={resumeTestSession} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index b4dcba5..60d1e6c 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -29,6 +29,7 @@ interface BottomTabsProps { globalAutoReview: boolean onOpenInBrowser: (url: string) => void onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void + isSessionTabPending: (sessionId: string) => boolean onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void onStartTestSession: (issueNumber: number) => void onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void @@ -127,6 +128,7 @@ export function BottomTabs(props: BottomTabsProps) { globalAutoReview={props.globalAutoReview} onOpenInBrowser={props.onOpenInBrowser} onResumeSession={props.onResumeSession} + isSessionTabPending={props.isSessionTabPending} onResumeReviewSession={props.onResumeReviewSession} onStartTestSession={props.onStartTestSession} onResumeTestSession={props.onResumeTestSession} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 49dba59..6878d52 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -39,6 +39,8 @@ interface IssueDetailPanelProps { globalAutoReview: boolean onOpenInBrowser: (url: string) => void onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void + /** 会话 id 行的 spinner:该会话的开 tab 请求还没被扩展处理完就为 true。 */ + isSessionTabPending: (sessionId: string) => boolean /** * Open a new terminal that runs `codex resume ` for the auto-review * conversation associated with this issue. @@ -153,6 +155,7 @@ export function IssueDetailPanel({ onUpdateTestProfilePath, onOpenLogs, me, + isSessionTabPending, onStartHandoff, onAcceptHandoff, isHandoffRunning, @@ -252,6 +255,7 @@ export function IssueDetailPanel({ key: 'sessionId', label: '头脑风暴会话id', type: 'action' as const, + pending: hasSession ? isSessionTabPending(issue!.sessionId!) : false, description: hasSession ? '双击在新终端运行 claude --resume 恢复对话;点 ▶ 续聊' : '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊', @@ -274,6 +278,7 @@ export function IssueDetailPanel({ key: 'implementSessionId', label: '实施会话id', type: 'action', + pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false, description: issue?.worktreeExists ? '双击在新终端运行 claude --resume 恢复实施对话(cwd 为 worktree)' : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', @@ -298,6 +303,7 @@ export function IssueDetailPanel({ key: 'reviewSessionId', label: '审查会话id', type: 'action', + pending: issue.reviewSessionId ? isSessionTabPending(issue.reviewSessionId) : false, description: '双击在新终端运行 codex resume 查看审查会话(worktree 在则用之,否则工作区根目录)', actionIcon: , onAction: (v) => { @@ -355,6 +361,7 @@ export function IssueDetailPanel({ key: 'testSessionId', label: '测试会话id', type: 'action' as const, + pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false, description: hasSession ? '双击在新终端运行 claude --resume 在工作区根(main)恢复测试对话' : '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)', @@ -535,7 +542,7 @@ export function IssueDetailPanel({ ], }, ], - [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles], + [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles, isSessionTabPending], ) // 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。 diff --git a/vscode/webview-ui/src/components/property-grid.tsx b/vscode/webview-ui/src/components/property-grid.tsx index ef158fd..af35aef 100644 --- a/vscode/webview-ui/src/components/property-grid.tsx +++ b/vscode/webview-ui/src/components/property-grid.tsx @@ -60,6 +60,11 @@ export interface PropertyDef { onAction?: (value: unknown) => void /** type='action' 专用:值前的图标 */ actionIcon?: ReactNode + /** + * 主动作是否还在进行中。给了就由外部决定 spinner 何时收(真实进度), + * 不给则沿用点击后固定 1.5s 的即时反馈。 + */ + pending?: boolean /** type='file-link' 专用:双击文件名时调用,参数为 value(workspace 相对路径) */ onOpen?: (path: string) => void /** type='file-link' 专用:点击右侧图标按钮时调用,用于重新扫描/加载 */ @@ -174,7 +179,8 @@ function PropertyRow({ registerCellRef: (fullKey: string, el: HTMLDivElement | null) => void }) { const [showPassword, setShowPassword] = useState(false) - const [actionPending, setActionPending] = useState(false) + const [localActionPending, setLocalActionPending] = useState(false) + const actionPending = propDef.pending ?? localActionPending const pendingTimerRef = useRef(null) const ro = propDef.readOnly @@ -183,14 +189,16 @@ function PropertyRow({ window.clearTimeout(pendingTimerRef.current) }, []) - /** 触发主动作并给出即时反馈:行内图标临时切换为 spinner,约 1.5s 后恢复 */ + /** 触发主动作:外部受控时 spinner 交给 propDef.pending,否则本地转约 1.5s */ function firePrimaryAction(fire: () => void): void { fire() - setActionPending(true) + if (propDef.pending !== undefined) + return + setLocalActionPending(true) if (pendingTimerRef.current != null) window.clearTimeout(pendingTimerRef.current) pendingTimerRef.current = window.setTimeout(() => { - setActionPending(false) + setLocalActionPending(false) pendingTimerRef.current = null }, 1500) } diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 876c4d0..44fdda8 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -184,6 +184,8 @@ export interface UseIssuesResult { dismissToast: (id: string) => void openUrl: (url: string) => void resumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void + /** 该会话 id 的开 tab 请求还没被扩展处理完 → 行内转 spinner。 */ + isSessionTabPending: (sessionId: string) => boolean resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void startTestSession: (issueNumber: number) => void resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void @@ -479,6 +481,50 @@ export function useIssues(): UseIssuesResult { postMessage({ type: 'toast/open-url', url }) }, []) + // 开 tab 是秒级操作(要读 state JSON、跑 pre-create 钩子),行内 spinner 要 + // 真实反映进度:发请求时标 pending,扩展回 session/tab-settled 时清。消息万一 + // 丢了就靠 PENDING_TIMEOUT_MS 兜底,spinner 不会永远转下去。 + const [pendingSessionTabs, setPendingSessionTabs] = useState>(() => new Set()) + const pendingTimersRef = useRef>(new Map()) + const PENDING_TIMEOUT_MS = 60_000 + + const clearSessionTabPending = useCallback((sessionId: string): void => { + const timer = pendingTimersRef.current.get(sessionId) + if (timer !== undefined) { + window.clearTimeout(timer) + pendingTimersRef.current.delete(sessionId) + } + setPendingSessionTabs((prev) => { + if (!prev.has(sessionId)) + return prev + const next = new Set(prev) + next.delete(sessionId) + return next + }) + }, []) + + const markSessionTabPending = useCallback((sessionId: string): void => { + const existing = pendingTimersRef.current.get(sessionId) + if (existing !== undefined) + window.clearTimeout(existing) + pendingTimersRef.current.set( + sessionId, + window.setTimeout(() => clearSessionTabPending(sessionId), PENDING_TIMEOUT_MS), + ) + setPendingSessionTabs(prev => new Set(prev).add(sessionId)) + }, [clearSessionTabPending]) + + const isSessionTabPending = useCallback( + (sessionId: string): boolean => pendingSessionTabs.has(sessionId), + [pendingSessionTabs], + ) + + useEffect(() => () => { + for (const timer of pendingTimersRef.current.values()) + window.clearTimeout(timer) + pendingTimersRef.current.clear() + }, []) + const resumeSession = useCallback(( sessionId: string, kind: 'implement' | 'brainstorm', @@ -486,24 +532,27 @@ export function useIssues(): UseIssuesResult { cwd?: string, issueNumber?: number, ): void => { + markSessionTabPending(sessionId) postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber }) - }, []) + }, [markSessionTabPending]) const focusSession = useCallback((issueNumber: number): void => { postMessage({ type: 'session/focus', issueNumber }) }, []) const resumeReviewSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => { + markSessionTabPending(sessionId) postMessage({ type: 'session/resume-review', sessionId, issueNumber, cwd }) - }, []) + }, [markSessionTabPending]) const startTestSession = useCallback((issueNumber: number): void => { postMessage({ type: 'session/start-test', issueNumber }) }, []) const resumeTestSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => { + markSessionTabPending(sessionId) postMessage({ type: 'session/resume-test', sessionId, issueNumber, cwd }) - }, []) + }, [markSessionTabPending]) const openFile = useCallback((path: string): void => { postMessage({ type: 'editor/open-file', path }) @@ -722,6 +771,9 @@ export function useIssues(): UseIssuesResult { useEffect(() => { const cleanup = onMessage((msg) => { switch (msg.type) { + case 'session/tab-settled': + clearSessionTabPending(msg.sessionId) + break case 'issues/loading': setRefreshing(true) setState(prev => prev.status === 'ready' ? prev : { status: 'loading' }) @@ -938,5 +990,5 @@ export function useIssues(): UseIssuesResult { return cleanup }, [clearPrDiffSummaryRunning, markHandoffRunning]) - return { state, settings, avatars, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearBrainstormSession, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } + 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, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearBrainstormSession, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 19ac578..36b46de 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -84,6 +84,8 @@ export type ExtensionToWebview | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record } | { type: 'issues/error', message: string } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } } + /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ + | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'session-handoff/users-result', users: string[] }