Compare commits

...
6 Commits
Author SHA1 Message Date
cruldra 814e0e8f5a 🔧 chore(vscode): 版本号升到 0.2.138
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-14 10:20:45 +08:00
cruldra b62e58d739 ✨ feat(vscode): 新增第五种工单会话「原型会话」
照测试会话那条链路补一份 prototype:

- 消息与路由:session/start-prototype、session/resume-prototype、
  issue/update-prototype-profile-path,都进只读门禁
- 会话:handleStartPrototypeSession / handleResumePrototypeSession,
  终端名 issue-N-原型;cwd 有 worktree 用 worktree、没有退主仓库根
  (原 resolveTestSessionCwd 改名 resolveWorktreeOrRootCwd 两种会话共用)
- 不要求有 PR,任何阶段都能起;前置工单锁仍然拦
- 提示词:新增 prompts/prototype.md,设置里可改,占位符 {issueNumber}
- profile:工单级 prototypeProfilePath > 设置里的原型默认 profile >
  内置默认,与其它会话互不回退
- 状态字段 prototypeSessionId / prototypeProfilePath / prototypeTabOpen
  贯通 gitea、youtrack、本机 state 与移交包
- 详情面板加「原型会话id」「原型配置文件」两行,设置面板加对应两项

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-14 10:20:30 +08:00
cruldra 3fff796a61 🔧 chore(vscode): 版本号升到 0.2.137
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 13:19:41 +08:00
cruldra f946e5cfee ✨ feat(vscode): 测试提示词改成可配置模板并带上工单号
启动测试会话原先硬编码发 `/pr-acceptance-testing`,不带目标,验收技能只能
反问要测哪个。现在做成 prompts/test.md 模板 + 设置里的「测试提示词」,占位符
{issueNumber} 和 {prRef}(worktree 里是分支,主仓库里是已合并的 PR)。

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 13:19:28 +08:00
cruldra e05a5234db 🔧 chore(vscode): 版本号升到 0.2.136
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 12:46:50 +08:00
cruldra 0e84965e2a 🐛 fix(prompts): 实施提示词删掉项目专属的数据库段落
默认实施提示词里写死了某个项目的 dev DB 地址和 alembic 用法,而提示词设置
是全局共用的,换个项目就是错的指令。项目自己的迁移规则归各仓库 CLAUDE.md。

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-13 12:46:37 +08:00
32 changed files with 836 additions and 83 deletions

No files matched your search

@@ -3,13 +3,3 @@
按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先 `/ecc:code-review` 自审并修掉严重问题。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
## 数据库迁移(alembic)
多个 feature worktree 共用同一台 dev DB(`192.168.1.4:5433`)。任何 worktree 直接在共享库上跑迁移,都会让别的分支 `alembic upgrade` 崩(DB 里记着的 revision 在对方代码里不存在)。所以本会话:
- 只用 `uv run alembic revision --autogenerate -m "..."` 生成迁移文件,**绝不手写 revision 文件**。
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
- **绝不**对共享 dev DB(`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
-10
View File
@@ -3,13 +3,3 @@
按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先 `/ecc:code-review` 自审并修掉严重问题。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
## 数据库迁移(alembic)
多个 feature worktree 共用同一台 dev DB(`192.168.1.4:5433`)。任何 worktree 直接在共享库上跑迁移,都会让别的分支 `alembic upgrade` 崩(DB 里记着的 revision 在对方代码里不存在)。所以本会话:
- 只用 `uv run alembic revision --autogenerate -m "..."` 生成迁移文件,**绝不手写 revision 文件**。
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
- **绝不**对共享 dev DB(`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra",
"name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra",
"version": "0.2.135",
"version": "0.2.138",
"packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra",
-10
View File
@@ -3,13 +3,3 @@
按计划实施 {issueNumber} 号工单:具体编码派子代理去做,全程保持测试绿灯。计划里的验证命令只当意图,落地时用仓库自己的 test / lint / typecheck 命令。提 PR 前先自审并修掉严重问题:把 `git diff origin/main...HEAD` 交给 `ecc:code-reviewer` 子代理(`/ecc:code-review` 的本地模式只看未提交改动,提交过就看不到了)。发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
## 数据库迁移(alembic)
多个 feature worktree 共用同一台 dev DB(`192.168.1.4:5433`)。任何 worktree 直接在共享库上跑迁移,都会让别的分支 `alembic upgrade` 崩(DB 里记着的 revision 在对方代码里不存在)。所以本会话:
- 只用 `uv run alembic revision --autogenerate -m "..."` 生成迁移文件,**绝不手写 revision 文件**。
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
- **绝不**对共享 dev DB(`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
+1
View File
@@ -0,0 +1 @@
/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
+3
View File
@@ -0,0 +1,3 @@
/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。
验收目标以工单正文为准,不要再问验收哪个。测出问题就停下报告,不要顺手改代码。
+8 -5
View File
@@ -1,15 +1,17 @@
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test'
export type ClaudeSessionKind = 'brainstorm' | 'implement' | 'test' | 'prototype'
export const CLAUDE_SESSION_FIELDS = [
{ field: 'sessionId', kind: 'brainstorm' },
{ field: 'implementSessionId', kind: 'implement' },
{ field: 'testSessionId', kind: 'test' },
] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId', kind: ClaudeSessionKind }>
{ field: 'prototypeSessionId', kind: 'prototype' },
] as const satisfies ReadonlyArray<{ field: 'sessionId' | 'implementSessionId' | 'testSessionId' | 'prototypeSessionId', kind: ClaudeSessionKind }>
export interface HandoffSessions {
sessionId?: string
implementSessionId?: string
testSessionId?: string
prototypeSessionId?: string
reviewSessionId?: string
}
@@ -17,6 +19,7 @@ export interface HandoffProfiles {
profilePath?: string
brainstormProfilePath?: string
testProfilePath?: string
prototypeProfilePath?: string
}
export interface HandoffManifest {
@@ -38,7 +41,7 @@ export function handoffAttachmentName(issueNumber: number): string {
return `spx-handoff-issue-${issueNumber}.tgz`
}
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test'])
const KINDS: ReadonlySet<string> = new Set<ClaudeSessionKind>(['brainstorm', 'implement', 'test', 'prototype'])
// Claude / Codex 会话 id 都是 UUID;顺手挡掉路径穿越(如 `../x`)之类的伪造条目。
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -80,13 +83,13 @@ export function parseHandoffManifest(json: string, expectedIssue: number): Hando
return { id, relPath }
})
const sessions: HandoffSessions = {}
for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'reviewSessionId'] as const) {
for (const k of ['sessionId', 'implementSessionId', 'testSessionId', 'prototypeSessionId', 'reviewSessionId'] as const) {
const v = optString(sessionsRaw[k])
if (v)
sessions[k] = v
}
const profiles: HandoffProfiles = {}
for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath'] as const) {
for (const k of ['profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath'] as const) {
const v = optString(profilesRaw[k])
if (v)
profiles[k] = v
+21
View File
@@ -46,6 +46,27 @@ export function getImplementPlanPrompt(ctx: ExtensionContext, vars: { planFile:
.join(vars.issueRef ?? String(vars.issueNumber))
}
/**
* 测试会话首个提示词。`prRef` 是给验收技能的定位线索:主 worktree 上是
* 已合并的 PR,worktree 里就是那条分支。
*/
export function getTestPrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string, prRef: string }): string {
const tpl = getSettings(ctx).testPrompt
return tpl
.split('{issueNumber}')
.join(vars.issueRef ?? String(vars.issueNumber))
.split('{prRef}')
.join(vars.prRef)
}
/**
* 原型会话首个提示词。只认工单号——原型是照工单正文画界面,与 PR / 分支无关。
*/
export function getPrototypePrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string }): string {
const tpl = getSettings(ctx).prototypePrompt
return tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
}
export function getReviewPrompt(ctx: ExtensionContext, vars: { prNumber: string }): string {
const tpl = getSettings(ctx).reviewPrompt
return tpl.split('{prNumber}').join(vars.prNumber)
+16
View File
@@ -70,6 +70,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
profilePath?: string
brainstormProfilePath?: string
testProfilePath?: string
prototypeProfilePath?: string
specFile?: string
planFile?: string
prDiffFile?: string
@@ -82,6 +83,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
implementSessionId?: string
reviewSessionId?: string
testSessionId?: string
prototypeSessionId?: string
color?: string
autoReview?: boolean
handoffAttachmentId?: string
@@ -116,6 +118,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
profilePath?: unknown
brainstormProfilePath?: unknown
testProfilePath?: unknown
prototypeProfilePath?: unknown
specFile?: unknown
planFile?: unknown
prDiffFile?: unknown
@@ -128,6 +131,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
implementSessionId?: unknown
reviewSessionId?: unknown
testSessionId?: unknown
prototypeSessionId?: unknown
color?: unknown
autoReview?: unknown
handoffAttachmentId?: unknown
@@ -146,6 +150,9 @@ function parseColumnFromComments(comments: GiteaComment[]): {
const testProfilePath = typeof obj.testProfilePath === 'string' && obj.testProfilePath.length > 0
? obj.testProfilePath
: undefined
const prototypeProfilePath = typeof obj.prototypeProfilePath === 'string' && obj.prototypeProfilePath.length > 0
? obj.prototypeProfilePath
: undefined
const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : undefined
const planFile = isValidSpxFilePath(obj.planFile) ? obj.planFile : undefined
const prDiffFile = isValidPrDiffFilePath(obj.prDiffFile) ? obj.prDiffFile : undefined
@@ -176,6 +183,9 @@ function parseColumnFromComments(comments: GiteaComment[]): {
const testSessionId = typeof obj.testSessionId === 'string' && obj.testSessionId.length > 0
? obj.testSessionId
: undefined
const prototypeSessionId = typeof obj.prototypeSessionId === 'string' && obj.prototypeSessionId.length > 0
? obj.prototypeSessionId
: undefined
const color = typeof obj.color === 'string' && obj.color.length > 0
? obj.color
: undefined
@@ -192,6 +202,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
profilePath,
brainstormProfilePath,
testProfilePath,
prototypeProfilePath,
specFile,
planFile,
prDiffFile,
@@ -204,6 +215,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
implementSessionId,
reviewSessionId,
testSessionId,
prototypeSessionId,
color,
autoReview,
handoffAttachmentId,
@@ -333,6 +345,7 @@ async function buildIssue(opts: {
profilePath,
brainstormProfilePath,
testProfilePath,
prototypeProfilePath,
specFile,
planFile,
prDiffFile,
@@ -345,6 +358,7 @@ async function buildIssue(opts: {
implementSessionId,
reviewSessionId,
testSessionId,
prototypeSessionId,
color,
autoReview,
handoffAttachmentId,
@@ -395,6 +409,7 @@ async function buildIssue(opts: {
...(profilePath ? { profilePath } : {}),
...(brainstormProfilePath ? { brainstormProfilePath } : {}),
...(testProfilePath ? { testProfilePath } : {}),
...(prototypeProfilePath ? { prototypeProfilePath } : {}),
...(specFile ? { specFile } : {}),
...(planFile ? { planFile } : {}),
...(prDiffFile ? { prDiffFile } : {}),
@@ -408,6 +423,7 @@ async function buildIssue(opts: {
...(implementSessionId ? { implementSessionId } : {}),
...(reviewSessionId ? { reviewSessionId } : {}),
...(testSessionId ? { testSessionId } : {}),
...(prototypeSessionId ? { prototypeSessionId } : {}),
...(prerequisite !== undefined ? { prerequisite } : {}),
...(color ? { color } : {}),
...(autoReview !== undefined ? { autoReview } : {}),
+1 -1
View File
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
*/
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
/**
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
+9
View File
@@ -45,6 +45,8 @@ export interface Issue {
brainstormProfilePath?: string
/** 测试会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */
testProfilePath?: string
/** 原型会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */
prototypeProfilePath?: string
/**
* Optional workspace-relative path to the spec file for this issue, as
* surfaced from the Claude session transcript. Lives under
@@ -100,6 +102,11 @@ export interface Issue {
* is merged; reused for resume from the detail panel.
*/
testSessionId?: string
/**
* 原型会话(Claude Code session id)。详情面板点原型会话行的 ▶ 启动后写入,
* 之后该行改为可 resume。与 PR 无关,任何阶段都能起。
*/
prototypeSessionId?: string
/** Browser URL to the Gitea issue page (escape hatch button in the UI). */
htmlUrl: string
/** 前置任务 issue number (Gitea dependencies 取第一个) */
@@ -132,4 +139,6 @@ export interface Issue {
reviewTabOpen?: boolean
/** Whether the test terminal tab is currently alive in this VS Code window. */
testTabOpen?: boolean
/** Whether the prototype terminal tab is currently alive in this VS Code window. */
prototypeTabOpen?: boolean
}
+2
View File
@@ -17,9 +17,11 @@ export const LOCAL_STATE_FIELDS = [
'implementSessionId',
'reviewSessionId',
'testSessionId',
'prototypeSessionId',
'profilePath',
'brainstormProfilePath',
'testProfilePath',
'prototypeProfilePath',
'worktreePath',
'prDiffFile',
] as const
+22 -3
View File
@@ -7,6 +7,7 @@ import type {
import type { HookContext } from '../git/worktreeHooks'
import type { Issue } from '../gitea/types'
import type { IssueRef } from '../issues/stateRouter'
import type { SessionTabKind } from './handlers/terminals'
import type { IssueOwnershipFacts } from './issueOwnership'
import type { KanbanScope } from './kanbanScope'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
@@ -164,7 +165,7 @@ export class KanbanWebviewPanel {
* Map 更易于 inspect / debug。条目数顶天就是当前活跃 tab 数,无压力。
*/
// internal: handler 模块访问
readonly terminalOrigin = new Map<Terminal, { issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' }>()
readonly terminalOrigin = new Map<Terminal, { issueNumber: number, kind: SessionTabKind }>()
/**
* 防 handleResumeSession/handleResumeReviewSession 在 await 钩子/终端创建期间被重入触发,
@@ -296,7 +297,7 @@ export class KanbanWebviewPanel {
const origin = this.terminalOrigin.get(closed)
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
this.untrackClosedTerminal(closed)
if (origin?.kind === 'implement' || origin?.kind === 'test')
if (origin?.kind === 'implement' || origin?.kind === 'test' || origin?.kind === 'prototype')
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
const conflictMatch = closed.name.match(/^issue-(\d+)-冲突解决/)
@@ -451,6 +452,14 @@ export class KanbanWebviewPanel {
void sessions.handleResumeTestSession(this, msg.sessionId, msg.issueNumber, msg.cwd)
return
}
if (msg.type === 'session/start-prototype') {
void sessions.handleStartPrototypeSession(this, msg.issueNumber)
return
}
if (msg.type === 'session/resume-prototype') {
void sessions.handleResumePrototypeSession(this, msg.sessionId, msg.issueNumber, msg.cwd)
return
}
if (msg.type === 'editor/open-file') {
void issues.handleOpenFile(this, msg.path)
return
@@ -550,6 +559,10 @@ export class KanbanWebviewPanel {
void issues.handleUpdateTestProfilePath(this, msg.issueNumber, msg.testProfilePath)
return
}
if (msg.type === 'issue/update-prototype-profile-path') {
void issues.handleUpdatePrototypeProfilePath(this, msg.issueNumber, msg.prototypeProfilePath)
return
}
if (msg.type === 'logs/fetch') {
this.postMessage({ type: 'logs/snapshot', entries: logger.snapshot() })
return
@@ -988,6 +1001,8 @@ export class KanbanWebviewPanel {
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
testPrompt: s.testPrompt,
prototypePrompt: s.prototypePrompt,
devBranch: s.devBranch,
releaseBranchPrefix: s.releaseBranchPrefix,
jenkinsJob: s.jenkinsJob,
@@ -1003,6 +1018,7 @@ export class KanbanWebviewPanel {
defaultBrainstormProfilePath: s.defaultBrainstormProfilePath,
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
defaultPrototypeProfilePath: s.defaultPrototypeProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context),
@@ -1076,6 +1092,8 @@ export class KanbanWebviewPanel {
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
testPrompt: s.testPrompt,
prototypePrompt: s.prototypePrompt,
devBranch: s.devBranch,
releaseBranchPrefix: s.releaseBranchPrefix,
jenkinsJob: s.jenkinsJob,
@@ -1091,6 +1109,7 @@ export class KanbanWebviewPanel {
defaultBrainstormProfilePath: s.defaultBrainstormProfilePath,
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
defaultPrototypeProfilePath: s.defaultPrototypeProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context),
@@ -1200,7 +1219,7 @@ export class KanbanWebviewPanel {
trackSessionTerminal(
terminal: Terminal,
issueNumber: number,
kind: 'brainstorm' | 'implement' | 'review' | 'test',
kind: SessionTabKind,
): void {
terminals.trackSessionTerminal(this, terminal, issueNumber, kind)
}
+9 -1
View File
@@ -63,15 +63,17 @@ describe('payloads', () => {
handoffFrom: 'chw',
})
})
it('本机墓碑:keepWorktreePath=false 时九个本机字段全清', () => {
it('本机墓碑:keepWorktreePath=false 时本机字段全清', () => {
expect(handoffLocalTombstones({ keepWorktreePath: false })).toEqual({
sessionId: '',
implementSessionId: '',
reviewSessionId: '',
testSessionId: '',
prototypeSessionId: '',
profilePath: '',
brainstormProfilePath: '',
testProfilePath: '',
prototypeProfilePath: '',
worktreePath: '',
prDiffFile: '',
})
@@ -84,9 +86,11 @@ describe('payloads', () => {
implementSessionId: '',
reviewSessionId: '',
testSessionId: '',
prototypeSessionId: '',
profilePath: '',
brainstormProfilePath: '',
testProfilePath: '',
prototypeProfilePath: '',
prDiffFile: '',
})
})
@@ -99,9 +103,11 @@ describe('payloads', () => {
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
prototypeSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prototypeProfilePath: null,
prDiffFile: null,
worktreePath: null,
worktreeExists: false,
@@ -119,9 +125,11 @@ describe('payloads', () => {
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
prototypeSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prototypeProfilePath: null,
prDiffFile: null,
})
})
+2
View File
@@ -71,9 +71,11 @@ export function handoffStartedUiPatch(attachmentId: number, from: string, to: st
implementSessionId: null,
reviewSessionId: null,
testSessionId: null,
prototypeSessionId: null,
profilePath: null,
brainstormProfilePath: null,
testProfilePath: null,
prototypeProfilePath: null,
prDiffFile: null,
// worktree 没删掉时(opts.keepWorktreePath)不碰这两个字段,让 UI 继续显示孤儿 worktree。
...(opts.keepWorktreePath ? {} : { worktreePath: null, worktreeExists: false }),
+2
View File
@@ -181,12 +181,14 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
...(str(state.sessionId) ? { sessionId: str(state.sessionId) } : {}),
...(str(state.implementSessionId) ? { implementSessionId: str(state.implementSessionId) } : {}),
...(str(state.testSessionId) ? { testSessionId: str(state.testSessionId) } : {}),
...(str(state.prototypeSessionId) ? { prototypeSessionId: str(state.prototypeSessionId) } : {}),
...(str(state.reviewSessionId) ? { reviewSessionId: str(state.reviewSessionId) } : {}),
}
const profiles: HandoffProfiles = {
...(str(state.profilePath) ? { profilePath: str(state.profilePath) } : {}),
...(str(state.brainstormProfilePath) ? { brainstormProfilePath: str(state.brainstormProfilePath) } : {}),
...(str(state.testProfilePath) ? { testProfilePath: str(state.testProfilePath) } : {}),
...(str(state.prototypeProfilePath) ? { prototypeProfilePath: str(state.prototypeProfilePath) } : {}),
}
// ① worktree 守卫:干净 + 与远端一致。worktree 存在但 state 没有 branch 时
+95 -3
View File
@@ -117,6 +117,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
let fromColumn: IssueColumn | undefined
let implementSessionId: string | undefined
let testSessionId: string | undefined
let prototypeSessionId: string | undefined
let featureBranchFromState: string | undefined
try {
const stateObj = await panel.readIssueState(issueNumber)
@@ -134,6 +135,8 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
implementSessionId = stateObj.implementSessionId
if (typeof stateObj.testSessionId === 'string' && stateObj.testSessionId.length > 0)
testSessionId = stateObj.testSessionId
if (typeof stateObj.prototypeSessionId === 'string' && stateObj.prototypeSessionId.length > 0)
prototypeSessionId = stateObj.prototypeSessionId
if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0)
featureBranchFromState = stateObj.branch
}
@@ -394,13 +397,13 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
issueNumber,
})
// 删 worktree 前把实施/测试会话整包(jsonl + 子目录)搬到主仓库 projects 目录,
// 删 worktree 前把实施/测试/原型会话整包(jsonl + 子目录)搬到主仓库 projects 目录,
// 并清掉 worktree 侧副本:新版 claude 遇到重复 sid 会拒绝 resume。
if ((implementSessionId || testSessionId) && worktreePath) {
if ((implementSessionId || testSessionId || prototypeSessionId) && worktreePath) {
const worktreeAbs = resolveWorktreePath(worktreePath, workspaceRoot)
const srcProjectsDir = projectsDirFor(worktreeAbs)
const dstProjectsDir = projectsDirFor(workspaceRoot)
for (const sid of [implementSessionId, testSessionId]) {
for (const sid of [implementSessionId, testSessionId, prototypeSessionId]) {
if (typeof sid !== 'string' || sid.length === 0)
continue
try {
@@ -2210,6 +2213,95 @@ export async function handleUpdateTestProfilePath(panel: KanbanWebviewPanel, iss
}
}
/**
* Persist a per-issue `prototypeProfilePath` override into the issue's state JSON
* comment. 原型 start/resume 用它(见 resolvePrototypeProfilePath);
* 空 = 设置里的「原型默认配置文件」,再空 = getDefaultProfilePath(),与其它会话互不回退。
*/
export async function handleUpdatePrototypeProfilePath(panel: KanbanWebviewPanel, issueNumber: number, prototypeProfilePath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
const remote = await detectRepo(workspaceRoot)
if (!remote) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '当前工作区没有 Gitea 远程仓库',
dismissOnTimer: 5000,
})
return
}
const token = await getToken(panel.context, remote.host)
if (!token) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先完成 Gitea 配置',
dismissOnTimer: 5000,
})
return
}
// Snapshot the previous value so we can roll the webview back on failure
// without re-broadcasting the whole issues list.
let previousValue: string | undefined
try {
const existingState = await panel.readIssueState(issueNumber)
previousValue = typeof existingState.prototypeProfilePath === 'string'
? existingState.prototypeProfilePath
: undefined
}
catch {
// If we can't read the previous state, leave previousValue undefined —
// the rollback patch will clear the override, falling back to the default.
previousValue = undefined
}
try {
await panel.mergeIssueState(issueNumber, { prototypeProfilePath })
logger.add({
level: 'info',
source: 'panel',
message: `工单 #${issueNumber} prototypeProfilePath=${prototypeProfilePath} 已持久化`,
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'error',
source: 'panel',
message: `持久化 prototypeProfilePath 失败 (issue #${issueNumber})`,
details: message,
})
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `保存工单 #${issueNumber} 原型配置文件失败: ${message}`,
dismissOnTimer: 6000,
})
// Roll back the optimistic update in the webview.
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch: { prototypeProfilePath: previousValue },
})
}
}
export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
+316 -7
View File
@@ -13,7 +13,7 @@ import { buildCcCommand } from '../../cc/ccCommand'
import { buildCodexCommandString } from '../../cc/codexCommand'
import { watchForNewCodexSession } from '../../cc/codexSessionWatcher'
import { resolveProfilePath } from '../../cc/profiles'
import { getBrainstormContinuePrompt, getImplementPlanPrompt, getReviewPrompt } from '../../cc/prompts'
import { getBrainstormContinuePrompt, getImplementPlanPrompt, getPrototypePrompt, getReviewPrompt, getTestPrompt } from '../../cc/prompts'
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
import { detectRepo } from '../../git/remote'
import { ensureWorktree, resolveWorktreePath } from '../../git/worktree'
@@ -1156,7 +1156,7 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
}
// worktree 还在(PR 未合并/未清理)就进 worktree 测,已清理则回退主 worktree(main)。
const effectiveCwd = await resolveTestSessionCwd(workspaceRoot, worktreePathRel)
const effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
const inWorktree = effectiveCwd !== workspaceRoot
// 主 worktree(已合并)测试要靠 tea 了解合并进来的改动,故必须有 PR;
@@ -1176,8 +1176,14 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
}
// worktree 里:代码就在当前工作目录,不用 tea 拉取,直接测;
// 主 worktree:PR 已合并,先用 tea 熟悉改动再测。
const prompt = '/pr-acceptance-testing'
// 主 worktree:PR 已合并,先用 tea 熟悉改动再测。验收目标直接写进提示词,
// 省得验收技能反问要测哪个。
const prompt = getTestPrompt(panel.context, {
issueNumber,
prRef: inWorktree
? `worktree 分支 ${branch || effectiveCwd}`
: `PR #${pr}`,
})
if (prompt.includes('\'')) {
void window.showErrorMessage('启动测试失败:prompt 含单引号,拒绝执行')
return
@@ -1362,7 +1368,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
branchForHook = stateObj.branch
if (typeof stateObj?.testProfilePath === 'string' && stateObj.testProfilePath.length > 0)
testProfilePath = stateObj.testProfilePath
effectiveCwd = await resolveTestSessionCwd(workspaceRoot, worktreePathRel)
effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
}
catch (err) {
logger.add({
@@ -1433,6 +1439,297 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
}
}
/**
* 启动一个「原型」cc 会话:详情面板点原型会话行的 ▶,开一个 claude tab 让 cc
* 照工单正文画界面原型。
*
* 与测试会话的差异:原型只吃工单正文,不依赖 PR,任何阶段都能起;其余(cwd
* 回退、profile 解析、pre-create 钩子、session id 回写)与测试会话同构。
*
* in-flight 锁 key `${issueNumber}:prototype`,防 createTerminal 期间重复点击。
*/
export async function handleStartPrototypeSession(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const lockKey = `${issueNumber}:prototype`
if (panel.resumeInFlight.has(lockKey)) {
logger.add({
level: 'info',
source: 'panel',
message: `start-prototype #${issueNumber} 已在进行中,忽略重入`,
})
return
}
panel.resumeInFlight.add(lockKey)
try {
const terminalName = `issue-${issueNumber}-原型`
const existing = panel.findExistingTerminal(terminalName)
if (existing) {
panel.trackSessionTerminal(existing, issueNumber, 'prototype')
existing.show(false)
logger.add({
level: 'info',
source: 'panel',
message: `复用已有原型终端 #${issueNumber},跳过 cc 启动`,
})
return
}
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const remote = await detectRepo(workspaceRoot)
if (!remote) {
void window.showErrorMessage('当前工作区没有 Gitea 远程仓库')
return
}
const token = await getToken(panel.context, remote.host)
if (!token) {
void window.showErrorMessage('请先完成 Gitea 配置')
return
}
const lockCheck = await panel.resolveLockedReason(issueNumber)
if (lockCheck.locked) {
void window.showWarningMessage(`等待前置工单 #${lockCheck.prerequisiteNumber} 完成`)
return
}
let prototypeProfilePath: string | undefined
let worktreePathRel: string | undefined
let branch: string | undefined
try {
const stateObj = await panel.readIssueState(issueNumber)
if (stateObj) {
if (typeof stateObj.prototypeProfilePath === 'string' && stateObj.prototypeProfilePath.length > 0)
prototypeProfilePath = stateObj.prototypeProfilePath
if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0)
worktreePathRel = stateObj.worktreePath
if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0)
branch = stateObj.branch
}
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `读取 #${issueNumber} state JSON 失败,无法启动原型会话`,
details: err instanceof Error ? err.message : String(err),
})
}
// worktree 还在就进 worktree 画,已清理则退回主 worktree。
const effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
// 原型会话只用 prototypeProfilePath;空 = DEFAULT,不回退其它会话 profile。
const effectiveProfilePath = resolvePrototypeProfilePath(panel, prototypeProfilePath)
if (effectiveProfilePath.includes('\'')) {
void window.showErrorMessage(
`启动原型失败:prototypeProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
)
return
}
const prompt = getPrototypePrompt(panel.context, { issueNumber })
if (prompt.includes('\'')) {
void window.showErrorMessage('启动原型失败:prompt 含单引号,拒绝执行')
return
}
// Ensure the projects dir exists so the watcher can't miss the create
// event. cwd 决定 claude session jsonl 落在哪个 projects 子目录下。
const projDir = projectsDirFor(effectiveCwd)
try {
await fsp.mkdir(projDir, { recursive: true })
}
catch (err) {
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
}
// Kick off the watcher before spawning the terminal so we don't race.
const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
// 原型 cc tab 创建前钩子,与实施/测试会话共用同一钩子(impl-tab-pre-create)。
// 顶部「复用已有原型终端」的 fast path 已早 return,复用不会执行到这里。
const settings = getSettings(panel.context)
await panel.dispatchWorktreeHook('impl-tab-pre-create', {
workspaceRoot,
worktreePath: effectiveCwd,
branch: branch ?? '',
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.implTabPreCreateScript,
})
const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
const terminal = window.createTerminal({
name: terminalName,
cwd: effectiveCwd,
location: panel.resolveTerminalLocation(false),
iconPath: iconUri,
color: themeColor,
})
panel.trackSessionTerminal(terminal, issueNumber, 'prototype')
terminal.show(false)
logger.add({
level: 'info',
source: 'terminal',
message: `已创建终端 "${terminal.name}"`,
})
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt })
terminal.sendText(cmd)
logger.add({
level: 'info',
source: 'panel',
message: `已发送原型 prompt 到终端 #${issueNumber}`,
})
// Fire-and-forget: when the session jsonl materializes, persist the id as
// the issue's `prototypeSessionId` so the X / resume button toggles.
watchPromise.then(async (sid) => {
if (!sid) {
logger.add({
level: 'warn',
source: 'panel',
message: '原型会话监听超时 (120s)',
})
return
}
logger.add({
level: 'info',
source: 'panel',
message: `已捕获原型会话 ${sid}`,
})
try {
await panel.mergeIssueState(issueNumber, { prototypeSessionId: sid })
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch: { prototypeSessionId: sid },
})
}
catch (err) {
console.warn('[superpowers] failed to persist prototypeSessionId:', err)
}
}).catch((err) => {
console.warn('[superpowers] prototype session watch failed:', err)
})
void window.showInformationMessage(`已启动 #${issueNumber} 原型会话`)
}
finally {
panel.resumeInFlight.delete(lockKey)
}
}
/**
* 恢复一个已存在的原型 cc 会话:详情面板点原型会话行(prototypeTabOpen=false 时)
* 触发。终端名 `issue-${N}-原型`、命令 `claude ... --resume ${sessionId}`。
*
* cwd 与 start 对称:worktree 还在就进 worktree(claude 的会话 jsonl 按 cwd 分目录,
* 走错目录就找不到这条会话),已清理才退回主 worktree。
* in-flight 锁 key `${issueNumber}:prototype`。
*/
export async function handleResumePrototypeSession(panel: KanbanWebviewPanel, sessionId: string, issueNumber: number, _relCwd?: string): Promise<void> {
const lockKey = `${issueNumber}:prototype`
if (panel.resumeInFlight.has(lockKey)) {
logger.add({
level: 'info',
source: 'panel',
message: `resume #${issueNumber}/prototype 已在进行中,忽略重入`,
})
return
}
panel.resumeInFlight.add(lockKey)
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let effectiveCwd = workspaceRoot
// 顺带读出 branch 给 impl-tab-pre-create 钩子用;读不到就空字符串。
let branchForHook = ''
let prototypeProfilePath: string | undefined
if (workspaceRoot) {
const remote = await detectRepo(workspaceRoot)
const token = remote ? await getToken(panel.context, remote.host) : undefined
if (remote && token) {
try {
const stateObj = await panel.readIssueState(issueNumber)
const worktreePathRel = typeof stateObj?.worktreePath === 'string' ? stateObj.worktreePath : undefined
if (stateObj && typeof stateObj.branch === 'string')
branchForHook = stateObj.branch
if (typeof stateObj?.prototypeProfilePath === 'string' && stateObj.prototypeProfilePath.length > 0)
prototypeProfilePath = stateObj.prototypeProfilePath
effectiveCwd = await resolveWorktreeOrRootCwd(workspaceRoot, worktreePathRel)
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `读取 #${issueNumber} state JSON 失败,原型会话回退主 worktree 恢复`,
details: err instanceof Error ? err.message : String(err),
})
}
}
}
// --settings 决定 provider/model/鉴权,resume 也必须与 start 一致解析 profile。
const effectiveProfilePath = resolvePrototypeProfilePath(panel, prototypeProfilePath)
if (effectiveProfilePath.includes('\'')) {
void window.showErrorMessage(
`resume 失败:prototypeProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
)
return
}
const terminalName = `issue-${issueNumber}-原型`
const existingByName = panel.findExistingTerminal(terminalName)
if (existingByName) {
panel.trackSessionTerminal(existingByName, issueNumber, 'prototype')
existingByName.show(false)
return
}
// 回退主 worktree 时不触发 pre-create——主 worktree 不该被 pycharm 指向,
// 与实施 / 测试 resume 一致。
if (effectiveCwd !== workspaceRoot && workspaceRoot) {
const settings = getSettings(panel.context)
await panel.dispatchWorktreeHook('impl-tab-pre-create', {
workspaceRoot,
worktreePath: effectiveCwd!,
branch: branchForHook,
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.implTabPreCreateScript,
})
}
const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
const terminal = window.createTerminal({
name: terminalName,
cwd: effectiveCwd,
location: panel.resolveTerminalLocation(false),
iconPath: iconUri,
color: themeColor,
})
panel.trackSessionTerminal(terminal, issueNumber, 'prototype')
terminal.show(false)
logger.add({
level: 'info',
source: 'terminal',
message: `已创建原型会话终端 #${issueNumber} cwd=${effectiveCwd}`,
})
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, resumeSessionId: sessionId })
terminal.sendText(cmd)
}
finally {
panel.resumeInFlight.delete(lockKey)
panel.postMessage({ type: 'session/tab-settled', sessionId })
}
}
/**
* 当 done 列的 PR merge 因冲突失败时调用:在主 workspace 检出 feature 分支
* 并 merge dev 让冲突落地到 working tree,然后启一个临时 cc 会话让 cc 解决
@@ -1690,13 +1987,25 @@ export function resolveTestProfilePath(panel: KanbanWebviewPanel, testProfilePat
}
/**
* 测试会话的工作目录:worktree 还在就用 worktree,已清理则回退主 worktree。
* 原型会话 profile:工单级 `prototypeProfilePath` > 设置里的「原型默认配置文件」
* > DEFAULT。与其它三种会话互不回退。
*/
export function resolvePrototypeProfilePath(panel: KanbanWebviewPanel, prototypeProfilePath: string | undefined): string {
return resolveProfilePath(
prototypeProfilePath?.trim()
|| getSettings(panel.context).defaultPrototypeProfilePath.trim()
|| undefined,
)
}
/**
* 测试 / 原型会话的工作目录:worktree 还在就用 worktree,已清理则回退主 worktree。
*
* @param workspaceRoot 主 worktree 绝对路径
* @param worktreePathRel state JSON 里的 worktree 相对路径(可空)
* @returns 实际用作终端 cwd 的绝对路径
*/
async function resolveTestSessionCwd(workspaceRoot: string, worktreePathRel?: string): Promise<string> {
async function resolveWorktreeOrRootCwd(workspaceRoot: string, worktreePathRel?: string): Promise<string> {
if (worktreePathRel) {
const abs = resolveWorktreePath(worktreePathRel, workspaceRoot)
try {
+14 -1
View File
@@ -27,6 +27,8 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -42,6 +44,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -77,10 +80,11 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
const trimmedPreRemove = payload.worktreePreRemoveScript.trim()
const trimmedImplPre = payload.implTabPreCreateScript.trim()
const trimmedImplPost = payload.implTabPostCloseScript.trim()
// 三个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),只 trim。
// 四个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),只 trim。
const trimmedDefaultBrainstormProfile = payload.defaultBrainstormProfilePath.trim()
const trimmedDefaultImplementProfile = payload.defaultImplementProfilePath.trim()
const trimmedDefaultTestProfile = payload.defaultTestProfilePath.trim()
const trimmedDefaultPrototypeProfile = payload.defaultPrototypeProfilePath.trim()
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH).
const trimmedConflictProfile = payload.conflictResolutionProfilePath.trim()
// 提交代码 profile:'' 有意义(= 未配置 → 提交严格失败),只 trim。
@@ -113,6 +117,8 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
testPrompt: payload.testPrompt || prev.testPrompt,
prototypePrompt: payload.prototypePrompt || prev.prototypePrompt,
devBranch: trimmedDevBranch || prev.devBranch,
releaseBranchPrefix: trimmedReleasePrefix || prev.releaseBranchPrefix,
jenkinsJob: trimmedJenkinsJob,
@@ -128,6 +134,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile,
defaultImplementProfilePath: trimmedDefaultImplementProfile,
defaultTestProfilePath: trimmedDefaultTestProfile,
defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
@@ -145,6 +152,8 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
implementPlanPrompt: payload.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt,
testPrompt: payload.testPrompt,
prototypePrompt: payload.prototypePrompt,
devBranch: trimmedDevBranch,
releaseBranchPrefix: trimmedReleasePrefix,
jenkinsJob: trimmedJenkinsJob,
@@ -160,6 +169,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile,
defaultImplementProfilePath: trimmedDefaultImplementProfile,
defaultTestProfilePath: trimmedDefaultTestProfile,
defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
@@ -254,6 +264,8 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
testPrompt: s.testPrompt,
prototypePrompt: s.prototypePrompt,
devBranch: s.devBranch,
releaseBranchPrefix: s.releaseBranchPrefix,
jenkinsJob: s.jenkinsJob,
@@ -269,6 +281,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
defaultBrainstormProfilePath: s.defaultBrainstormProfilePath,
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
defaultPrototypeProfilePath: s.defaultPrototypeProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath,
prDiffSummaryProfilePath,
+27 -15
View File
@@ -9,6 +9,9 @@ import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { existingTerminalGroupColumn, terminalEditorColumn } from '../terminalColumn'
/** 一个工单能开的会话终端种类;每种对应详情面板里的一行会话 id。 */
export type SessionTabKind = 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype'
export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preserveFocus: boolean): TerminalEditorLocationOptions {
if (!panel) {
return {
@@ -155,13 +158,14 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
newIssueTerm.show(true)
return
}
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
// the convention (issue-${N}-实施 / issue-${N}-规划 / issue-${N}-审查 / issue-${N}-测试).
// Priority 1-5: 实施 > 规划 > 审查 > 测试 > 原型. Match by terminal.name since we
// know the convention (issue-${N}-实施 / -规划 / -审查 / -测试 / -原型).
const namePriority = [
`issue-${issueNumber}-实施`,
`issue-${issueNumber}-规划`,
`issue-${issueNumber}-审查`,
`issue-${issueNumber}-测试`,
`issue-${issueNumber}-原型`,
]
for (const name of namePriority) {
const term = findExistingTerminal(panel, name)
@@ -181,12 +185,12 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
* 解析 issueNumber 并通知 webview 选中对应工单。去重与回声抑制由调用方的
* `FocusedTerminalTracker` 负责,这里每次调用都视为一次真实切换。
*
* 终端名按 `issue-${N}-(规划|实施|审查|测试)` 命名;`issue-new-${nonce}-...`
* 终端名按 `issue-${N}-(规划|实施|审查|测试|原型)` 命名;`issue-new-${nonce}-...`
* 是新建工单流程的占位 tab,没有 issue number,跳过。
*/
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
// Primary: `issue-${N}-(规划|实施|审查|测试|原型)` — the steady-state naming.
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试|原型)/)
if (m) {
const issueNumber = Number.parseInt(m[1], 10)
if (Number.isFinite(issueNumber)) {
@@ -220,8 +224,8 @@ export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal:
* 按钮能找到对应 terminal。
*/
export function withLiveTerminalTabState(panel: KanbanWebviewPanel, issues: Issue[]): Issue[] {
const liveByIssue = new Map<number, Partial<Pick<Issue, 'brainstormTabOpen' | 'implementTabOpen' | 'reviewTabOpen' | 'testTabOpen'>>>()
const mark = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void => {
const liveByIssue = new Map<number, Partial<Pick<Issue, 'brainstormTabOpen' | 'implementTabOpen' | 'reviewTabOpen' | 'testTabOpen' | 'prototypeTabOpen'>>>()
const mark = (issueNumber: number, kind: SessionTabKind): void => {
const patch = liveByIssue.get(issueNumber) ?? {}
if (kind === 'brainstorm')
patch.brainstormTabOpen = true
@@ -229,8 +233,10 @@ export function withLiveTerminalTabState(panel: KanbanWebviewPanel, issues: Issu
patch.implementTabOpen = true
else if (kind === 'review')
patch.reviewTabOpen = true
else
else if (kind === 'test')
patch.testTabOpen = true
else
patch.prototypeTabOpen = true
liveByIssue.set(issueNumber, patch)
}
@@ -242,19 +248,21 @@ export function withLiveTerminalTabState(panel: KanbanWebviewPanel, issues: Issu
for (const terminal of window.terminals) {
if (terminal.exitStatus !== undefined)
continue
const match = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)(?:\s|$)/)
const match = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试|原型)(?:\s|$)/)
if (!match)
continue
const issueNumber = Number.parseInt(match[1], 10)
if (!Number.isFinite(issueNumber))
continue
const kind = match[2] === '规划'
const kind: SessionTabKind = match[2] === '规划'
? 'brainstorm'
: match[2] === '实施'
? 'implement'
: match[2] === '审查'
? 'review'
: 'test'
: match[2] === '测试'
? 'test'
: 'prototype'
panel.terminalOrigin.set(terminal, { issueNumber, kind })
mark(issueNumber, kind)
}
@@ -280,7 +288,7 @@ export function trackSessionTerminal(
panel: KanbanWebviewPanel,
terminal: Terminal,
issueNumber: number,
kind: 'brainstorm' | 'implement' | 'review' | 'test',
kind: SessionTabKind,
): void {
panel.terminalOrigin.set(terminal, { issueNumber, kind })
panel.postMessage({
@@ -293,7 +301,9 @@ export function trackSessionTerminal(
? { implementTabOpen: true }
: kind === 'review'
? { reviewTabOpen: true }
: { testTabOpen: true },
: kind === 'test'
? { testTabOpen: true }
: { prototypeTabOpen: true },
})
}
@@ -319,7 +329,9 @@ export function untrackClosedTerminal(panel: KanbanWebviewPanel, closed: Termina
? { implementTabOpen: false }
: origin.kind === 'review'
? { reviewTabOpen: false }
: { testTabOpen: false },
: origin.kind === 'test'
? { testTabOpen: false }
: { prototypeTabOpen: false },
})
}
@@ -334,7 +346,7 @@ export function untrackClosedTerminal(panel: KanbanWebviewPanel, closed: Termina
* thread_id 索引,此路径还没 thread_id),但会被登记到 terminalOrigin,
* 所以用它做单一真相源最稳。
*/
export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void {
export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: number, kind: SessionTabKind): void {
let terminal: Terminal | undefined
for (const [t, origin] of panel.terminalOrigin) {
if (origin.issueNumber === issueNumber && origin.kind === kind) {
+11 -2
View File
@@ -52,7 +52,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, 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, prototypeSessionId?: 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: 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 }
@@ -76,6 +76,8 @@ export type ExtensionToWebview
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -91,6 +93,7 @@ export type ExtensionToWebview
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -158,6 +161,8 @@ export type WebviewToExtension
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -173,6 +178,7 @@ export type WebviewToExtension
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -193,8 +199,10 @@ export type WebviewToExtension
| { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-prototype', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/start-review', issueNumber: number }
| { type: 'session/start-test', issueNumber: number }
| { type: 'session/start-prototype', issueNumber: number }
| { type: 'editor/open-file', path: string }
| { type: 'profiles/list' }
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
@@ -218,10 +226,11 @@ export type WebviewToExtension
| { type: 'issue/update-brainstorm-profile-path', issueNumber: number, brainstormProfilePath: string }
| { type: 'issue/clear-brainstorm-session', issueNumber: number }
| { type: 'issue/update-test-profile-path', issueNumber: number, testProfilePath: string }
| { type: 'issue/update-prototype-profile-path', issueNumber: number, prototypeProfilePath: string }
| { type: 'logs/fetch' }
| { type: 'logs/clear' }
| { type: 'commit/run' }
| { type: 'session/close-tab', issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' }
| { type: 'session/close-tab', issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype' }
| { type: 'release/check' }
| { type: 'release/cut' }
| { type: 'release/cherry-pick' }
+3
View File
@@ -14,8 +14,10 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = new Set([
'session/focus',
'session/resume-review',
'session/resume-test',
'session/resume-prototype',
'session/start-review',
'session/start-test',
'session/start-prototype',
'session/close-tab',
'brainstorm/continue',
'issue/implement',
@@ -29,6 +31,7 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = new Set([
'issue/update-brainstorm-profile-path',
'issue/clear-brainstorm-session',
'issue/update-test-profile-path',
'issue/update-prototype-profile-path',
'dependency/set',
'dependency/clear',
'handoff/users',
+29 -2
View File
@@ -30,9 +30,13 @@ const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumbe
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {issueNumber} 号工单({prRef})。验收目标以工单正文为准,不要再问验收哪个。`
const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{prNumber} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 <!-- spx:review=1 -->。`
type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review'
const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。`
type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review' | 'test' | 'prototype'
/**
* Read a default prompt template from `${extensionPath}/prompts/${name}.md`.
@@ -54,6 +58,8 @@ export function readDefaultPrompt(extensionPath: string, name: PromptName): stri
case 'brainstorm-continue': return FALLBACK_BRAINSTORM_CONTINUE_PROMPT
case 'implement-plan': return FALLBACK_IMPLEMENT_PLAN_PROMPT
case 'review': return FALLBACK_REVIEW_PROMPT
case 'test': return FALLBACK_TEST_PROMPT
case 'prototype': return FALLBACK_PROTOTYPE_PROMPT
}
}
}
@@ -87,6 +93,10 @@ export interface Settings {
autoReview: boolean
/** Prompt template for the auto-review flow. `{prNumber}` placeholder. */
reviewPrompt: string
/** 测试会话首个提示词模板;占位符 {issueNumber} / {prRef}。 */
testPrompt: string
/** 原型会话首个提示词模板;占位符 {issueNumber}。 */
prototypePrompt: string
/** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */
devBranch: string
/**
@@ -189,6 +199,8 @@ export interface Settings {
defaultImplementProfilePath: string
/** 测试会话的默认 Claude profile;工单级「测试配置文件」为空时用它。 */
defaultTestProfilePath: string
/** 原型会话的默认 Claude profile;工单级「原型配置文件」为空时用它。 */
defaultPrototypeProfilePath: string
/**
* Claude settings profile path for the conflict-resolution cc session
* (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH.
@@ -266,6 +278,8 @@ function defaults(ctx: ExtensionContext): Settings {
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
autoReview: true,
reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'),
testPrompt: readDefaultPrompt(ctx.extensionPath, 'test'),
prototypePrompt: readDefaultPrompt(ctx.extensionPath, 'prototype'),
devBranch: 'main',
releaseBranchPrefix: 'release/',
jenkinsJob: '',
@@ -284,6 +298,7 @@ function defaults(ctx: ExtensionContext): Settings {
defaultBrainstormProfilePath: '',
defaultImplementProfilePath: '',
defaultTestProfilePath: '',
defaultPrototypeProfilePath: '',
conflictResolutionProfilePath: '',
commitProfilePath: '',
prDiffSummaryProfilePath: '',
@@ -319,6 +334,12 @@ export function getSettings(ctx: ExtensionContext): Settings {
const reviewPrompt = typeof stored.reviewPrompt === 'string' && stored.reviewPrompt.length > 0
? stored.reviewPrompt
: base.reviewPrompt
const testPrompt = typeof stored.testPrompt === 'string' && stored.testPrompt.length > 0
? stored.testPrompt
: base.testPrompt
const prototypePrompt = typeof stored.prototypePrompt === 'string' && stored.prototypePrompt.length > 0
? stored.prototypePrompt
: base.prototypePrompt
// devBranch always has a usable value — fall back to 'main' if missing/blank
// so we never end up trying to fetch the empty string.
const devBranch = typeof stored.devBranch === 'string' && stored.devBranch.length > 0
@@ -381,7 +402,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
const youtrackCloseCommand = typeof stored.youtrackCloseCommand === 'string'
? stored.youtrackCloseCommand
: base.youtrackCloseCommand
// 三个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),不强制回退。
// 四个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),不强制回退。
const defaultBrainstormProfilePath = typeof stored.defaultBrainstormProfilePath === 'string'
? stored.defaultBrainstormProfilePath
: base.defaultBrainstormProfilePath
@@ -391,6 +412,9 @@ export function getSettings(ctx: ExtensionContext): Settings {
const defaultTestProfilePath = typeof stored.defaultTestProfilePath === 'string'
? stored.defaultTestProfilePath
: base.defaultTestProfilePath
const defaultPrototypeProfilePath = typeof stored.defaultPrototypeProfilePath === 'string'
? stored.defaultPrototypeProfilePath
: base.defaultPrototypeProfilePath
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH at
// launch time), so don't coerce. Non-string legacy values collapse to ''.
const conflictResolutionProfilePath = typeof stored.conflictResolutionProfilePath === 'string'
@@ -424,6 +448,8 @@ export function getSettings(ctx: ExtensionContext): Settings {
implementPlanPrompt,
autoReview,
reviewPrompt,
testPrompt,
prototypePrompt,
devBranch,
releaseBranchPrefix,
jenkinsJob,
@@ -442,6 +468,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
defaultBrainstormProfilePath,
defaultImplementProfilePath,
defaultTestProfilePath,
defaultPrototypeProfilePath,
conflictResolutionProfilePath,
commitProfilePath,
prDiffSummaryProfilePath,
+2
View File
@@ -68,9 +68,11 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
implementSessionId: str(state.implementSessionId),
reviewSessionId: str(state.reviewSessionId),
testSessionId: str(state.testSessionId),
prototypeSessionId: str(state.prototypeSessionId),
profilePath: str(state.profilePath),
brainstormProfilePath: str(state.brainstormProfilePath),
testProfilePath: str(state.testProfilePath),
prototypeProfilePath: str(state.prototypeProfilePath),
specFile: str(state.specFile),
planFile: str(state.planFile),
prDiffFile: str(state.prDiffFile),
+1 -1
View File
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->'
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
/**
* Parse a comment body into a state object, or null if it isn't a state
+9
View File
@@ -45,6 +45,8 @@ export function App() {
resumeReviewSession,
startTestSession,
resumeTestSession,
startPrototypeSession,
resumePrototypeSession,
focusSession,
openFile,
implement,
@@ -66,6 +68,7 @@ export function App() {
updateIssueProfilePath,
updateIssueBrainstormProfilePath,
updateIssueTestProfilePath,
updateIssuePrototypeProfilePath,
logs,
clearLogs,
pendingSelectId,
@@ -380,6 +383,8 @@ export function App() {
onResumeReviewSession={resumeReviewSession}
onStartTestSession={startTestSession}
onResumeTestSession={resumeTestSession}
onStartPrototypeSession={startPrototypeSession}
onResumePrototypeSession={resumePrototypeSession}
onOpenFile={openFile}
onImplement={implement}
onOpenPr={openPr}
@@ -398,6 +403,7 @@ export function App() {
onUpdateProfilePath={updateIssueProfilePath}
onUpdateBrainstormProfilePath={updateIssueBrainstormProfilePath}
onUpdateTestProfilePath={updateIssueTestProfilePath}
onUpdatePrototypeProfilePath={updateIssuePrototypeProfilePath}
onOpenLogs={() => setShowLogs(true)}
me={me}
avatars={avatars}
@@ -538,6 +544,8 @@ export function App() {
initialImplementPlanPrompt={settings?.implementPlanPrompt ?? ''}
initialAutoReview={settings?.autoReview ?? true}
initialReviewPrompt={settings?.reviewPrompt ?? ''}
initialTestPrompt={settings?.testPrompt ?? ''}
initialPrototypePrompt={settings?.prototypePrompt ?? ''}
initialDevBranch={settings?.devBranch ?? 'main'}
initialReleaseBranchPrefix={settings?.releaseBranchPrefix ?? 'release/'}
initialJenkinsJob={settings?.jenkinsJob ?? ''}
@@ -553,6 +561,7 @@ export function App() {
initialDefaultBrainstormProfilePath={settings?.defaultBrainstormProfilePath ?? ''}
initialDefaultImplementProfilePath={settings?.defaultImplementProfilePath ?? ''}
initialDefaultTestProfilePath={settings?.defaultTestProfilePath ?? ''}
initialDefaultPrototypeProfilePath={settings?.defaultPrototypeProfilePath ?? ''}
initialConflictResolutionProfilePath={settings?.conflictResolutionProfilePath ?? ''}
initialCommitProfilePath={settings?.commitProfilePath ?? ''}
initialPrDiffSummaryProfilePath={settings?.prDiffSummaryProfilePath ?? ''}
@@ -34,6 +34,8 @@ interface BottomTabsProps {
onStartReviewSession: (issueNumber: number) => void
onStartTestSession: (issueNumber: number) => void
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
onStartPrototypeSession: (issueNumber: number) => void
onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
onOpenFile: (path: string) => void
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
onOpenPr: (pr: string) => void
@@ -45,13 +47,14 @@ interface BottomTabsProps {
onDeleteIssue?: (issueNumber: number) => void
onCloseIssue: (issueNumber: number) => void
onResetToTodo: (issueNumber: number) => void
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype') => void
onContinueBrainstorm: (issueNumber: number) => void
onClearBrainstormSession: (issueNumber: number) => void
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
onOpenLogs: () => void
me?: string
/** Gitea login → 头像 data URI。 */
@@ -134,6 +137,8 @@ export function BottomTabs(props: BottomTabsProps) {
onStartReviewSession={props.onStartReviewSession}
onStartTestSession={props.onStartTestSession}
onResumeTestSession={props.onResumeTestSession}
onStartPrototypeSession={props.onStartPrototypeSession}
onResumePrototypeSession={props.onResumePrototypeSession}
onOpenFile={props.onOpenFile}
onImplement={props.onImplement}
onOpenPr={props.onOpenPr}
@@ -152,6 +157,7 @@ export function BottomTabs(props: BottomTabsProps) {
onUpdateProfilePath={props.onUpdateProfilePath}
onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath}
onUpdateTestProfilePath={props.onUpdateTestProfilePath}
onUpdatePrototypeProfilePath={props.onUpdatePrototypeProfilePath}
profiles={props.profiles}
onOpenLogs={props.onOpenLogs}
me={props.me}
@@ -51,12 +51,22 @@ interface IssueDetailPanelProps {
* conversation associated with this issue.
*/
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
/**
* Open a new terminal that runs `claude --resume <id>` for the prototype
* conversation associated with this issue.
*/
onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
/**
* 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).
*/
onStartReviewSession: (issueNumber: number) => void
onStartTestSession: (issueNumber: number) => void
/**
* Spawn a fresh "原型" cc tab. Bound to the Play button next to the
* "原型会话 id" row;不依赖 PR,任何阶段都能起。
*/
onStartPrototypeSession: (issueNumber: number) => void
/** Open a workspace-relative file in the editor. */
onOpenFile: (path: string) => void
/** Kick off the end-to-end implementation flow for the given plan file. */
@@ -90,7 +100,7 @@ interface IssueDetailPanelProps {
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
* makes the X button vanish on its own.
*/
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype') => void
/**
* 「▶ 续聊」:由插件把续聊提示词送进头脑风暴会话——规划 tab 开着就直接
* 打进去,关了就 `claude --resume <id>` 带着提示词恢复,没会话就新开一个。
@@ -107,6 +117,8 @@ interface IssueDetailPanelProps {
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
/** Persist a per-issue `testProfilePath` override into the state JSON. */
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
/** Persist a per-issue `prototypeProfilePath` override into the state JSON. */
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
/** Open the in-webview log modal. */
onOpenLogs: () => void
/** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */
@@ -134,8 +146,10 @@ export function IssueDetailPanel({
onResumeSession,
onResumeReviewSession,
onResumeTestSession,
onResumePrototypeSession,
onStartReviewSession,
onStartTestSession,
onStartPrototypeSession,
onOpenFile,
onImplement,
onOpenPr,
@@ -155,6 +169,7 @@ export function IssueDetailPanel({
onUpdateProfilePath,
onUpdateBrainstormProfilePath,
onUpdateTestProfilePath,
onUpdatePrototypeProfilePath,
onOpenLogs,
me,
isSessionTabPending,
@@ -402,6 +417,51 @@ export function IssueDetailPanel({
secondaryActions,
}
})(),
(() => {
// 原型会话 id 行:与测试行同构的三态,差别是启动不看 PR——原型只照
// 工单正文画界面,任何阶段都能起;前置工单没做完时按钮禁用。
const hasSession = !!(issue?.prototypeSessionId && issue.prototypeSessionId.length > 0)
const secondaryActions: RowAction[] = []
if (hasSession && issue?.prototypeTabOpen) {
secondaryActions.push({
icon: <X className="size-3.5" />,
title: '关闭原型 tab',
onClick: () => {
if (issue)
onCloseSessionTab(issue.number, 'prototype')
},
})
}
else if (!hasSession) {
secondaryActions.push({
icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '启动原型会话',
disabled: locked,
onClick: () => {
if (issue)
onStartPrototypeSession(issue.number)
},
})
}
return {
key: 'prototypeSessionId',
label: '原型会话id',
type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.prototypeSessionId!) : false,
description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复原型对话(worktree 在则用之,否则工作区根目录)'
: '点击右侧 ▶ 启动一个原型 cc tab:照工单正文画界面原型,不依赖 PR',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0 || !issue)
return
if (!throttleBySessionId(v))
return
onResumePrototypeSession(v, issue.number)
},
secondaryActions,
}
})(),
{
key: 'autoReview',
label: '自动审查',
@@ -458,6 +518,19 @@ export function IssueDetailPanel({
})(),
description: '测试会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/实施互不回退',
},
{
key: 'prototypeProfilePath',
label: '原型配置文件',
type: 'select',
options: (() => {
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
const current = matchProfileOption(issue?.prototypeProfilePath, profiles)
if (current && !opts.some(o => o.value === current))
opts.push({ label: `自定义:${current}`, value: current })
return opts
})(),
description: '原型会话使用的 Claude 配置文件;选「(默认)」用内置默认,与其它会话互不回退',
},
{
key: 'specFile',
label: '规格文件',
@@ -568,7 +641,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, isSessionTabPending, onStartReviewSession],
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onResumePrototypeSession, onStartPrototypeSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles, isSessionTabPending, onStartReviewSession],
)
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
@@ -588,12 +661,14 @@ export function IssueDetailPanel({
implementSessionId: issue.implementSessionId ?? null,
reviewSessionId: issue.reviewSessionId ?? null,
testSessionId: issue.testSessionId ?? null,
prototypeSessionId: issue.prototypeSessionId ?? null,
autoReview: issue.autoReview ?? globalAutoReview,
color: issue.color ?? null,
// 展示/选中值按 basename 映射到本机 profiles,避免跨机绝对路径变成「自定义」项。
brainstormProfilePath: matchProfileOption(issue.brainstormProfilePath, profiles) || null,
profilePath: matchProfileOption(issue.profilePath, profiles) || null,
testProfilePath: matchProfileOption(issue.testProfilePath, profiles) || null,
prototypeProfilePath: matchProfileOption(issue.prototypeProfilePath, profiles) || null,
specFile: issue.specFile ?? null,
planFile: issue.planFile ?? null,
prDiffFile: issue.prDiffFile ?? null,
@@ -788,6 +863,8 @@ export function IssueDetailPanel({
onUpdateProfilePath(issue.number, value)
if (key === 'testProfilePath' && issue && typeof value === 'string')
onUpdateTestProfilePath(issue.number, value)
if (key === 'prototypeProfilePath' && issue && typeof value === 'string')
onUpdatePrototypeProfilePath(issue.number, value)
}}
/>
</div>
@@ -25,6 +25,8 @@ interface SubmitValues {
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -40,6 +42,7 @@ interface SubmitValues {
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -78,6 +81,8 @@ export interface SettingsModalProps {
initialImplementPlanPrompt: string
initialAutoReview: boolean
initialReviewPrompt: string
initialTestPrompt: string
initialPrototypePrompt: string
initialDevBranch: string
initialReleaseBranchPrefix: string
initialJenkinsJob: string
@@ -93,6 +98,7 @@ export interface SettingsModalProps {
initialDefaultBrainstormProfilePath: string
initialDefaultImplementProfilePath: string
initialDefaultTestProfilePath: string
initialDefaultPrototypeProfilePath: string
initialConflictResolutionProfilePath: string
initialCommitProfilePath: string
initialPrDiffSummaryProfilePath: string
@@ -167,6 +173,8 @@ export function SettingsModal({
initialImplementPlanPrompt,
initialAutoReview,
initialReviewPrompt,
initialTestPrompt,
initialPrototypePrompt,
initialDevBranch,
initialReleaseBranchPrefix,
initialJenkinsJob,
@@ -182,6 +190,7 @@ export function SettingsModal({
initialDefaultBrainstormProfilePath,
initialDefaultImplementProfilePath,
initialDefaultTestProfilePath,
initialDefaultPrototypeProfilePath,
initialConflictResolutionProfilePath,
initialCommitProfilePath,
initialPrDiffSummaryProfilePath,
@@ -208,6 +217,8 @@ export function SettingsModal({
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
const [autoReview, setAutoReview] = useState(initialAutoReview)
const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt)
const [testPrompt, setTestPrompt] = useState(initialTestPrompt)
const [prototypePrompt, setPrototypePrompt] = useState(initialPrototypePrompt)
const [devBranch, setDevBranch] = useState(initialDevBranch)
const [releaseBranchPrefix, setReleaseBranchPrefix] = useState(initialReleaseBranchPrefix)
const [jenkinsJob, setJenkinsJob] = useState(initialJenkinsJob)
@@ -223,6 +234,7 @@ export function SettingsModal({
const [defaultBrainstormProfilePath, setDefaultBrainstormProfilePath] = useState(initialDefaultBrainstormProfilePath)
const [defaultImplementProfilePath, setDefaultImplementProfilePath] = useState(initialDefaultImplementProfilePath)
const [defaultTestProfilePath, setDefaultTestProfilePath] = useState(initialDefaultTestProfilePath)
const [defaultPrototypeProfilePath, setDefaultPrototypeProfilePath] = useState(initialDefaultPrototypeProfilePath)
const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath)
const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath)
const [prDiffSummaryProfilePath, setPrDiffSummaryProfilePath] = useState(initialPrDiffSummaryProfilePath)
@@ -251,6 +263,8 @@ export function SettingsModal({
setImplementPlanPrompt(initialImplementPlanPrompt)
setAutoReview(initialAutoReview)
setReviewPrompt(initialReviewPrompt)
setTestPrompt(initialTestPrompt)
setPrototypePrompt(initialPrototypePrompt)
setDevBranch(initialDevBranch)
setReleaseBranchPrefix(initialReleaseBranchPrefix)
setJenkinsJob(initialJenkinsJob)
@@ -266,6 +280,7 @@ export function SettingsModal({
setDefaultBrainstormProfilePath(initialDefaultBrainstormProfilePath)
setDefaultImplementProfilePath(initialDefaultImplementProfilePath)
setDefaultTestProfilePath(initialDefaultTestProfilePath)
setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath)
setConflictResolutionProfilePath(initialConflictResolutionProfilePath)
setCommitProfilePath(initialCommitProfilePath)
setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath)
@@ -342,6 +357,8 @@ export function SettingsModal({
implementPlanPrompt,
autoReview,
reviewPrompt,
testPrompt,
prototypePrompt,
devBranch: devBranch.trim(),
releaseBranchPrefix: releaseBranchPrefix.trim(),
jenkinsJob: jenkinsJob.trim(),
@@ -357,10 +374,11 @@ export function SettingsModal({
implTabPreCreateScript: implTabPreCreateScript.trim(),
implTabPostCloseScript: implTabPostCloseScript.trim(),
splitNewTerminals,
// 三个会话默认 profile:空串 = 落到 DEFAULT_PROFILE_PATH
// 四个会话默认 profile:空串 = 落到 DEFAULT_PROFILE_PATH
defaultBrainstormProfilePath: defaultBrainstormProfilePath.trim(),
defaultImplementProfilePath: defaultImplementProfilePath.trim(),
defaultTestProfilePath: defaultTestProfilePath.trim(),
defaultPrototypeProfilePath: defaultPrototypeProfilePath.trim(),
// 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH
conflictResolutionProfilePath: conflictResolutionProfilePath.trim(),
// 提交代码 profile:空串 = 未配置 → 提交严格失败
@@ -619,6 +637,30 @@ export function SettingsModal({
/>
</Field>
<Field
label="原型默认配置文件"
hint={(
<>
原型会话默认用此 profile;工单详情面板里的「原型配置文件」优先级更高。留空用 official。
</>
)}
>
<SelectMenu
value={defaultPrototypeProfilePath}
onChange={setDefaultPrototypeProfilePath}
variant="input"
className={inputClass}
options={[
{ value: '', label: '(默认)' },
...profiles.map(p => ({ value: p.path, label: p.name })),
...(defaultPrototypeProfilePath
&& !profiles.some(p => p.path === defaultPrototypeProfilePath)
? [{ value: defaultPrototypeProfilePath, label: `自定义:${defaultPrototypeProfilePath}` }]
: []),
]}
/>
</Field>
<Field
label="冲突解决配置文件"
hint={(
@@ -1002,6 +1044,43 @@ export function SettingsModal({
className={`${inputClass} resize-y font-mono`}
/>
</Field>
<Field
label="测试提示词"
hint={(
<>
可用占位符:
<code>{'{issueNumber}'}</code>
、
<code>{'{prRef}'}</code>
(worktree 里是分支,主仓库里是已合并的 PR)
</>
)}
>
<textarea
rows={4}
value={testPrompt}
onChange={e => setTestPrompt(e.target.value)}
className={`${inputClass} resize-y font-mono`}
/>
</Field>
<Field
label="原型提示词"
hint={(
<>
可用占位符:
<code>{'{issueNumber}'}</code>
</>
)}
>
<textarea
rows={4}
value={prototypePrompt}
onChange={e => setPrototypePrompt(e.target.value)}
className={`${inputClass} resize-y font-mono`}
/>
</Field>
</>
)}
+45 -3
View File
@@ -43,6 +43,8 @@ export interface SettingsValues {
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -58,6 +60,7 @@ export interface SettingsValues {
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -82,6 +85,8 @@ export interface SettingsOverlayState {
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -97,6 +102,7 @@ export interface SettingsOverlayState {
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -190,6 +196,8 @@ export interface UseIssuesResult {
startReviewSession: (issueNumber: number) => void
startTestSession: (issueNumber: number) => void
resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
startPrototypeSession: (issueNumber: number) => void
resumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
focusSession: (issueNumber: number) => void
openFile: (path: string) => void
implement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
@@ -211,7 +219,7 @@ export interface UseIssuesResult {
* Dispose the matching session terminal tab. Extension reacts via
* onDidCloseTerminal and clears `*TabOpen` so the X button disappears.
*/
closeSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
closeSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype') => void
/**
* Spawn a fresh "规划" cc tab for an existing issue whose sessionId is
* still empty. Extension 用工单级 brainstormProfilePath(空 = 默认),
@@ -240,6 +248,11 @@ export interface UseIssuesResult {
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
*/
updateIssueTestProfilePath: (issueNumber: number, testProfilePath: string) => void
/**
* 覆盖该工单原型会话专用的 Claude 配置文件,乐观更新本地 state 后
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
*/
updateIssuePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
logs: LogEntry[]
fetchLogs: () => void
clearLogs: () => void
@@ -421,6 +434,8 @@ export function useIssues(): UseIssuesResult {
implementPlanPrompt: values.implementPlanPrompt,
autoReview: values.autoReview,
reviewPrompt: values.reviewPrompt,
testPrompt: values.testPrompt,
prototypePrompt: values.prototypePrompt,
devBranch: values.devBranch,
releaseBranchPrefix: values.releaseBranchPrefix,
jenkinsJob: values.jenkinsJob,
@@ -436,6 +451,7 @@ export function useIssues(): UseIssuesResult {
defaultBrainstormProfilePath: values.defaultBrainstormProfilePath,
defaultImplementProfilePath: values.defaultImplementProfilePath,
defaultTestProfilePath: values.defaultTestProfilePath,
defaultPrototypeProfilePath: values.defaultPrototypeProfilePath,
conflictResolutionProfilePath: values.conflictResolutionProfilePath,
commitProfilePath: values.commitProfilePath,
prDiffSummaryProfilePath: values.prDiffSummaryProfilePath,
@@ -559,6 +575,15 @@ export function useIssues(): UseIssuesResult {
postMessage({ type: 'session/resume-test', sessionId, issueNumber, cwd })
}, [markSessionTabPending])
const startPrototypeSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/start-prototype', issueNumber })
}, [])
const resumePrototypeSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume-prototype', sessionId, issueNumber, cwd })
}, [markSessionTabPending])
const openFile = useCallback((path: string): void => {
postMessage({ type: 'editor/open-file', path })
}, [])
@@ -638,7 +663,7 @@ export function useIssues(): UseIssuesResult {
}, [])
const closeSessionTab = useCallback(
(issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void => {
(issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype'): void => {
postMessage({ type: 'session/close-tab', issueNumber, kind })
},
[],
@@ -731,6 +756,20 @@ export function useIssues(): UseIssuesResult {
postMessage({ type: 'issue/update-test-profile-path', issueNumber, testProfilePath })
}, [])
const updateIssuePrototypeProfilePath = useCallback((issueNumber: number, prototypeProfilePath: string): void => {
setState((prev) => {
if (prev.status !== 'ready')
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === issueNumber ? { ...i, prototypeProfilePath } : i,
),
}
})
postMessage({ type: 'issue/update-prototype-profile-path', issueNumber, prototypeProfilePath })
}, [])
const fetchLogs = useCallback((): void => {
postMessage({ type: 'logs/fetch' })
}, [])
@@ -886,6 +925,8 @@ export function useIssues(): UseIssuesResult {
implementPlanPrompt: msg.implementPlanPrompt,
autoReview: msg.autoReview,
reviewPrompt: msg.reviewPrompt,
testPrompt: msg.testPrompt,
prototypePrompt: msg.prototypePrompt,
devBranch: msg.devBranch,
releaseBranchPrefix: msg.releaseBranchPrefix,
jenkinsJob: msg.jenkinsJob,
@@ -901,6 +942,7 @@ export function useIssues(): UseIssuesResult {
defaultBrainstormProfilePath: msg.defaultBrainstormProfilePath,
defaultImplementProfilePath: msg.defaultImplementProfilePath,
defaultTestProfilePath: msg.defaultTestProfilePath,
defaultPrototypeProfilePath: msg.defaultPrototypeProfilePath,
conflictResolutionProfilePath: msg.conflictResolutionProfilePath,
commitProfilePath: msg.commitProfilePath,
prDiffSummaryProfilePath: msg.prDiffSummaryProfilePath,
@@ -995,5 +1037,5 @@ export function useIssues(): UseIssuesResult {
return cleanup
}, [clearPrDiffSummaryRunning, markHandoffRunning])
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 }
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearBrainstormSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
}
+11 -2
View File
@@ -83,7 +83,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, 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, prototypeSessionId?: 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, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: 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 }
@@ -107,6 +107,8 @@ export type ExtensionToWebview
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -122,6 +124,7 @@ export type ExtensionToWebview
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -189,6 +192,8 @@ export type WebviewToExtension
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
@@ -204,6 +209,7 @@ export type WebviewToExtension
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
@@ -224,8 +230,10 @@ export type WebviewToExtension
| { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-prototype', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/start-review', issueNumber: number }
| { type: 'session/start-test', issueNumber: number }
| { type: 'session/start-prototype', issueNumber: number }
| { type: 'editor/open-file', path: string }
| { type: 'profiles/list' }
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
@@ -249,10 +257,11 @@ export type WebviewToExtension
| { type: 'issue/update-brainstorm-profile-path', issueNumber: number, brainstormProfilePath: string }
| { type: 'issue/clear-brainstorm-session', issueNumber: number }
| { type: 'issue/update-test-profile-path', issueNumber: number, testProfilePath: string }
| { type: 'issue/update-prototype-profile-path', issueNumber: number, prototypeProfilePath: string }
| { type: 'logs/fetch' }
| { type: 'logs/clear' }
| { type: 'commit/run' }
| { type: 'session/close-tab', issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' }
| { type: 'session/close-tab', issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype' }
| { type: 'release/check' }
| { type: 'release/cut' }
| { type: 'release/cherry-pick' }
+10 -2
View File
@@ -26,6 +26,8 @@ export interface Issue {
brainstormProfilePath?: string
/** 测试会话专用的 Claude 配置文件;空 = 默认,不回退其它字段。 */
testProfilePath?: string
/** 原型会话专用的 Claude 配置文件;空 = 默认,不回退其它字段。 */
prototypeProfilePath?: string
/**
* Optional workspace-relative path to the spec file for this issue, as
* surfaced from the Claude session transcript. Lives under
@@ -71,6 +73,11 @@ export interface Issue {
* the PR is merged, resumable from the detail panel.
*/
testSessionId?: string
/**
* Claude Code session id of the prototype conversation; 与 PR 无关,
* 任何阶段都能从详情面板起,起完可 resume。
*/
prototypeSessionId?: string
htmlUrl: string
/** 前置任务 issue number (Gitea dependencies 取第一个) */
prerequisite?: number
@@ -90,13 +97,14 @@ export interface Issue {
/** 发起移交的 Gitea login。 */
handoffFrom?: string
/**
* 三类会话 tab 是否当前在编辑器分组里打开(由 extension 通过 issue/patch 同步)。
* 仅用于详情面板控制是否在三行会话 id 右侧显示 "关闭 tab" 按钮,不持久化到 state JSON。
* 各类会话 tab 是否当前在编辑器分组里打开(由 extension 通过 issue/patch 同步)。
* 仅用于详情面板控制是否在会话 id 行右侧显示 "关闭 tab" 按钮,不持久化到 state JSON。
*/
brainstormTabOpen?: boolean
implementTabOpen?: boolean
reviewTabOpen?: boolean
testTabOpen?: boolean
prototypeTabOpen?: boolean
}
export const COLUMN_ORDER: IssueColumn[] = ['todo', 'in-progress', 'review', 'done']