Compare commits

...
7 Commits
Author SHA1 Message Date
cruldra 1ed4da5e47 🔧 chore(vscode): 版本号升到 0.2.135
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:52:26 +08:00
cruldra 410a04da77 ✨ feat(vscode): 实施和审查会话行加启动箭头
不用再把卡片拖到「实施中」「审查」列:

- 实施会话id 行在有计划文件且没实施会话时显示 ▶,等同拖进实施中列
- 审查会话id 行在有 PR 时显示 ▶,按 PR 事件那套流程起 codex 审查

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:52:14 +08:00
cruldra 620b7c73c8 🔧 chore(vscode): 版本号升到 0.2.134
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:45:52 +08:00
cruldra 743e0e7325 ✨ feat(vscode): 会话列表删除前弹确认框
删除会关掉终端 tab 并抹掉列表记录,之前点一下就执行。现在先弹 modal
确认,带上会话名字。

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:45:39 +08:00
cruldra 3d9f0e02c6 🔧 chore(vscode): 版本号升到 0.2.133
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:15:01 +08:00
cruldra 70b826123c ⚡ perf(vscode): 开会话去掉多余远程往返,spinner 跟随真实进度
开会话原先要先全量拉工单(含每张工单的评论、依赖、PR)做前置锁检查,
再各读一次 state JSON 给 pre-create 钩子和终端图标,几秒后才 createTerminal。

- 锁检查改用主进程已算好的工单快照,快照里没有该工单才回落全量拉取
- state JSON 一次读两处用,且提前发起与本地准备重叠
- 会话行 spinner 由扩展的 session/tab-settled 消息驱动,不再是固定 1.5s 假动画,
  消息丢失时 60s 超时兜底

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:14:48 +08:00
cruldra b182e0ac5b ✨ feat(vscode): URI 开的会话不再登记进会话管理列表
delegate-fix 等外部方通过 create-session URI 开的一次性会话,
不写入 .spx/session-names.json,也跳过 sessionId 轮询捕获;
面板内手动创建的会话仍照常登记。

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 10:07:30 +08:00
16 changed files with 338 additions and 83 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra", "publisher": "clurdra",
"name": "superpowers-vscode-clurdra", "name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra", "displayName": "Superpowers-clurdra",
"version": "0.2.131", "version": "0.2.135",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer", "description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra", "author": "clurdra",
+43 -17
View File
@@ -439,6 +439,10 @@ export class KanbanWebviewPanel {
void sessions.handleResumeReviewSession(this, msg.sessionId, msg.issueNumber, msg.cwd) void sessions.handleResumeReviewSession(this, msg.sessionId, msg.issueNumber, msg.cwd)
return return
} }
if (msg.type === 'session/start-review') {
void sessions.handleStartReviewSession(this, msg.issueNumber)
return
}
if (msg.type === 'session/start-test') { if (msg.type === 'session/start-test') {
void sessions.handleStartTestSession(this, msg.issueNumber) void sessions.handleStartTestSession(this, msg.issueNumber)
return return
@@ -696,7 +700,10 @@ export class KanbanWebviewPanel {
* Persistence failures are logged and swallowed. * Persistence failures are logged and swallowed.
*/ */
// internal: handler 模块访问 // internal: handler 模块访问
async resolveIssueIcon(issueNumber: number): Promise<{ themeColor: ThemeColor, iconUri: Uri }> { async resolveIssueIcon(
issueNumber: number,
presetState?: Record<string, unknown> | null,
): Promise<{ themeColor: ThemeColor, iconUri: Uri }> {
// Helper: produce both representations from a palette id. // Helper: produce both representations from a palette id.
const pack = (id: string) => ({ const pack = (id: string) => ({
themeColor: new ThemeColor(id), themeColor: new ThemeColor(id),
@@ -707,24 +714,34 @@ export class KanbanWebviewPanel {
// pick so the icon is still stable across sessions. // pick so the icon is still stable across sessions.
const fallbackId = PALETTE[((issueNumber % PALETTE.length) + PALETTE.length) % PALETTE.length] 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 let stored: string | undefined
try { // 调用方已经读过 state(开会话热路径就一次)时直接复用,省一次远程往返;
const state = await this.readIssueState(issueNumber) // presetState 为 null 表示那次读取失败,按 tracker 不可达处理。
if (typeof state.color === 'string' && state.color.length > 0) if (presetState !== undefined) {
stored = state.color if (presetState === null)
return pack(fallbackId)
if (typeof presetState.color === 'string' && presetState.color.length > 0)
stored = presetState.color
} }
catch (err) { else {
console.warn('[superpowers] failed to read state JSON for color:', err) 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) const { id, isNew } = resolveIssueColor(stored)
@@ -871,6 +888,15 @@ export class KanbanWebviewPanel {
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out * `{ locked: false }` so a transient Gitea hiccup can't lock the user out
* of starting a session. * of starting a session.
*/ */
/**
* 主进程上一次算好的工单快照(issues/update 的 payload)。锁检查等只读
* 判断直接用它,省掉一次全量 loadIssues 往返;面板还没加载过时为 undefined。
*/
// internal: handler 模块访问
snapshotIssues(): Issue[] | undefined {
return this.lastIssuesUpdate?.issues
}
// internal: handler 模块访问 // internal: handler 模块访问
async resolveLockedReason( async resolveLockedReason(
issueNumber: number, issueNumber: number,
+12 -22
View File
@@ -1,4 +1,5 @@
import type { IssueColumn } from '../../gitea/types' import type { IssueColumn } from '../../gitea/types'
import type { LockedReason } from '../issueLock'
import type { KanbanWebviewPanel } from '../KanbanPanel' import type { KanbanWebviewPanel } from '../KanbanPanel'
import { Buffer } from 'node:buffer' import { Buffer } from 'node:buffer'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
@@ -35,6 +36,7 @@ import { logger } from '../../logging/logger'
import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store' import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator' import { webhookCoordinator } from '../../webhook/coordinator'
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor' import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
import { evaluateLockedReason } from '../issueLock'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup' import { cleanupFeatureBranch } from './branchCleanup'
import * as sessions from './sessions' 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 * Server-side lock check for prerequisite gating(不信 webview 传来的状态)。
* snapshot (we don't trust webview state) and reports whether * 优先用主进程自己算好的工单快照——开会话是热路径,全量 loadIssues 要拉工单
* `issueNumber` is blocked by an unfinished prerequisite. Mirrors the * 列表、每张工单的评论、依赖和 PR,几秒起步;快照没有该工单(面板还没加载
* 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.
* *
* Fail-open: any error fetching issues is logged and we return * Fail-open: any error fetching issues is logged and we return
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out * `{ 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( export async function resolveLockedReason(
panel: KanbanWebviewPanel, panel: KanbanWebviewPanel,
issueNumber: number, issueNumber: number,
): Promise<{ locked: boolean, prerequisiteNumber?: number, prerequisiteColumn?: string }> { ): Promise<LockedReason> {
try { try {
const snapshot = panel.snapshotIssues()
if (snapshot?.some(i => i.number === issueNumber))
return evaluateLockedReason(snapshot, issueNumber)
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) if (!workspaceRoot)
return { locked: false } return { locked: false }
@@ -1604,21 +1608,7 @@ export async function resolveLockedReason(
repo: remote.repo, repo: remote.repo,
workspaceRoot, workspaceRoot,
}) })
const issue = issues.find(i => i.number === issueNumber) return evaluateLockedReason(issues, 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,
}
} }
catch (err) { catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
+20 -6
View File
@@ -65,14 +65,17 @@ export interface CreateCcSessionTabOptions {
cwd?: string cwd?: string
/** 有 panel 时登记存活终端并推全量列表;panel 外触发(如 URI handler)传 null。 */ /** 有 panel 时登记存活终端并推全量列表;panel 外触发(如 URI handler)传 null。 */
panel: KanbanWebviewPanel | null panel: KanbanWebviewPanel | null
/** 是否把会话登记进 managed store(会话管理列表);委派类一次性会话传 false。 */
register: boolean
} }
/** /**
* 创建 cc 会话终端 tab 并登记 managed store(核心逻辑,不要求 panel): * 创建 cc 会话终端 tab(核心逻辑,不要求 panel):
* - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions * - 启动命令统一由 buildCcCommand 构建(claude --dangerously-skip-permissions
* --settings '<profilePath>'),prompt 非空时作为位置参数传入。 * --settings '<profilePath>'),prompt 非空时作为位置参数传入。
* - 用 pollForNewSession 捕获新 sessionId(共享 projects 目录用轮询更可靠), * - register 为 true 时用 pollForNewSession 捕获新 sessionId(共享 projects 目录用轮询更可靠),
* 落进 workspaceRoot 的 .spx/session-names.json;有 panel 时再登记终端并推全量列表。 * 落进 workspaceRoot 的 .spx/session-names.json;有 panel 时再登记终端并推全量列表。
* - register 为 false 时只开终端,不落 store、不进会话管理列表。
* *
* 校验失败时 showErrorMessage 并返回 undefined;成功返回终端名。 * 校验失败时 showErrorMessage 并返回 undefined;成功返回终端名。
*/ */
@@ -120,7 +123,10 @@ export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promi
} }
// managed 会话的 projects 目录是繁忙共享目录,fs.watch 会漏 rename;改用轮询 diff。 // managed 会话的 projects 目录是繁忙共享目录,fs.watch 会漏 rename;改用轮询 diff。
const watchPromise = pollForNewSession({ projectsDir: projDir, timeoutMs: 120_000 }) // 不登记的会话无需捕获 sessionId,省掉这趟轮询。
const watchPromise = opts.register
? pollForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
: null
// 终端名:用户填了会话名字就用它,否则用默认 cc-会话。 // 终端名:用户填了会话名字就用它,否则用默认 cc-会话。
// (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。) // (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。)
@@ -140,7 +146,7 @@ export async function createCcSessionTab(opts: CreateCcSessionTabOptions): Promi
terminal.sendText(cmd) terminal.sendText(cmd)
// Fire-and-forget:会话 jsonl 出现后写进 store;有 panel 再登记终端并推全量列表。 // Fire-and-forget:会话 jsonl 出现后写进 store;有 panel 再登记终端并推全量列表。
watchPromise.then(async (sid) => { watchPromise?.then(async (sid) => {
if (!sid) { if (!sid) {
logger.add({ logger.add({
level: 'warn', level: 'warn',
@@ -191,7 +197,7 @@ export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, pro
return return
} }
const created = await createCcSessionTab({ workspaceRoot, profilePath, name, prompt, panel }) const created = await createCcSessionTab({ workspaceRoot, profilePath, name, prompt, panel, register: true })
if (!created) if (!created)
return return
@@ -313,9 +319,17 @@ export async function handleManagedSessionsDelete(panel: KanbanWebviewPanel, ses
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) if (!workspaceRoot)
return return
const data = await readManagedSessions(workspaceRoot)
const target = data.sessions.find(s => s.id === sessionId)
const choice = await window.showWarningMessage(
`确定从会话列表删除「${target?.name ?? sessionId}」?会关掉它的终端 tab,列表记录不可恢复(cc 会话本身的历史仍在 ~/.claude 里)。`,
{ modal: true },
'删除',
)
if (choice !== '删除')
return
// 删除前先关掉该会话的终端 tab。 // 删除前先关掉该会话的终端 tab。
panel.managedTerminals.get(sessionId)?.dispose() panel.managedTerminals.get(sessionId)?.dispose()
const data = await readManagedSessions(workspaceRoot)
const next = { sessions: data.sessions.filter(s => s.id !== sessionId) } const next = { sessions: data.sessions.filter(s => s.id !== sessionId) }
await writeManagedSessions(workspaceRoot, next) await writeManagedSessions(workspaceRoot, next)
panel.managedTerminals.delete(sessionId) panel.managedTerminals.delete(sessionId)
+75 -24
View File
@@ -13,7 +13,7 @@ import { buildCcCommand } from '../../cc/ccCommand'
import { buildCodexCommandString } from '../../cc/codexCommand' import { buildCodexCommandString } from '../../cc/codexCommand'
import { watchForNewCodexSession } from '../../cc/codexSessionWatcher' import { watchForNewCodexSession } from '../../cc/codexSessionWatcher'
import { resolveProfilePath } from '../../cc/profiles' import { resolveProfilePath } from '../../cc/profiles'
import { getBrainstormContinuePrompt, getImplementPlanPrompt } from '../../cc/prompts' import { getBrainstormContinuePrompt, getImplementPlanPrompt, getReviewPrompt } from '../../cc/prompts'
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher' import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
import { detectRepo } from '../../git/remote' import { detectRepo } from '../../git/remote'
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree' import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
@@ -73,6 +73,20 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
existing.show(false) existing.show(false)
return return
} }
// state JSON 一次读、两处用(钩子的 branch + 终端图标颜色)。提前发起,
// 让这趟远程往返和后面的本地准备工作重叠;读失败为 null,两处各自退化。
const statePromise: Promise<Record<string, unknown> | 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。 // 实施 / 头脑风暴各自独立:调用方传入对应字段,空 = DEFAULT。
const effectiveProfilePath const effectiveProfilePath
= sessionKind === 'implement' = sessionKind === 'implement'
@@ -127,28 +141,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
// (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。 // (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。
if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) { if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) {
const settingsForHook = getSettings(panel.context) const settingsForHook = getSettings(panel.context)
// 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里 // 从 state JSON 读 branch;读不到就用空字符串(脚本里 BRANCH env 仍能
// BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。 // 传,只是为空),不阻塞 resume 主流程。
let branchForHook = '' const stateObj = await statePromise
try { const branchForHook = typeof stateObj?.branch === 'string' ? stateObj.branch : ''
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),
})
}
await panel.dispatchWorktreeHook('impl-tab-pre-create', { await panel.dispatchWorktreeHook('impl-tab-pre-create', {
workspaceRoot, workspaceRoot,
worktreePath: effectiveCwd!, 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 // harmless in the editor tab, still useful if the same terminal ever
// gets rendered in panel-view mode. // gets rendered in panel-view mode.
const issueIcon = issueNumber !== undefined const issueIcon = issueNumber !== undefined
? await panel.resolveIssueIcon(issueNumber) ? await panel.resolveIssueIcon(issueNumber, await statePromise)
: undefined : undefined
const terminal = window.createTerminal({ const terminal = window.createTerminal({
name: terminalName, name: terminalName,
@@ -198,6 +194,8 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
finally { finally {
if (lockKey !== undefined) if (lockKey !== undefined)
panel.resumeInFlight.delete(lockKey) 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 { finally {
panel.resumeInFlight.delete(lockKey) panel.resumeInFlight.delete(lockKey)
panel.postMessage({ type: 'session/tab-settled', sessionId })
} }
} }
@@ -1271,6 +1270,57 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
} }
} }
/**
* 手动启动审查会话:等同 PR 事件触发的自动审查,只是入口在详情面板的
* 「审查会话id」行。必须已有 PR——审查提示词就是让 codex 去看这个 PR。
*/
export async function handleStartReviewSession(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const lockCheck = await panel.resolveLockedReason(issueNumber)
if (lockCheck.locked) {
await window.showWarningMessage(`等待前置工单 #${lockCheck.prerequisiteNumber} 完成`)
return
}
let pr: string | undefined
let worktreePath = ''
try {
const stateObj = await panel.readIssueState(issueNumber)
if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0)
pr = stateObj.pr.replace(/\(已合并\)\s*$/, '')
if (typeof stateObj.worktreePath === 'string')
worktreePath = stateObj.worktreePath
}
catch (err) {
logger.add({
level: 'warn',
source: 'review',
message: `读取 #${issueNumber} state JSON 失败,无法启动审查会话`,
details: err instanceof Error ? err.message : String(err),
})
}
if (!pr) {
void window.showWarningMessage('该工单还没有 PR,无法启动审查会话')
return
}
const ok = await triggerAutoReviewTab(panel, {
issueNumber,
prNumber: pr,
prompt: getReviewPrompt(panel.context, { prNumber: pr }),
worktreePath,
workspaceRoot,
})
if (!ok)
void window.showErrorMessage(`启动审查会话失败 #${issueNumber},详见扩展日志`)
}
/** /**
* 恢复一个已存在的测试 cc 会话:详情面板点测试会话行(testTabOpen=false 时) * 恢复一个已存在的测试 cc 会话:详情面板点测试会话行(testTabOpen=false 时)
* 触发,照搬 handleResumeSession 的 implement 分支,但 kind='test'、终端名 * 触发,照搬 handleResumeSession 的 implement 分支,但 kind='test'、终端名
@@ -1379,6 +1429,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
} }
finally { finally {
panel.resumeInFlight.delete(lockKey) panel.resumeInFlight.delete(lockKey)
panel.postMessage({ type: 'session/tab-settled', sessionId })
} }
} }
+35
View File
@@ -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 })
})
})
+25
View File
@@ -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,
}
}
+3
View File
@@ -53,6 +53,8 @@ export type ExtensionToWebview
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string } | { 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 } } | { 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: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'handoff/done', issueNumber: number } | { type: 'handoff/done', issueNumber: number }
@@ -191,6 +193,7 @@ export type WebviewToExtension
| { type: 'session/focus', issueNumber: number } | { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/start-review', issueNumber: number }
| { type: 'session/start-test', issueNumber: number } | { type: 'session/start-test', issueNumber: number }
| { type: 'editor/open-file', path: string } | { type: 'editor/open-file', path: string }
| { type: 'profiles/list' } | { type: 'profiles/list' }
+1
View File
@@ -14,6 +14,7 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = new Set([
'session/focus', 'session/focus',
'session/resume-review', 'session/resume-review',
'session/resume-test', 'session/resume-test',
'session/start-review',
'session/start-test', 'session/start-test',
'session/close-tab', 'session/close-tab',
'brainstorm/continue', 'brainstorm/continue',
@@ -81,6 +81,7 @@ async function handleCreateSessionUri(uri: Uri): Promise<void> {
prompt, prompt,
cwd, cwd,
panel: null, panel: null,
register: false,
}) })
if (!created) if (!created)
return return
+4
View File
@@ -40,6 +40,8 @@ export function App() {
dismissToast, dismissToast,
openUrl, openUrl,
resumeSession, resumeSession,
isSessionTabPending,
startReviewSession,
resumeReviewSession, resumeReviewSession,
startTestSession, startTestSession,
resumeTestSession, resumeTestSession,
@@ -373,6 +375,8 @@ export function App() {
globalAutoReview={globalAutoReview} globalAutoReview={globalAutoReview}
onOpenInBrowser={openUrl} onOpenInBrowser={openUrl}
onResumeSession={resumeSession} onResumeSession={resumeSession}
isSessionTabPending={isSessionTabPending}
onStartReviewSession={startReviewSession}
onResumeReviewSession={resumeReviewSession} onResumeReviewSession={resumeReviewSession}
onStartTestSession={startTestSession} onStartTestSession={startTestSession}
onResumeTestSession={resumeTestSession} onResumeTestSession={resumeTestSession}
@@ -29,7 +29,9 @@ interface BottomTabsProps {
globalAutoReview: boolean globalAutoReview: boolean
onOpenInBrowser: (url: string) => void onOpenInBrowser: (url: string) => void
onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => 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 onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
onStartReviewSession: (issueNumber: number) => void
onStartTestSession: (issueNumber: number) => void onStartTestSession: (issueNumber: number) => void
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
@@ -127,7 +129,9 @@ export function BottomTabs(props: BottomTabsProps) {
globalAutoReview={props.globalAutoReview} globalAutoReview={props.globalAutoReview}
onOpenInBrowser={props.onOpenInBrowser} onOpenInBrowser={props.onOpenInBrowser}
onResumeSession={props.onResumeSession} onResumeSession={props.onResumeSession}
isSessionTabPending={props.isSessionTabPending}
onResumeReviewSession={props.onResumeReviewSession} onResumeReviewSession={props.onResumeReviewSession}
onStartReviewSession={props.onStartReviewSession}
onStartTestSession={props.onStartTestSession} onStartTestSession={props.onStartTestSession}
onResumeTestSession={props.onResumeTestSession} onResumeTestSession={props.onResumeTestSession}
onOpenFile={props.onOpenFile} onOpenFile={props.onOpenFile}
@@ -39,6 +39,8 @@ interface IssueDetailPanelProps {
globalAutoReview: boolean globalAutoReview: boolean
onOpenInBrowser: (url: string) => void onOpenInBrowser: (url: string) => void
onResumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => 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 <id>` for the auto-review * Open a new terminal that runs `codex resume <id>` for the auto-review
* conversation associated with this issue. * conversation associated with this issue.
@@ -53,6 +55,7 @@ interface IssueDetailPanelProps {
* Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the * Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the
* Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). * Play button next to the "测试会话 id" row (only shown when `issue.pr` exists).
*/ */
onStartReviewSession: (issueNumber: number) => void
onStartTestSession: (issueNumber: number) => void onStartTestSession: (issueNumber: number) => void
/** Open a workspace-relative file in the editor. */ /** Open a workspace-relative file in the editor. */
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
@@ -131,6 +134,7 @@ export function IssueDetailPanel({
onResumeSession, onResumeSession,
onResumeReviewSession, onResumeReviewSession,
onResumeTestSession, onResumeTestSession,
onStartReviewSession,
onStartTestSession, onStartTestSession,
onOpenFile, onOpenFile,
onImplement, onImplement,
@@ -153,6 +157,7 @@ export function IssueDetailPanel({
onUpdateTestProfilePath, onUpdateTestProfilePath,
onOpenLogs, onOpenLogs,
me, me,
isSessionTabPending,
onStartHandoff, onStartHandoff,
onAcceptHandoff, onAcceptHandoff,
isHandoffRunning, isHandoffRunning,
@@ -252,6 +257,7 @@ export function IssueDetailPanel({
key: 'sessionId', key: 'sessionId',
label: '头脑风暴会话id', label: '头脑风暴会话id',
type: 'action' as const, type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.sessionId!) : false,
description: hasSession description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复对话;点 ▶ 续聊' ? '双击在新终端运行 claude --resume <id> 恢复对话;点 ▶ 续聊'
: '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊', : '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊',
@@ -274,6 +280,7 @@ export function IssueDetailPanel({
key: 'implementSessionId', key: 'implementSessionId',
label: '实施会话id', label: '实施会话id',
type: 'action', type: 'action',
pending: issue?.implementSessionId ? isSessionTabPending(issue.implementSessionId) : false,
description: issue?.worktreeExists description: issue?.worktreeExists
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)' ? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)', : 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
@@ -285,19 +292,34 @@ export function IssueDetailPanel({
return return
onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number) onResumeSession(v, 'implement', issue?.profilePath, issue?.worktreePath, issue?.number)
}, },
secondaryActions: issue?.implementTabOpen secondaryActions: (() => {
? [{ if (issue?.implementTabOpen) {
return [{
icon: <X className="size-3.5" />, icon: <X className="size-3.5" />,
title: '关闭实施 tab', title: '关闭实施 tab',
onClick: () => onCloseSessionTab(issue.number, 'implement'), onClick: () => onCloseSessionTab(issue.number, 'implement'),
}] }]
: [], }
// 还没实施会话时给个 ▶,省去把卡片拖进「实施中」列。
if (issue?.implementSessionId || !issue?.planFile)
return []
return [{
icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '启动实施会话',
disabled:
issue.implementStatus === 'running'
|| issue.implementStatus === 'done'
|| locked,
onClick: () => onImplement(issue.number, issue.planFile ?? '', issue.profilePath, issue.sessionId),
}]
})(),
}, },
issue?.reviewSessionFileExists issue?.reviewSessionFileExists
? { ? {
key: 'reviewSessionId', key: 'reviewSessionId',
label: '审查会话id', label: '审查会话id',
type: 'action', type: 'action',
pending: issue.reviewSessionId ? isSessionTabPending(issue.reviewSessionId) : false,
description: '双击在新终端运行 codex resume <id> 查看审查会话(worktree 在则用之,否则工作区根目录)', description: '双击在新终端运行 codex resume <id> 查看审查会话(worktree 在则用之,否则工作区根目录)',
actionIcon: <Terminal className="size-3.5" />, actionIcon: <Terminal className="size-3.5" />,
onAction: (v) => { onAction: (v) => {
@@ -318,9 +340,19 @@ export function IssueDetailPanel({
: { : {
key: 'reviewSessionId', key: 'reviewSessionId',
label: '审查会话id', label: '审查会话id',
type: 'string', type: 'action',
readOnly: true, readOnly: true,
description: '本机没有该 codex 会话文件,无法 resume;仅保留 id 文本', description: issue?.reviewSessionId
? '本机没有该 codex 会话文件,无法 resume;点 ▶ 对当前 PR 重新起一个审查会话'
: '还没有审查会话;点 ▶ 对当前 PR 启动 codex 审查(等同 PR 事件自动触发的那次)',
secondaryActions: issue?.pr
? [{
icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '启动审查会话',
disabled: locked,
onClick: () => onStartReviewSession(issue.number),
}]
: [],
}, },
(() => { (() => {
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态): // 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
@@ -355,6 +387,7 @@ export function IssueDetailPanel({
key: 'testSessionId', key: 'testSessionId',
label: '测试会话id', label: '测试会话id',
type: 'action' as const, type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false,
description: hasSession description: hasSession
? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话' ? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)', : '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
@@ -535,7 +568,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, onStartReviewSession],
) )
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。 // 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
@@ -60,6 +60,11 @@ export interface PropertyDef {
onAction?: (value: unknown) => void onAction?: (value: unknown) => void
/** type='action' 专用:值前的图标 */ /** type='action' 专用:值前的图标 */
actionIcon?: ReactNode actionIcon?: ReactNode
/**
* 主动作是否还在进行中。给了就由外部决定 spinner 何时收(真实进度),
* 不给则沿用点击后固定 1.5s 的即时反馈。
*/
pending?: boolean
/** type='file-link' 专用:双击文件名时调用,参数为 value(workspace 相对路径) */ /** type='file-link' 专用:双击文件名时调用,参数为 value(workspace 相对路径) */
onOpen?: (path: string) => void onOpen?: (path: string) => void
/** type='file-link' 专用:点击右侧图标按钮时调用,用于重新扫描/加载 */ /** type='file-link' 专用:点击右侧图标按钮时调用,用于重新扫描/加载 */
@@ -174,7 +179,8 @@ function PropertyRow({
registerCellRef: (fullKey: string, el: HTMLDivElement | null) => void registerCellRef: (fullKey: string, el: HTMLDivElement | null) => void
}) { }) {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [actionPending, setActionPending] = useState(false) const [localActionPending, setLocalActionPending] = useState(false)
const actionPending = propDef.pending ?? localActionPending
const pendingTimerRef = useRef<number | null>(null) const pendingTimerRef = useRef<number | null>(null)
const ro = propDef.readOnly const ro = propDef.readOnly
@@ -183,14 +189,16 @@ function PropertyRow({
window.clearTimeout(pendingTimerRef.current) window.clearTimeout(pendingTimerRef.current)
}, []) }, [])
/** 触发主动作并给出即时反馈:行内图标临时切换为 spinner,约 1.5s 后恢复 */ /** 触发主动作:外部受控时 spinner 交给 propDef.pending,否则本地转约 1.5s */
function firePrimaryAction(fire: () => void): void { function firePrimaryAction(fire: () => void): void {
fire() fire()
setActionPending(true) if (propDef.pending !== undefined)
return
setLocalActionPending(true)
if (pendingTimerRef.current != null) if (pendingTimerRef.current != null)
window.clearTimeout(pendingTimerRef.current) window.clearTimeout(pendingTimerRef.current)
pendingTimerRef.current = window.setTimeout(() => { pendingTimerRef.current = window.setTimeout(() => {
setActionPending(false) setLocalActionPending(false)
pendingTimerRef.current = null pendingTimerRef.current = null
}, 1500) }, 1500)
} }
+60 -3
View File
@@ -184,7 +184,10 @@ export interface UseIssuesResult {
dismissToast: (id: string) => void dismissToast: (id: string) => void
openUrl: (url: string) => void openUrl: (url: string) => void
resumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => 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 resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
startReviewSession: (issueNumber: number) => void
startTestSession: (issueNumber: number) => void startTestSession: (issueNumber: number) => void
resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
focusSession: (issueNumber: number) => void focusSession: (issueNumber: number) => void
@@ -479,6 +482,50 @@ export function useIssues(): UseIssuesResult {
postMessage({ type: 'toast/open-url', url }) postMessage({ type: 'toast/open-url', url })
}, []) }, [])
// 开 tab 是秒级操作(要读 state JSON、跑 pre-create 钩子),行内 spinner 要
// 真实反映进度:发请求时标 pending,扩展回 session/tab-settled 时清。消息万一
// 丢了就靠 PENDING_TIMEOUT_MS 兜底,spinner 不会永远转下去。
const [pendingSessionTabs, setPendingSessionTabs] = useState<Set<string>>(() => new Set())
const pendingTimersRef = useRef<Map<string, number>>(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(( const resumeSession = useCallback((
sessionId: string, sessionId: string,
kind: 'implement' | 'brainstorm', kind: 'implement' | 'brainstorm',
@@ -486,15 +533,21 @@ export function useIssues(): UseIssuesResult {
cwd?: string, cwd?: string,
issueNumber?: number, issueNumber?: number,
): void => { ): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber }) postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber })
}, []) }, [markSessionTabPending])
const focusSession = useCallback((issueNumber: number): void => { const focusSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/focus', issueNumber }) postMessage({ type: 'session/focus', issueNumber })
}, []) }, [])
const resumeReviewSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => { const resumeReviewSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume-review', sessionId, issueNumber, cwd }) postMessage({ type: 'session/resume-review', sessionId, issueNumber, cwd })
}, [markSessionTabPending])
const startReviewSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/start-review', issueNumber })
}, []) }, [])
const startTestSession = useCallback((issueNumber: number): void => { const startTestSession = useCallback((issueNumber: number): void => {
@@ -502,8 +555,9 @@ export function useIssues(): UseIssuesResult {
}, []) }, [])
const resumeTestSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => { const resumeTestSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume-test', sessionId, issueNumber, cwd }) postMessage({ type: 'session/resume-test', sessionId, issueNumber, cwd })
}, []) }, [markSessionTabPending])
const openFile = useCallback((path: string): void => { const openFile = useCallback((path: string): void => {
postMessage({ type: 'editor/open-file', path }) postMessage({ type: 'editor/open-file', path })
@@ -722,6 +776,9 @@ export function useIssues(): UseIssuesResult {
useEffect(() => { useEffect(() => {
const cleanup = onMessage((msg) => { const cleanup = onMessage((msg) => {
switch (msg.type) { switch (msg.type) {
case 'session/tab-settled':
clearSessionTabPending(msg.sessionId)
break
case 'issues/loading': case 'issues/loading':
setRefreshing(true) setRefreshing(true)
setState(prev => prev.status === 'ready' ? prev : { status: 'loading' }) setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
@@ -938,5 +995,5 @@ export function useIssues(): UseIssuesResult {
return cleanup return cleanup
}, [clearPrDiffSummaryRunning, markHandoffRunning]) }, [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, startReviewSession, 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 }
} }
+3
View File
@@ -84,6 +84,8 @@ export type ExtensionToWebview
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string } | { 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 } } | { 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: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'handoff/users-result', issueNumber: number, users: string[] } | { type: 'handoff/users-result', issueNumber: number, users: string[] }
| { type: 'session-handoff/users-result', users: string[] } | { type: 'session-handoff/users-result', users: string[] }
@@ -222,6 +224,7 @@ export type WebviewToExtension
| { type: 'session/focus', issueNumber: number } | { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string } | { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/start-review', issueNumber: number }
| { type: 'session/start-test', issueNumber: number } | { type: 'session/start-test', issueNumber: number }
| { type: 'editor/open-file', path: string } | { type: 'editor/open-file', path: string }
| { type: 'profiles/list' } | { type: 'profiles/list' }