From b62e58d739bf601aaba9bf3f81934ab0867cb51a Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 14 Sep 2026 10:20:30 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=AC=AC=E4=BA=94=E7=A7=8D=E5=B7=A5=E5=8D=95=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E3=80=8C=E5=8E=9F=E5=9E=8B=E4=BC=9A=E8=AF=9D=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照测试会话那条链路补一份 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 --- vscode/prompts/prototype.md | 1 + vscode/src/cc/handoffManifest.ts | 13 +- vscode/src/cc/prompts.ts | 8 + vscode/src/gitea/issueLoader.ts | 16 + vscode/src/gitea/stateJson.ts | 2 +- vscode/src/gitea/types.ts | 9 + vscode/src/issues/localState.ts | 2 + vscode/src/panel/KanbanPanel.ts | 23 +- vscode/src/panel/handlers/handoff.test.ts | 10 +- vscode/src/panel/handlers/handoff.ts | 2 + vscode/src/panel/handlers/handoffFlow.ts | 2 + vscode/src/panel/handlers/issues.ts | 98 +++++- vscode/src/panel/handlers/sessions.ts | 313 +++++++++++++++++- vscode/src/panel/handlers/settings.ts | 11 +- vscode/src/panel/handlers/terminals.ts | 42 ++- vscode/src/panel/messages.ts | 11 +- vscode/src/panel/readOnlyGuard.ts | 3 + vscode/src/settings/store.ts | 21 +- vscode/src/youtrack/issueLoader.ts | 2 + vscode/src/youtrack/stateComment.ts | 2 +- vscode/webview-ui/src/App.tsx | 8 + .../webview-ui/src/components/BottomTabs.tsx | 8 +- .../src/components/IssueDetailPanel.tsx | 81 ++++- .../src/components/SettingsModal.tsx | 55 ++- vscode/webview-ui/src/hooks/useIssues.ts | 44 ++- vscode/webview-ui/src/lib/messages.ts | 11 +- vscode/webview-ui/src/types.ts | 12 +- 27 files changed, 760 insertions(+), 50 deletions(-) create mode 100644 vscode/prompts/prototype.md diff --git a/vscode/prompts/prototype.md b/vscode/prompts/prototype.md new file mode 100644 index 0000000..e97a422 --- /dev/null +++ b/vscode/prompts/prototype.md @@ -0,0 +1 @@ +/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。 diff --git a/vscode/src/cc/handoffManifest.ts b/vscode/src/cc/handoffManifest.ts index 3399646..45b904f 100644 --- a/vscode/src/cc/handoffManifest.ts +++ b/vscode/src/cc/handoffManifest.ts @@ -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 = new Set(['brainstorm', 'implement', 'test']) +const KINDS: ReadonlySet = new Set(['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 diff --git a/vscode/src/cc/prompts.ts b/vscode/src/cc/prompts.ts index 345fbc8..54e082f 100644 --- a/vscode/src/cc/prompts.ts +++ b/vscode/src/cc/prompts.ts @@ -59,6 +59,14 @@ export function getTestPrompt(ctx: ExtensionContext, vars: { issueNumber: number .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) diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index d654983..09b7b8f 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -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 } : {}), diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index f5194d1..a85ab55 100644 --- a/vscode/src/gitea/stateJson.ts +++ b/vscode/src/gitea/stateJson.ts @@ -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。 diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index 3ab04c8..4661d6a 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -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 } diff --git a/vscode/src/issues/localState.ts b/vscode/src/issues/localState.ts index 994b098..de56dc6 100644 --- a/vscode/src/issues/localState.ts +++ b/vscode/src/issues/localState.ts @@ -17,9 +17,11 @@ export const LOCAL_STATE_FIELDS = [ 'implementSessionId', 'reviewSessionId', 'testSessionId', + 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', + 'prototypeProfilePath', 'worktreePath', 'prDiffFile', ] as const diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 6d41ab7..5be552e 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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() + readonly terminalOrigin = new Map() /** * 防 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 @@ -989,6 +1002,7 @@ export class KanbanWebviewPanel { autoReview: s.autoReview, reviewPrompt: s.reviewPrompt, testPrompt: s.testPrompt, + prototypePrompt: s.prototypePrompt, devBranch: s.devBranch, releaseBranchPrefix: s.releaseBranchPrefix, jenkinsJob: s.jenkinsJob, @@ -1004,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), @@ -1078,6 +1093,7 @@ export class KanbanWebviewPanel { autoReview: s.autoReview, reviewPrompt: s.reviewPrompt, testPrompt: s.testPrompt, + prototypePrompt: s.prototypePrompt, devBranch: s.devBranch, releaseBranchPrefix: s.releaseBranchPrefix, jenkinsJob: s.jenkinsJob, @@ -1093,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), @@ -1202,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) } diff --git a/vscode/src/panel/handlers/handoff.test.ts b/vscode/src/panel/handlers/handoff.test.ts index 2c95577..a5f6976 100644 --- a/vscode/src/panel/handlers/handoff.test.ts +++ b/vscode/src/panel/handlers/handoff.test.ts @@ -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, }) }) diff --git a/vscode/src/panel/handlers/handoff.ts b/vscode/src/panel/handlers/handoff.ts index a485757..1c14339 100644 --- a/vscode/src/panel/handlers/handoff.ts +++ b/vscode/src/panel/handlers/handoff.ts @@ -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 }), diff --git a/vscode/src/panel/handlers/handoffFlow.ts b/vscode/src/panel/handlers/handoffFlow.ts index 2062148..f1e949b 100644 --- a/vscode/src/panel/handlers/handoffFlow.ts +++ b/vscode/src/panel/handlers/handoffFlow.ts @@ -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 时 diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index efacfdf..520c73a 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -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 { + 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 { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 8e1e51e..0f9f4d5 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -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, getTestPrompt } 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; @@ -1368,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({ @@ -1439,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 { + 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 { + 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 解决 @@ -1696,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 { +async function resolveWorktreeOrRootCwd(workspaceRoot: string, worktreePathRel?: string): Promise { if (worktreePathRel) { const abs = resolveWorktreePath(worktreePathRel, workspaceRoot) try { diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index c94c520..3622293 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -28,6 +28,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { autoReview: boolean reviewPrompt: string testPrompt: string + prototypePrompt: string devBranch: string releaseBranchPrefix: string jenkinsJob: string @@ -43,6 +44,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { defaultBrainstormProfilePath: string defaultImplementProfilePath: string defaultTestProfilePath: string + defaultPrototypeProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -78,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。 @@ -115,6 +118,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { 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, @@ -130,6 +134,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile, defaultImplementProfilePath: trimmedDefaultImplementProfile, defaultTestProfilePath: trimmedDefaultTestProfile, + defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile, conflictResolutionProfilePath: trimmedConflictProfile, commitProfilePath: trimmedCommitProfile, prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile, @@ -148,6 +153,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { autoReview: payload.autoReview, reviewPrompt: payload.reviewPrompt, testPrompt: payload.testPrompt, + prototypePrompt: payload.prototypePrompt, devBranch: trimmedDevBranch, releaseBranchPrefix: trimmedReleasePrefix, jenkinsJob: trimmedJenkinsJob, @@ -163,6 +169,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile, defaultImplementProfilePath: trimmedDefaultImplementProfile, defaultTestProfilePath: trimmedDefaultTestProfile, + defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile, conflictResolutionProfilePath: trimmedConflictProfile, commitProfilePath: trimmedCommitProfile, prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile, @@ -258,6 +265,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom autoReview: s.autoReview, reviewPrompt: s.reviewPrompt, testPrompt: s.testPrompt, + prototypePrompt: s.prototypePrompt, devBranch: s.devBranch, releaseBranchPrefix: s.releaseBranchPrefix, jenkinsJob: s.jenkinsJob, @@ -273,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, diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 9e9b23a..056e0d8 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -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>>() - const mark = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void => { + const liveByIssue = new Map>>() + 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) { diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 2f188f3..2725d36 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 } | { 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 } @@ -77,6 +77,7 @@ export type ExtensionToWebview autoReview: boolean reviewPrompt: string testPrompt: string + prototypePrompt: string devBranch: string releaseBranchPrefix: string jenkinsJob: string @@ -92,6 +93,7 @@ export type ExtensionToWebview defaultBrainstormProfilePath: string defaultImplementProfilePath: string defaultTestProfilePath: string + defaultPrototypeProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -160,6 +162,7 @@ export type WebviewToExtension autoReview: boolean reviewPrompt: string testPrompt: string + prototypePrompt: string devBranch: string releaseBranchPrefix: string jenkinsJob: string @@ -175,6 +178,7 @@ export type WebviewToExtension defaultBrainstormProfilePath: string defaultImplementProfilePath: string defaultTestProfilePath: string + defaultPrototypeProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -195,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 } @@ -220,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' } diff --git a/vscode/src/panel/readOnlyGuard.ts b/vscode/src/panel/readOnlyGuard.ts index d728b65..2aa3a2e 100644 --- a/vscode/src/panel/readOnlyGuard.ts +++ b/vscode/src/panel/readOnlyGuard.ts @@ -14,8 +14,10 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet = 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 = 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', diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index 90bf858..2c6141c 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -34,7 +34,9 @@ const FALLBACK_TEST_PROMPT = `/pr-acceptance-testing 验收 {issueNumber} 号工 const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{prNumber} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 。` -type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review' | 'test' +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`. @@ -57,6 +59,7 @@ export function readDefaultPrompt(extensionPath: string, name: PromptName): stri 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 } } } @@ -92,6 +95,8 @@ export interface Settings { reviewPrompt: string /** 测试会话首个提示词模板;占位符 {issueNumber} / {prRef}。 */ testPrompt: string + /** 原型会话首个提示词模板;占位符 {issueNumber}。 */ + prototypePrompt: string /** Day-to-day development branch (e.g. `main`). Jenkins doesn't watch it. */ devBranch: string /** @@ -194,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. @@ -272,6 +279,7 @@ function defaults(ctx: ExtensionContext): Settings { autoReview: true, reviewPrompt: readDefaultPrompt(ctx.extensionPath, 'review'), testPrompt: readDefaultPrompt(ctx.extensionPath, 'test'), + prototypePrompt: readDefaultPrompt(ctx.extensionPath, 'prototype'), devBranch: 'main', releaseBranchPrefix: 'release/', jenkinsJob: '', @@ -290,6 +298,7 @@ function defaults(ctx: ExtensionContext): Settings { defaultBrainstormProfilePath: '', defaultImplementProfilePath: '', defaultTestProfilePath: '', + defaultPrototypeProfilePath: '', conflictResolutionProfilePath: '', commitProfilePath: '', prDiffSummaryProfilePath: '', @@ -328,6 +337,9 @@ export function getSettings(ctx: ExtensionContext): Settings { 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 @@ -390,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 @@ -400,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' @@ -434,6 +449,7 @@ export function getSettings(ctx: ExtensionContext): Settings { autoReview, reviewPrompt, testPrompt, + prototypePrompt, devBranch, releaseBranchPrefix, jenkinsJob, @@ -452,6 +468,7 @@ export function getSettings(ctx: ExtensionContext): Settings { defaultBrainstormProfilePath, defaultImplementProfilePath, defaultTestProfilePath, + defaultPrototypeProfilePath, conflictResolutionProfilePath, commitProfilePath, prDiffSummaryProfilePath, diff --git a/vscode/src/youtrack/issueLoader.ts b/vscode/src/youtrack/issueLoader.ts index 5fd6699..aac719c 100644 --- a/vscode/src/youtrack/issueLoader.ts +++ b/vscode/src/youtrack/issueLoader.ts @@ -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), diff --git a/vscode/src/youtrack/stateComment.ts b/vscode/src/youtrack/stateComment.ts index cf3ba04..63a180a 100644 --- a/vscode/src/youtrack/stateComment.ts +++ b/vscode/src/youtrack/stateComment.ts @@ -20,7 +20,7 @@ import { addComment, listComments } from './api' export const STATE_MARKER = '' /** 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 diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 1cdc745..84ba0c1 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -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} @@ -539,6 +545,7 @@ export function App() { initialAutoReview={settings?.autoReview ?? true} initialReviewPrompt={settings?.reviewPrompt ?? ''} initialTestPrompt={settings?.testPrompt ?? ''} + initialPrototypePrompt={settings?.prototypePrompt ?? ''} initialDevBranch={settings?.devBranch ?? 'main'} initialReleaseBranchPrefix={settings?.releaseBranchPrefix ?? 'release/'} initialJenkinsJob={settings?.jenkinsJob ?? ''} @@ -554,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 ?? ''} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index f045c45..198a4e1 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -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} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 2e73ff6..cace5a9 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -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 ` 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 ` 带着提示词恢复,没会话就新开一个。 @@ -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: , + title: '关闭原型 tab', + onClick: () => { + if (issue) + onCloseSessionTab(issue.number, 'prototype') + }, + }) + } + else if (!hasSession) { + secondaryActions.push({ + icon: , + 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 恢复原型对话(worktree 在则用之,否则工作区根目录)' + : '点击右侧 ▶ 启动一个原型 cc tab:照工单正文画界面原型,不依赖 PR', + actionIcon: , + 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) }} /> diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 24d5c78..522c2c3 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -26,6 +26,7 @@ interface SubmitValues { autoReview: boolean reviewPrompt: string testPrompt: string + prototypePrompt: string devBranch: string releaseBranchPrefix: string jenkinsJob: string @@ -41,6 +42,7 @@ interface SubmitValues { defaultBrainstormProfilePath: string defaultImplementProfilePath: string defaultTestProfilePath: string + defaultPrototypeProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -80,6 +82,7 @@ export interface SettingsModalProps { initialAutoReview: boolean initialReviewPrompt: string initialTestPrompt: string + initialPrototypePrompt: string initialDevBranch: string initialReleaseBranchPrefix: string initialJenkinsJob: string @@ -95,6 +98,7 @@ export interface SettingsModalProps { initialDefaultBrainstormProfilePath: string initialDefaultImplementProfilePath: string initialDefaultTestProfilePath: string + initialDefaultPrototypeProfilePath: string initialConflictResolutionProfilePath: string initialCommitProfilePath: string initialPrDiffSummaryProfilePath: string @@ -170,6 +174,7 @@ export function SettingsModal({ initialAutoReview, initialReviewPrompt, initialTestPrompt, + initialPrototypePrompt, initialDevBranch, initialReleaseBranchPrefix, initialJenkinsJob, @@ -185,6 +190,7 @@ export function SettingsModal({ initialDefaultBrainstormProfilePath, initialDefaultImplementProfilePath, initialDefaultTestProfilePath, + initialDefaultPrototypeProfilePath, initialConflictResolutionProfilePath, initialCommitProfilePath, initialPrDiffSummaryProfilePath, @@ -212,6 +218,7 @@ export function SettingsModal({ 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) @@ -227,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) @@ -256,6 +264,7 @@ export function SettingsModal({ setAutoReview(initialAutoReview) setReviewPrompt(initialReviewPrompt) setTestPrompt(initialTestPrompt) + setPrototypePrompt(initialPrototypePrompt) setDevBranch(initialDevBranch) setReleaseBranchPrefix(initialReleaseBranchPrefix) setJenkinsJob(initialJenkinsJob) @@ -271,6 +280,7 @@ export function SettingsModal({ setDefaultBrainstormProfilePath(initialDefaultBrainstormProfilePath) setDefaultImplementProfilePath(initialDefaultImplementProfilePath) setDefaultTestProfilePath(initialDefaultTestProfilePath) + setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath) setConflictResolutionProfilePath(initialConflictResolutionProfilePath) setCommitProfilePath(initialCommitProfilePath) setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath) @@ -348,6 +358,7 @@ export function SettingsModal({ autoReview, reviewPrompt, testPrompt, + prototypePrompt, devBranch: devBranch.trim(), releaseBranchPrefix: releaseBranchPrefix.trim(), jenkinsJob: jenkinsJob.trim(), @@ -363,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:空串 = 未配置 → 提交严格失败 @@ -625,6 +637,30 @@ export function SettingsModal({ /> + + 原型会话默认用此 profile;工单详情面板里的「原型配置文件」优先级更高。留空用 official。 + + )} + > + ({ value: p.path, label: p.name })), + ...(defaultPrototypeProfilePath + && !profiles.some(p => p.path === defaultPrototypeProfilePath) + ? [{ value: defaultPrototypeProfilePath, label: `自定义:${defaultPrototypeProfilePath}` }] + : []), + ]} + /> + + + + + 可用占位符: + {'{issueNumber}'} + + )} + > +