✨ feat(vscode): 工单挂原型插件,详情面板一键导入 Figma
- prototypeFile 走通 state → Issue → webview 全链路(gitea/youtrack 两个
loader、两份 KNOWN_STATE_FIELDS、issue/patch、webview types),按 planFile
同款当共享字段;路径校验换成 .json 版,允许绝对路径
- 详情面板「计划文件」下加「原型插件路径」行:双击开 manifest.json,
行尾按钮「在 Figma 中导入并打开」;没挂插件时只显示空态
- src/figma/:把 manifest 写进 Figma 桌面版的 localFileExtensions 再拉起
Figma。坑是 Figma 退出会用内存状态覆盖 settings.json,所以写之前先
osascript 优雅退出并轮询等它真没了(10s 等不到就让用户手动关,不强杀);
写前备份 settings.json.bak-<时间戳>;已导入过就跳过写入直接打开;
非 macOS 只把路径复制到剪贴板
- webhook 里 spec/plan 的 marker 同步改成表驱动,顺带认第三种 prototype
- 提示词:prototype.md 要求画完挂 marker;brainstorm-continue.md 新增
{prototypeRef} 占位符,有原型插件时让续聊先读它别从零重画,没有时
连同整段抹掉
Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
This commit is contained in:
1 parent
def5f4e88c
commit
142af57d9d
24 files changed
+628
-85
No files matched your search
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
用 tea 命令找工单,用 `ecc:grilling` 技能讨论细节,用表单形式和我交互,每次只问一个问题(成组的问题也拆成一次一问)。
|
用 tea 命令找工单,用 `ecc:grilling` 技能讨论细节,用表单形式和我交互,每次只问一个问题(成组的问题也拆成一次一问)。
|
||||||
|
|
||||||
|
{prototypeRef}
|
||||||
|
|
||||||
## 产物与顺序
|
## 产物与顺序
|
||||||
|
|
||||||
1. 需求讨论到共识后,用 Skill 工具调 `ecc:spec` 写设计文档到 `.claude/specs/<stem>.md`,让我确认。
|
1. 需求讨论到共识后,用 Skill 工具调 `ecc:spec` 写设计文档到 `.claude/specs/<stem>.md`,让我确认。
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||||
|
|
||||||
|
画完用 `opencli spx issue marker --issue {issueNumber} --type prototype --value <manifest.json 的仓库相对路径>` 把原型插件挂到工单上,再汇报。
|
||||||
@@ -29,12 +29,27 @@ export function getBrainstormPrompt(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrainstormContinuePrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string }): string {
|
export function getBrainstormContinuePrompt(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
vars: { issueNumber: number, issueRef?: string, prototypeFile?: string },
|
||||||
|
): string {
|
||||||
const tpl = getSettings(ctx).brainstormContinuePrompt
|
const tpl = getSettings(ctx).brainstormContinuePrompt
|
||||||
// For youtrack cards the board number is synthetic; substitute the readable
|
// For youtrack cards the board number is synthetic; substitute the readable
|
||||||
// id (e.g. LXF-12) so the cc agent references the real issue (readable via
|
// id (e.g. LXF-12) so the cc agent references the real issue (readable via
|
||||||
// `opencli yt issues show <id>`), not a non-existent gitea number.
|
// `opencli yt issues show <id>`), not a non-existent gitea number.
|
||||||
return tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
|
const out = tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
|
||||||
|
return fillPrototypeRef(out, vars.prototypeFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{prototypeRef}` 在模板里独占一段:有原型插件就填一段「先读它」的交代,
|
||||||
|
* 没有就连同前后空行一起抹掉——否则模板里会留下一个空洞段落。
|
||||||
|
*/
|
||||||
|
function fillPrototypeRef(tpl: string, prototypeFile: string | undefined): string {
|
||||||
|
if (!prototypeFile)
|
||||||
|
return tpl.replace(/\n*\{prototypeRef\}\n*/g, '\n\n').trimEnd()
|
||||||
|
const para = `工单已有原型插件 ${prototypeFile},先读它了解已经定下来的界面和交互,在此基础上只对齐还不明确的地方,不要从零重画。`
|
||||||
|
return tpl.split('{prototypeRef}').join(para)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getImplementPlanPrompt(ctx: ExtensionContext, vars: { planFile: string, issueNumber: number, issueRef?: string }): string {
|
export function getImplementPlanPrompt(ctx: ExtensionContext, vars: { planFile: string, issueNumber: number, issueRef?: string }): string {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* 「在 Figma 中导入并打开」:把工单挂着的 Figma 插件 manifest.json 写进
|
||||||
|
* Figma 桌面版的已导入插件清单,然后拉起 Figma。
|
||||||
|
*
|
||||||
|
* 为什么要自己改配置文件:Figma 没有命令行导入开发插件的入口,UI 上得
|
||||||
|
* 「Plugins → Development → Import plugin from manifest」一路点。清单本身
|
||||||
|
* 就是 `~/Library/Application Support/Figma/settings.json` 里的一个数组,
|
||||||
|
* 直接往里加一条即可。
|
||||||
|
*
|
||||||
|
* 最大的坑:**Figma 退出时会用内存里的状态整个覆盖 settings.json**。开着
|
||||||
|
* Figma 写进去,用户一关 Figma 就全没了。所以写之前必须先把 Figma 关掉,
|
||||||
|
* 而且要等进程真的退出——不弹窗问用户,问了也只能答「关」。
|
||||||
|
*
|
||||||
|
* 只支持 macOS:路径和 `osascript` / `open -a` 都是 mac 专有。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
import * as os from 'node:os'
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { env, window } from 'vscode'
|
||||||
|
import { logger } from '../logging/logger'
|
||||||
|
import { appendManifestEntry, asExtensionEntries } from './localExtensions'
|
||||||
|
|
||||||
|
/** Figma 桌面版的设置文件(含已导入开发插件清单)。 */
|
||||||
|
function figmaSettingsPath(): string {
|
||||||
|
return path.join(os.homedir(), 'Library', 'Application Support', 'Figma', 'settings.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等 Figma 退出的上限;到点还没退就让用户自己关,绝不 kill -9。 */
|
||||||
|
const QUIT_TIMEOUT_MS = 10_000
|
||||||
|
const QUIT_POLL_MS = 400
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[]): Promise<{ code: number, stdout: string, stderr: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
|
||||||
|
// pgrep 没匹配到进程也是非零退出,所以这里不把非零当失败,交给调用方判。
|
||||||
|
const code = err ? Number((err as NodeJS.ErrnoException).code ?? 1) : 0
|
||||||
|
resolve({
|
||||||
|
code: Number.isFinite(code) ? code : 1,
|
||||||
|
stdout: (stdout ?? '').toString(),
|
||||||
|
stderr: (stderr ?? '').toString(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isFigmaRunning(): Promise<boolean> {
|
||||||
|
const { stdout } = await run('pgrep', ['-x', 'Figma'])
|
||||||
|
return stdout.trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优雅退出 Figma 并轮询等它真的没了。超时返回 false(调用方报错让用户手动关),
|
||||||
|
* 不强杀——Figma 被 SIGKILL 会丢用户没保存的画布。
|
||||||
|
*/
|
||||||
|
async function quitFigmaAndWait(): Promise<boolean> {
|
||||||
|
await run('osascript', ['-e', 'quit app "Figma"'])
|
||||||
|
const deadline = Date.now() + QUIT_TIMEOUT_MS
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!await isFigmaRunning())
|
||||||
|
return true
|
||||||
|
await new Promise(resolve => setTimeout(resolve, QUIT_POLL_MS))
|
||||||
|
}
|
||||||
|
return !await isFigmaRunning()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读 manifest.json 的 `name` 当 Figma 里显示的插件名;没有就用目录名兜底。 */
|
||||||
|
async function readPluginName(manifestAbs: string): Promise<string> {
|
||||||
|
const raw = await fsp.readFile(manifestAbs, 'utf8')
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
const name = parsed && typeof parsed === 'object'
|
||||||
|
? (parsed as { name?: unknown }).name
|
||||||
|
: undefined
|
||||||
|
if (typeof name === 'string' && name.trim().length > 0)
|
||||||
|
return name.trim()
|
||||||
|
return path.basename(path.dirname(manifestAbs))
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string, err?: unknown): void {
|
||||||
|
const details = err === undefined
|
||||||
|
? undefined
|
||||||
|
: err instanceof Error
|
||||||
|
? (err.stack ?? err.message)
|
||||||
|
: String(err)
|
||||||
|
logger.add({ level: 'error', source: 'figma', message, details })
|
||||||
|
void window.showErrorMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主流程。`manifestPath` 可以是仓库相对路径也可以是绝对路径。
|
||||||
|
* 任何一步失败都出错误提示 + 记日志,不静默。
|
||||||
|
*/
|
||||||
|
export async function importPluginIntoFigma(opts: {
|
||||||
|
issueNumber: number
|
||||||
|
manifestPath: string
|
||||||
|
workspaceRoot: string
|
||||||
|
}): Promise<void> {
|
||||||
|
const { issueNumber, manifestPath, workspaceRoot } = opts
|
||||||
|
const manifestAbs = path.isAbsolute(manifestPath)
|
||||||
|
? manifestPath
|
||||||
|
: path.join(workspaceRoot, manifestPath)
|
||||||
|
|
||||||
|
if (!fs.existsSync(manifestAbs)) {
|
||||||
|
fail(`#${issueNumber} 的原型插件不存在:${manifestAbs}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非 mac 没有这条路径可走,把绝对路径给用户,让他自己在 Figma 里导入。
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
await env.clipboard.writeText(manifestAbs)
|
||||||
|
void window.showWarningMessage(
|
||||||
|
`当前系统不支持自动导入 Figma 插件,manifest 路径已复制到剪贴板,请在 Figma 里手动导入:${manifestAbs}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsPath = figmaSettingsPath()
|
||||||
|
if (!fs.existsSync(settingsPath)) {
|
||||||
|
fail(`没找到 Figma 配置文件:${settingsPath}。先装 Figma 桌面版并至少启动一次。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginName: string
|
||||||
|
try {
|
||||||
|
pluginName = await readPluginName(manifestAbs)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
fail(`读取 manifest.json 失败:${manifestAbs}`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(await fsp.readFile(settingsPath, 'utf8'))
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||||
|
throw new TypeError('settings.json 顶层不是对象')
|
||||||
|
settings = parsed as Record<string, unknown>
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
fail(`读取 Figma 配置失败:${settingsPath}`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = asExtensionEntries(settings.localFileExtensions)
|
||||||
|
const result = appendManifestEntry(entries, { manifestPath: manifestAbs, name: pluginName })
|
||||||
|
|
||||||
|
if (result.added) {
|
||||||
|
// 先关 Figma 再写,否则退出时的内存快照会把这次写入盖掉。
|
||||||
|
if (await isFigmaRunning()) {
|
||||||
|
logger.add({ level: 'info', source: 'figma', message: '写配置前先退出 Figma(退出会覆盖 settings.json)' })
|
||||||
|
if (!await quitFigmaAndWait()) {
|
||||||
|
fail(`Figma 在 ${QUIT_TIMEOUT_MS / 1000} 秒内没有退出,请手动关掉 Figma 后重试。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const backup = `${settingsPath}.bak-${Date.now()}`
|
||||||
|
await fsp.copyFile(settingsPath, backup)
|
||||||
|
settings.localFileExtensions = result.entries
|
||||||
|
await fsp.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
|
||||||
|
logger.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'figma',
|
||||||
|
message: `#${issueNumber} 原型插件已写入 Figma 配置:${pluginName}`,
|
||||||
|
details: `manifest=${manifestAbs} backup=${backup}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
fail(`写入 Figma 配置失败:${settingsPath}`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
logger.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'figma',
|
||||||
|
message: `#${issueNumber} 原型插件已在 Figma 清单中,跳过写入:${result.name}`,
|
||||||
|
details: `manifest=${manifestAbs}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = await run('open', ['-a', 'Figma'])
|
||||||
|
if (open.code !== 0) {
|
||||||
|
fail(`拉起 Figma 失败:${open.stderr.trim() || `exit ${open.code}`}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void window.showInformationMessage(
|
||||||
|
result.added
|
||||||
|
? `原型插件「${result.name}」已导入 Figma,在 Plugins > Development 里跑它`
|
||||||
|
: `原型插件「${result.name}」此前已导入,在 Plugins > Development 里跑它`,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
appendManifestEntry,
|
||||||
|
asExtensionEntries,
|
||||||
|
buildManifestEntry,
|
||||||
|
findManifestEntry,
|
||||||
|
nextExtensionId,
|
||||||
|
} from './localExtensions'
|
||||||
|
|
||||||
|
/** 造一条 manifest 条目(id 与 codeFileId 成对,和 Figma 真实清单一致)。 */
|
||||||
|
function manifest(id: number, manifestPath: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
manifestPath,
|
||||||
|
lastKnownName: `plugin-${id}`,
|
||||||
|
lastKnownPluginId: '',
|
||||||
|
fileMetadata: { type: 'manifest' as const, codeFileId: id + 1, uiFileIds: [] },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Figma 加载插件后补的 code.js 条目,占的是 manifest 的 codeFileId。 */
|
||||||
|
function code(id: number, manifestFileId: number, manifestPath: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
manifestPath,
|
||||||
|
fileMetadata: { type: 'code' as const, manifestFileId },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('nextExtensionId', () => {
|
||||||
|
it('空清单从 1 开始', () => {
|
||||||
|
expect(nextExtensionId([])).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取已有条目 id 最大值 +1', () => {
|
||||||
|
expect(nextExtensionId([manifest(1, '/a/manifest.json'), code(2, 1, '/a/code.js')])).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('codeFileId 预定的号也要算进去(code 条目还没建出来时)', () => {
|
||||||
|
// id 只到 11,但 11 的 codeFileId 已经把 12 预定了 → 下一个必须是 13
|
||||||
|
expect(nextExtensionId([
|
||||||
|
manifest(9, '/a/manifest.json'),
|
||||||
|
code(10, 9, '/a/code.js'),
|
||||||
|
manifest(11, '/b/manifest.json'),
|
||||||
|
])).toBe(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('顺序乱了也按最大值算', () => {
|
||||||
|
expect(nextExtensionId([
|
||||||
|
code(8, 7, '/b/code.js'),
|
||||||
|
manifest(3, '/a/manifest.json'),
|
||||||
|
manifest(7, '/b/manifest.json'),
|
||||||
|
])).toBe(9)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('findManifestEntry', () => {
|
||||||
|
const entries = [manifest(1, '/a/manifest.json'), manifest(3, '/b/manifest.json')]
|
||||||
|
|
||||||
|
it('按 manifestPath 精确命中', () => {
|
||||||
|
expect(findManifestEntry(entries, '/b/manifest.json')?.id).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('路径不同就不算重复(同名不同目录)', () => {
|
||||||
|
expect(findManifestEntry(entries, '/c/manifest.json')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildManifestEntry', () => {
|
||||||
|
it('codeFileId 紧跟 id,给 Figma 之后建 code 条目占号', () => {
|
||||||
|
expect(buildManifestEntry({ id: 19, manifestPath: '/x/manifest.json', name: 'X Lab' })).toEqual({
|
||||||
|
id: 19,
|
||||||
|
manifestPath: '/x/manifest.json',
|
||||||
|
lastKnownName: 'X Lab',
|
||||||
|
lastKnownPluginId: '',
|
||||||
|
fileMetadata: { type: 'manifest', codeFileId: 20, uiFileIds: [] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('appendManifestEntry', () => {
|
||||||
|
it('新插件追加到末尾,id 接着往下发', () => {
|
||||||
|
const entries = [manifest(1, '/a/manifest.json'), code(2, 1, '/a/code.js')]
|
||||||
|
const got = appendManifestEntry(entries, { manifestPath: '/b/manifest.json', name: 'B Lab' })
|
||||||
|
expect(got.added).toBe(true)
|
||||||
|
expect(got.entries).toHaveLength(3)
|
||||||
|
expect(got.entries[2]).toMatchObject({ id: 3, manifestPath: '/b/manifest.json', lastKnownName: 'B Lab' })
|
||||||
|
// 原数组不被改动
|
||||||
|
expect(entries).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已导入过 → 不追加,名字用清单里记的那个', () => {
|
||||||
|
const entries = [manifest(1, '/a/manifest.json')]
|
||||||
|
const got = appendManifestEntry(entries, { manifestPath: '/a/manifest.json', name: '新名字' })
|
||||||
|
expect(got.added).toBe(false)
|
||||||
|
expect(got.entries).toBe(entries)
|
||||||
|
expect(got.name).toBe('plugin-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('asExtensionEntries', () => {
|
||||||
|
it('不是数组 → 空清单(Figma 没装过插件时该字段可能缺失)', () => {
|
||||||
|
expect(asExtensionEntries(undefined)).toEqual([])
|
||||||
|
expect(asExtensionEntries({})).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('丢掉缺 id / manifestPath 的脏条目', () => {
|
||||||
|
expect(asExtensionEntries([
|
||||||
|
manifest(1, '/a/manifest.json'),
|
||||||
|
{ id: 'x', manifestPath: '/b/manifest.json' },
|
||||||
|
{ id: 2 },
|
||||||
|
null,
|
||||||
|
])).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Figma 桌面版「已导入的开发插件」清单的纯逻辑:算新 id、查重、造条目。
|
||||||
|
*
|
||||||
|
* 清单落在 `~/Library/Application Support/Figma/settings.json` 的
|
||||||
|
* `localFileExtensions` 数组里,一条 manifest 条目长这样:
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* { "id": 9, "manifestPath": "/abs/.../manifest.json",
|
||||||
|
* "lastKnownName": "Xxx Design Lab", "lastKnownPluginId": "",
|
||||||
|
* "fileMetadata": { "type": "manifest", "codeFileId": 10, "uiFileIds": [] } }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 坑:id 是**整个数组共享**的递增计数器,manifest 条目占一个 id,Figma 之后
|
||||||
|
* 给它的 code.js 建条目时占 `fileMetadata.codeFileId` 那个 id。所以算下一个
|
||||||
|
* id 时不能只看已有条目的 id,还得把被 codeFileId / manifestFileId 预定掉的
|
||||||
|
* 号算进去,否则新插件会和 Figma 迟早要建的 code 条目撞号。
|
||||||
|
*
|
||||||
|
* 这里不碰文件系统,好单测;读写与关 Figma 在 importPlugin.ts。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FigmaFileMetadata {
|
||||||
|
type: 'manifest' | 'code' | string
|
||||||
|
/** manifest 条目专有:预留给同插件 code.js 条目的 id。 */
|
||||||
|
codeFileId?: number
|
||||||
|
/** code 条目专有:反指所属 manifest 条目的 id。 */
|
||||||
|
manifestFileId?: number
|
||||||
|
uiFileIds?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FigmaLocalFileExtension {
|
||||||
|
id: number
|
||||||
|
manifestPath: string
|
||||||
|
lastKnownName?: string
|
||||||
|
lastKnownPluginId?: string
|
||||||
|
fileMetadata?: FigmaFileMetadata
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数组里混着 Figma 写的杂项,只把「有数字 id + 字符串 manifestPath」的当条目。 */
|
||||||
|
export function asExtensionEntries(value: unknown): FigmaLocalFileExtension[] {
|
||||||
|
if (!Array.isArray(value))
|
||||||
|
return []
|
||||||
|
return value.filter((e): e is FigmaLocalFileExtension =>
|
||||||
|
!!e
|
||||||
|
&& typeof e === 'object'
|
||||||
|
&& typeof (e as FigmaLocalFileExtension).id === 'number'
|
||||||
|
&& typeof (e as FigmaLocalFileExtension).manifestPath === 'string',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下一个可用 id:已有条目 id 与被 codeFileId / manifestFileId 预定的 id 取最大再 +1。
|
||||||
|
* 空清单从 1 开始(Figma 自己也是从 1 起)。
|
||||||
|
*/
|
||||||
|
export function nextExtensionId(entries: FigmaLocalFileExtension[]): number {
|
||||||
|
let max = 0
|
||||||
|
for (const e of entries) {
|
||||||
|
for (const n of [e.id, e.fileMetadata?.codeFileId, e.fileMetadata?.manifestFileId]) {
|
||||||
|
if (typeof n === 'number' && Number.isFinite(n) && n > max)
|
||||||
|
max = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 manifestPath 精确查重;已导入过就返回那条,用来跳过写入。 */
|
||||||
|
export function findManifestEntry(
|
||||||
|
entries: FigmaLocalFileExtension[],
|
||||||
|
manifestPath: string,
|
||||||
|
): FigmaLocalFileExtension | undefined {
|
||||||
|
return entries.find(e => e.manifestPath === manifestPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 造一条 manifest 条目;code 条目留给 Figma 自己建,这里只把 id 号占住。 */
|
||||||
|
export function buildManifestEntry(opts: {
|
||||||
|
id: number
|
||||||
|
manifestPath: string
|
||||||
|
name: string
|
||||||
|
}): FigmaLocalFileExtension {
|
||||||
|
return {
|
||||||
|
id: opts.id,
|
||||||
|
manifestPath: opts.manifestPath,
|
||||||
|
lastKnownName: opts.name,
|
||||||
|
lastKnownPluginId: '',
|
||||||
|
fileMetadata: {
|
||||||
|
type: 'manifest',
|
||||||
|
codeFileId: opts.id + 1,
|
||||||
|
uiFileIds: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppendResult {
|
||||||
|
/** 追加后的完整条目数组;已存在时原样返回。 */
|
||||||
|
entries: FigmaLocalFileExtension[]
|
||||||
|
/** false = 已导入过,调用方跳过写文件直接拉起 Figma。 */
|
||||||
|
added: boolean
|
||||||
|
/** 这条插件在 Figma 里的显示名(新建用传入名,已存在用清单里记的名)。 */
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查重 → 算 id → 追加,一次算完,写文件的那端只管落盘。 */
|
||||||
|
export function appendManifestEntry(
|
||||||
|
entries: FigmaLocalFileExtension[],
|
||||||
|
opts: { manifestPath: string, name: string },
|
||||||
|
): AppendResult {
|
||||||
|
const existing = findManifestEntry(entries, opts.manifestPath)
|
||||||
|
if (existing)
|
||||||
|
return { entries, added: false, name: existing.lastKnownName || opts.name }
|
||||||
|
const entry = buildManifestEntry({
|
||||||
|
id: nextExtensionId(entries),
|
||||||
|
manifestPath: opts.manifestPath,
|
||||||
|
name: opts.name,
|
||||||
|
})
|
||||||
|
return { entries: [...entries, entry], added: true, name: opts.name }
|
||||||
|
}
|
||||||
@@ -45,6 +45,19 @@ export function isValidSpxFilePath(v: unknown): v is string {
|
|||||||
&& v !== '…'
|
&& v !== '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prototypeFile 指向 Figma 插件的 manifest.json,规则与 isValidSpxFilePath
|
||||||
|
* 同款,只是后缀换成 .json(可以是仓库相对路径,也可以是绝对路径)。
|
||||||
|
*/
|
||||||
|
export function isValidPrototypeFilePath(v: unknown): v is string {
|
||||||
|
return typeof v === 'string'
|
||||||
|
&& v.length > 0
|
||||||
|
&& v.includes('/')
|
||||||
|
&& v.endsWith('.json')
|
||||||
|
&& v !== '...'
|
||||||
|
&& v !== '…'
|
||||||
|
}
|
||||||
|
|
||||||
function isValidPrDiffFilePath(v: unknown): v is string {
|
function isValidPrDiffFilePath(v: unknown): v is string {
|
||||||
return typeof v === 'string'
|
return typeof v === 'string'
|
||||||
&& /^docs\/pr-diff\/\S+\.md$/.test(v)
|
&& /^docs\/pr-diff\/\S+\.md$/.test(v)
|
||||||
@@ -73,6 +86,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
|||||||
prototypeProfilePath?: string
|
prototypeProfilePath?: string
|
||||||
specFile?: string
|
specFile?: string
|
||||||
planFile?: string
|
planFile?: string
|
||||||
|
prototypeFile?: string
|
||||||
prDiffFile?: string
|
prDiffFile?: string
|
||||||
pr?: string
|
pr?: string
|
||||||
prMerged?: boolean
|
prMerged?: boolean
|
||||||
@@ -121,6 +135,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
|||||||
prototypeProfilePath?: unknown
|
prototypeProfilePath?: unknown
|
||||||
specFile?: unknown
|
specFile?: unknown
|
||||||
planFile?: unknown
|
planFile?: unknown
|
||||||
|
prototypeFile?: unknown
|
||||||
prDiffFile?: unknown
|
prDiffFile?: unknown
|
||||||
pr?: unknown
|
pr?: unknown
|
||||||
prMerged?: unknown
|
prMerged?: unknown
|
||||||
@@ -155,6 +170,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
|||||||
: undefined
|
: undefined
|
||||||
const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : undefined
|
const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : undefined
|
||||||
const planFile = isValidSpxFilePath(obj.planFile) ? obj.planFile : undefined
|
const planFile = isValidSpxFilePath(obj.planFile) ? obj.planFile : undefined
|
||||||
|
const prototypeFile = isValidPrototypeFilePath(obj.prototypeFile) ? obj.prototypeFile : undefined
|
||||||
const prDiffFile = isValidPrDiffFilePath(obj.prDiffFile) ? obj.prDiffFile : undefined
|
const prDiffFile = isValidPrDiffFilePath(obj.prDiffFile) ? obj.prDiffFile : undefined
|
||||||
const pr = typeof obj.pr === 'string' && obj.pr.length > 0
|
const pr = typeof obj.pr === 'string' && obj.pr.length > 0
|
||||||
? obj.pr
|
? obj.pr
|
||||||
@@ -205,6 +221,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
|||||||
prototypeProfilePath,
|
prototypeProfilePath,
|
||||||
specFile,
|
specFile,
|
||||||
planFile,
|
planFile,
|
||||||
|
prototypeFile,
|
||||||
prDiffFile,
|
prDiffFile,
|
||||||
pr,
|
pr,
|
||||||
prMerged,
|
prMerged,
|
||||||
@@ -348,6 +365,7 @@ async function buildIssue(opts: {
|
|||||||
prototypeProfilePath,
|
prototypeProfilePath,
|
||||||
specFile,
|
specFile,
|
||||||
planFile,
|
planFile,
|
||||||
|
prototypeFile,
|
||||||
prDiffFile,
|
prDiffFile,
|
||||||
pr,
|
pr,
|
||||||
prMerged: persistedMerged,
|
prMerged: persistedMerged,
|
||||||
@@ -412,6 +430,7 @@ async function buildIssue(opts: {
|
|||||||
...(prototypeProfilePath ? { prototypeProfilePath } : {}),
|
...(prototypeProfilePath ? { prototypeProfilePath } : {}),
|
||||||
...(specFile ? { specFile } : {}),
|
...(specFile ? { specFile } : {}),
|
||||||
...(planFile ? { planFile } : {}),
|
...(planFile ? { planFile } : {}),
|
||||||
|
...(prototypeFile ? { prototypeFile } : {}),
|
||||||
...(prDiffFile ? { prDiffFile } : {}),
|
...(prDiffFile ? { prDiffFile } : {}),
|
||||||
...(pr ? { pr } : {}),
|
...(pr ? { pr } : {}),
|
||||||
...(prMerged !== undefined ? { prMerged } : {}),
|
...(prMerged !== undefined ? { prMerged } : {}),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
|
|||||||
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
|
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
|
||||||
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
|
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
|
||||||
*/
|
*/
|
||||||
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
|
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom'] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
|
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export interface Issue {
|
|||||||
* `.claude/plans/<stem>.md`.
|
* `.claude/plans/<stem>.md`.
|
||||||
*/
|
*/
|
||||||
planFile?: string
|
planFile?: string
|
||||||
|
/**
|
||||||
|
* 原型会话产出的 Figma 插件 `manifest.json` 路径(仓库相对或绝对)。
|
||||||
|
* cc 画完原型后用 `spx issue marker --type prototype` 挂上来。
|
||||||
|
*/
|
||||||
|
prototypeFile?: string
|
||||||
/** Optional workspace-relative path to the generated PR diff summary. */
|
/** Optional workspace-relative path to the generated PR diff summary. */
|
||||||
prDiffFile?: string
|
prDiffFile?: string
|
||||||
/** PR number associated with this issue (set after webhook fires). */
|
/** PR number associated with this issue (set after webhook fires). */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as fs from 'node:fs'
|
|||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
||||||
import { deleteToken, getToken } from '../auth/secrets'
|
import { deleteToken, getToken } from '../auth/secrets'
|
||||||
|
import { importPluginIntoFigma } from '../figma/importPlugin'
|
||||||
import { detectRepo } from '../git/remote'
|
import { detectRepo } from '../git/remote'
|
||||||
import {
|
import {
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
@@ -464,6 +465,15 @@ export class KanbanWebviewPanel {
|
|||||||
void issues.handleOpenFile(this, msg.path)
|
void issues.handleOpenFile(this, msg.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'prototype/open-in-figma') {
|
||||||
|
const root = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
if (!root) {
|
||||||
|
void window.showErrorMessage('请先打开一个工作区文件夹')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void importPluginIntoFigma({ issueNumber: msg.issueNumber, manifestPath: msg.path, workspaceRoot: root })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (msg.type === 'issue/implement') {
|
if (msg.type === 'issue/implement') {
|
||||||
void this.handleImplement(msg.issueNumber, msg.planFile, msg.profilePath, msg.sessionId)
|
void this.handleImplement(msg.issueNumber, msg.planFile, msg.profilePath, msg.sessionId)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2314,7 +2314,8 @@ export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string)
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const abs = path.join(workspaceRoot, relPath)
|
// 多数字段存的是仓库相对路径,但 prototypeFile 允许绝对路径(插件可能在仓库外)。
|
||||||
|
const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath)
|
||||||
const isMarkdown = abs.toLowerCase().endsWith('.md')
|
const isMarkdown = abs.toLowerCase().endsWith('.md')
|
||||||
try {
|
try {
|
||||||
if (isMarkdown) {
|
if (isMarkdown) {
|
||||||
|
|||||||
@@ -947,12 +947,16 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
|
|||||||
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
|
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
|
||||||
let brainstormProfilePath: string | undefined
|
let brainstormProfilePath: string | undefined
|
||||||
let sessionId: string | undefined
|
let sessionId: string | undefined
|
||||||
|
// 工单已有原型插件时,续聊提示词里让 cc 先读它,别把已经画定的界面又讨论一遍。
|
||||||
|
let prototypeFile: string | undefined
|
||||||
try {
|
try {
|
||||||
const stateObj = await panel.readIssueState(issueNumber)
|
const stateObj = await panel.readIssueState(issueNumber)
|
||||||
if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0)
|
if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0)
|
||||||
brainstormProfilePath = stateObj.brainstormProfilePath
|
brainstormProfilePath = stateObj.brainstormProfilePath
|
||||||
if (stateObj && typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0)
|
if (stateObj && typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0)
|
||||||
sessionId = stateObj.sessionId
|
sessionId = stateObj.sessionId
|
||||||
|
if (stateObj && typeof stateObj.prototypeFile === 'string' && stateObj.prototypeFile.length > 0)
|
||||||
|
prototypeFile = stateObj.prototypeFile
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -963,7 +967,11 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId })
|
const prompt = getBrainstormContinuePrompt(panel.context, {
|
||||||
|
issueNumber,
|
||||||
|
issueRef: panel.refFor(issueNumber).externalId,
|
||||||
|
prototypeFile,
|
||||||
|
})
|
||||||
const terminalName = `issue-${issueNumber}-规划`
|
const terminalName = `issue-${issueNumber}-规划`
|
||||||
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
|
||||||
const linked = panel.newIssueTerminals.get(issueNumber)
|
const linked = panel.newIssueTerminals.get(issueNumber)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issues/loaded' }
|
| { type: 'issues/loaded' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, 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 } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: 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。 */
|
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||||
| { type: 'session/tab-settled', sessionId: string }
|
| { type: 'session/tab-settled', sessionId: string }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
@@ -203,6 +203,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'session/start-review', issueNumber: number }
|
| { type: 'session/start-review', issueNumber: number }
|
||||||
| { type: 'session/start-test', issueNumber: number }
|
| { type: 'session/start-test', issueNumber: number }
|
||||||
| { type: 'session/start-prototype', issueNumber: number }
|
| { type: 'session/start-prototype', issueNumber: number }
|
||||||
|
| { type: 'prototype/open-in-figma', issueNumber: number, path: string }
|
||||||
| { type: 'editor/open-file', path: string }
|
| { type: 'editor/open-file', path: string }
|
||||||
| { type: 'profiles/list' }
|
| { type: 'profiles/list' }
|
||||||
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
|
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ import { normalizeReleasePrefix } from '../git/release'
|
|||||||
*/
|
*/
|
||||||
const FALLBACK_BRAINSTORM_PROMPT = `我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。`
|
const FALLBACK_BRAINSTORM_PROMPT = `我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。`
|
||||||
|
|
||||||
const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumber} 号工单。达成共识后用 ecc:spec 写 .claude/specs/<stem>.md,我确认后用 ecc:plan 出 .claude/plans/<stem>.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。`
|
const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumber} 号工单。
|
||||||
|
|
||||||
|
{prototypeRef}
|
||||||
|
|
||||||
|
达成共识后用 ecc:spec 写 .claude/specs/<stem>.md,我确认后用 ecc:plan 出 .claude/plans/<stem>.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。`
|
||||||
|
|
||||||
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
|
const FALLBACK_IMPLEMENT_PLAN_PROMPT = `/ecc:tdd @{planFile} 按计划实施,编码派子代理,发起 PR 时在 body 中包含 "Closes #{issueNumber}"。严禁合并 PR。`
|
||||||
|
|
||||||
@@ -34,7 +38,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 第一行写 <!-- spx:review=1 -->。`
|
const FALLBACK_REVIEW_PROMPT = `用 tea 拿到 #{prNumber} PR,diff 交给 ecc:code-reviewer 子代理审查。审查意见用 spx pr review-comment 发评论,body 第一行写 <!-- spx:review=1 -->。`
|
||||||
|
|
||||||
const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。`
|
const FALLBACK_PROTOTYPE_PROMPT = `/ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。
|
||||||
|
|
||||||
|
画完用 \`opencli spx issue marker --issue {issueNumber} --type prototype --value <manifest.json 的仓库相对路径>\` 把原型插件挂到工单上,再汇报。`
|
||||||
|
|
||||||
type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
type PromptName = 'brainstorm' | 'brainstorm-continue' | 'implement-plan' | 'review' | 'test' | 'prototype'
|
||||||
|
|
||||||
@@ -84,7 +90,8 @@ export interface Settings {
|
|||||||
/**
|
/**
|
||||||
* Prompt template for continuing brainstorm on an already-existing issue
|
* Prompt template for continuing brainstorm on an already-existing issue
|
||||||
* (when the user later wants to discuss / write spec/plan in the panel).
|
* (when the user later wants to discuss / write spec/plan in the panel).
|
||||||
* `{issueNumber}` placeholder.
|
* 占位符 `{issueNumber}` / `{prototypeRef}`(后者由扩展按工单有无原型插件填,
|
||||||
|
* 无则连同所在段落一起删掉)。
|
||||||
*/
|
*/
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
/** Prompt template for the implement-plan flow. `{planFile}` placeholder. */
|
/** Prompt template for the implement-plan flow. `{planFile}` placeholder. */
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ export interface PendingHook {
|
|||||||
feature: string
|
feature: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* issue body 里的 spx marker → state JSON 字段。三种 marker 走同一套同步
|
||||||
|
* 逻辑,这张表是唯一真源(与 cli 的 markerTypeToStateField 对应)。
|
||||||
|
*
|
||||||
|
* 路径必须含 `/` 且以对应后缀结尾:cc 在头脑风暴阶段会往 body 里写
|
||||||
|
* `<!-- spx:spec=... -->` 这种占位说明文,不卡死就会被当成真路径存进去。
|
||||||
|
*/
|
||||||
|
const BODY_MARKERS = [
|
||||||
|
{ type: 'spec', field: 'specFile', ext: 'md' },
|
||||||
|
{ type: 'plan', field: 'planFile', ext: 'md' },
|
||||||
|
{ type: 'prototype', field: 'prototypeFile', ext: 'json' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** 本次从 body 里扫出来的 marker 值,按 state JSON 字段名索引。 */
|
||||||
|
type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string }
|
||||||
|
|
||||||
|
function parseBodyMarkers(body: string): MarkerPatch {
|
||||||
|
const out: MarkerPatch = {}
|
||||||
|
for (const { type, field, ext } of BODY_MARKERS) {
|
||||||
|
const re = new RegExp(String.raw`<!--\s*spx:${type}=([^\s/>]*\/[^\s>]*\.${ext})\s*-->`)
|
||||||
|
const m = body.match(re)
|
||||||
|
if (m)
|
||||||
|
out[field] = m[1]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日志用的一行摘要,例如 `spec=a.md plan=<空> prototype=<空>`。 */
|
||||||
|
function describeMarkers(patch: MarkerPatch): string {
|
||||||
|
return BODY_MARKERS.map(({ type, field }) => `${type}=${patch[field] ?? '<空>'}`).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A {@link PrWebhookEvent} whose `issueNumber` has been resolved (either from
|
* A {@link PrWebhookEvent} whose `issueNumber` has been resolved (either from
|
||||||
* the legacy `/webhook/:n` path or via PR-body / branch heuristics).
|
* the legacy `/webhook/:n` path or via PR-body / branch heuristics).
|
||||||
@@ -779,11 +811,10 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issue `edited` payload: cc updates the issue body with `<!-- spx:spec=... -->`
|
* Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->`
|
||||||
* / `<!-- spx:plan=... -->` annotation lines as it discovers spec/plan files.
|
* / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。
|
||||||
* We scan the body for those markers, diff against the state-JSON comment,
|
* 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对,
|
||||||
* and (if anything changed) merge the new paths in + patch the open panel
|
* 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。
|
||||||
* so the detail view refreshes without a full kanban reload.
|
|
||||||
*/
|
*/
|
||||||
private async handleIssueEdited(event: IssueWebhookEvent): Promise<void> {
|
private async handleIssueEdited(event: IssueWebhookEvent): Promise<void> {
|
||||||
if (!this.ctx)
|
if (!this.ctx)
|
||||||
@@ -817,17 +848,12 @@ class WebhookCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路径必须含 `/` 且以 `.md` 结尾,避免 cc 写的 `<!-- spx:spec=... -->` 占位被当真。
|
const found = parseBodyMarkers(event.body)
|
||||||
const specMatch = event.body.match(/<!--\s*spx:spec=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
|
if (Object.keys(found).length === 0) {
|
||||||
const planMatch = event.body.match(/<!--\s*spx:plan=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
|
|
||||||
const specFile = specMatch ? specMatch[1] : undefined
|
|
||||||
const planFile = planMatch ? planMatch[1] : undefined
|
|
||||||
|
|
||||||
if (!specFile && !planFile) {
|
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `issue=#${event.issueNumber} edited body 无 spx:spec/spx:plan 注释,跳过`,
|
message: `issue=#${event.issueNumber} edited body 无 spx marker 注释,跳过`,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -852,15 +878,19 @@ class WebhookCoordinator {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSpec = typeof currentState.specFile === 'string' ? currentState.specFile : undefined
|
// cc 只增不删:body 里没有某个 marker 当「没信号」,不当「清空」,
|
||||||
const currentPlan = typeof currentState.planFile === 'string' ? currentState.planFile : undefined
|
// 免得手滑删掉一行就把已有指针丢了。
|
||||||
|
const next: MarkerPatch = {}
|
||||||
// cc only adds or updates; it never deletes. Treat an absent marker
|
let changed = false
|
||||||
// (undefined) as "no signal", not "clear" — that way deleting a spec
|
for (const { field } of BODY_MARKERS) {
|
||||||
// line by mistake won't lose the existing pointer.
|
const current = typeof currentState[field] === 'string' ? currentState[field] as string : undefined
|
||||||
const nextSpec = specFile ?? currentSpec
|
const value = found[field] ?? current
|
||||||
const nextPlan = planFile ?? currentPlan
|
if (value)
|
||||||
if (currentSpec === nextSpec && currentPlan === nextPlan) {
|
next[field] = value
|
||||||
|
if (value !== current)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (!changed) {
|
||||||
// Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment
|
// Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment
|
||||||
// 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠
|
// 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠
|
||||||
// coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去,
|
// coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去,
|
||||||
@@ -868,24 +898,17 @@ class WebhookCoordinator {
|
|||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `issue=#${event.issueNumber} edited spec/plan 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (spec=${nextSpec ?? '<空>'} plan=${nextPlan ?? '<空>'})`,
|
message: `issue=#${event.issueNumber} edited marker 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (${describeMarkers(next)})`,
|
||||||
|
})
|
||||||
|
this.activePanel?.postMessage({
|
||||||
|
type: 'issue/patch',
|
||||||
|
issueNumber: event.issueNumber,
|
||||||
|
patch: next,
|
||||||
})
|
})
|
||||||
if (this.activePanel) {
|
|
||||||
const patch: { specFile?: string, planFile?: string } = {}
|
|
||||||
if (nextSpec)
|
|
||||||
patch.specFile = nextSpec
|
|
||||||
if (nextPlan)
|
|
||||||
patch.planFile = nextPlan
|
|
||||||
this.activePanel.postMessage({
|
|
||||||
type: 'issue/patch',
|
|
||||||
issueNumber: event.issueNumber,
|
|
||||||
patch,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/plan 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
|
// marker 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
|
||||||
// 额外认规划终端。observe 机器只把新值推给本机 webview。
|
// 额外认规划终端。observe 机器只把新值推给本机 webview。
|
||||||
const ownership = await this.resolveOwnership(
|
const ownership = await this.resolveOwnership(
|
||||||
event.issueNumber,
|
event.issueNumber,
|
||||||
@@ -893,27 +916,14 @@ class WebhookCoordinator {
|
|||||||
['实施', '测试', '规划'],
|
['实施', '测试', '规划'],
|
||||||
)
|
)
|
||||||
if (ownership === 'observe') {
|
if (ownership === 'observe') {
|
||||||
if (this.activePanel) {
|
this.activePanel?.postMessage({
|
||||||
const patch: { specFile?: string, planFile?: string } = {}
|
type: 'issue/patch',
|
||||||
if (specFile)
|
issueNumber: event.issueNumber,
|
||||||
patch.specFile = specFile
|
patch: found,
|
||||||
if (planFile)
|
})
|
||||||
patch.planFile = planFile
|
|
||||||
this.activePanel.postMessage({
|
|
||||||
type: 'issue/patch',
|
|
||||||
issueNumber: event.issueNumber,
|
|
||||||
patch,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const extra: Record<string, unknown> = {}
|
|
||||||
if (specFile)
|
|
||||||
extra.specFile = specFile
|
|
||||||
if (planFile)
|
|
||||||
extra.planFile = planFile
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mergeStateJsonComment({
|
await mergeStateJsonComment({
|
||||||
host: remote.host,
|
host: remote.host,
|
||||||
@@ -921,7 +931,7 @@ class WebhookCoordinator {
|
|||||||
repo: remote.repo,
|
repo: remote.repo,
|
||||||
token,
|
token,
|
||||||
issueNumber: event.issueNumber,
|
issueNumber: event.issueNumber,
|
||||||
extra,
|
extra: found,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -938,22 +948,15 @@ class WebhookCoordinator {
|
|||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `issue=#${event.issueNumber} edited spec/plan 已同步`,
|
message: `issue=#${event.issueNumber} edited marker 已同步`,
|
||||||
details: `spec=${specFile ?? '<未变>'} plan=${planFile ?? '<未变>'}`,
|
details: describeMarkers(found),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.activePanel) {
|
this.activePanel?.postMessage({
|
||||||
const patch: { specFile?: string, planFile?: string } = {}
|
type: 'issue/patch',
|
||||||
if (specFile)
|
issueNumber: event.issueNumber,
|
||||||
patch.specFile = specFile
|
patch: found,
|
||||||
if (planFile)
|
})
|
||||||
patch.planFile = planFile
|
|
||||||
this.activePanel.postMessage({
|
|
||||||
type: 'issue/patch',
|
|
||||||
issueNumber: event.issueNumber,
|
|
||||||
patch,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
|
|||||||
prototypeProfilePath: str(state.prototypeProfilePath),
|
prototypeProfilePath: str(state.prototypeProfilePath),
|
||||||
specFile: str(state.specFile),
|
specFile: str(state.specFile),
|
||||||
planFile: str(state.planFile),
|
planFile: str(state.planFile),
|
||||||
|
prototypeFile: str(state.prototypeFile),
|
||||||
prDiffFile: str(state.prDiffFile),
|
prDiffFile: str(state.prDiffFile),
|
||||||
pr: str(state.pr),
|
pr: str(state.pr),
|
||||||
branch: str(state.branch),
|
branch: str(state.branch),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
|
|||||||
export const STATE_MARKER = '<!--spx-state-->'
|
export const STATE_MARKER = '<!--spx-state-->'
|
||||||
|
|
||||||
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
|
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
|
||||||
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
|
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', '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
|
* Parse a comment body into a state object, or null if it isn't a state
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function App() {
|
|||||||
resumePrototypeSession,
|
resumePrototypeSession,
|
||||||
focusSession,
|
focusSession,
|
||||||
openFile,
|
openFile,
|
||||||
|
openPrototypeInFigma,
|
||||||
implement,
|
implement,
|
||||||
generatePrDiffSummary,
|
generatePrDiffSummary,
|
||||||
isPrDiffSummaryRunning,
|
isPrDiffSummaryRunning,
|
||||||
@@ -386,6 +387,7 @@ export function App() {
|
|||||||
onStartPrototypeSession={startPrototypeSession}
|
onStartPrototypeSession={startPrototypeSession}
|
||||||
onResumePrototypeSession={resumePrototypeSession}
|
onResumePrototypeSession={resumePrototypeSession}
|
||||||
onOpenFile={openFile}
|
onOpenFile={openFile}
|
||||||
|
onOpenPrototypeInFigma={openPrototypeInFigma}
|
||||||
onImplement={implement}
|
onImplement={implement}
|
||||||
onOpenPr={openPr}
|
onOpenPr={openPr}
|
||||||
onGeneratePrDiffSummary={generatePrDiffSummary}
|
onGeneratePrDiffSummary={generatePrDiffSummary}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface BottomTabsProps {
|
|||||||
onStartPrototypeSession: (issueNumber: number) => void
|
onStartPrototypeSession: (issueNumber: number) => void
|
||||||
onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
|
onOpenPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||||
onOpenPr: (pr: string) => void
|
onOpenPr: (pr: string) => void
|
||||||
onGeneratePrDiffSummary: (issueNumber: number) => void
|
onGeneratePrDiffSummary: (issueNumber: number) => void
|
||||||
@@ -140,6 +141,7 @@ export function BottomTabs(props: BottomTabsProps) {
|
|||||||
onStartPrototypeSession={props.onStartPrototypeSession}
|
onStartPrototypeSession={props.onStartPrototypeSession}
|
||||||
onResumePrototypeSession={props.onResumePrototypeSession}
|
onResumePrototypeSession={props.onResumePrototypeSession}
|
||||||
onOpenFile={props.onOpenFile}
|
onOpenFile={props.onOpenFile}
|
||||||
|
onOpenPrototypeInFigma={props.onOpenPrototypeInFigma}
|
||||||
onImplement={props.onImplement}
|
onImplement={props.onImplement}
|
||||||
onOpenPr={props.onOpenPr}
|
onOpenPr={props.onOpenPr}
|
||||||
onGeneratePrDiffSummary={props.onGeneratePrDiffSummary}
|
onGeneratePrDiffSummary={props.onGeneratePrDiffSummary}
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ interface IssueDetailPanelProps {
|
|||||||
onStartPrototypeSession: (issueNumber: number) => void
|
onStartPrototypeSession: (issueNumber: number) => void
|
||||||
/** Open a workspace-relative file in the editor. */
|
/** Open a workspace-relative file in the editor. */
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
|
/**
|
||||||
|
* 把工单的 Figma 插件 manifest 导进本机 Figma 桌面版并拉起 Figma。
|
||||||
|
* 「原型插件路径」行尾那个按钮。
|
||||||
|
*/
|
||||||
|
onOpenPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||||
/** Kick off the end-to-end implementation flow for the given plan file. */
|
/** Kick off the end-to-end implementation flow for the given plan file. */
|
||||||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||||
/** Open the gitea PR page in the browser. */
|
/** Open the gitea PR page in the browser. */
|
||||||
@@ -151,6 +156,7 @@ export function IssueDetailPanel({
|
|||||||
onStartTestSession,
|
onStartTestSession,
|
||||||
onStartPrototypeSession,
|
onStartPrototypeSession,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenPrototypeInFigma,
|
||||||
onImplement,
|
onImplement,
|
||||||
onOpenPr,
|
onOpenPr,
|
||||||
onGeneratePrDiffSummary,
|
onGeneratePrDiffSummary,
|
||||||
@@ -567,6 +573,24 @@ export function IssueDetailPanel({
|
|||||||
}]
|
}]
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'prototypeFile',
|
||||||
|
label: '原型插件路径',
|
||||||
|
type: 'file-link',
|
||||||
|
description: '双击在编辑器打开 Figma 插件的 manifest.json;点右侧按钮把插件导入本机 Figma 并打开;cc 通过 issue body 的 <!-- spx:prototype=路径 --> 注释同步',
|
||||||
|
onOpen: (p: string) => onOpenFile(p),
|
||||||
|
// 没挂原型插件时整个按钮不渲染,行只显示空态。
|
||||||
|
secondaryActions: issue?.prototypeFile
|
||||||
|
? [{
|
||||||
|
icon: <ExternalLink className="size-3.5" />,
|
||||||
|
title: '在 Figma 中导入并打开',
|
||||||
|
onClick: () => {
|
||||||
|
if (issue?.prototypeFile)
|
||||||
|
onOpenPrototypeInFigma(issue.number, issue.prototypeFile)
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'pr',
|
key: 'pr',
|
||||||
label: '合并请求',
|
label: '合并请求',
|
||||||
@@ -641,7 +665,7 @@ export function IssueDetailPanel({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[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],
|
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onResumePrototypeSession, onStartPrototypeSession, onOpenFile, onOpenPrototypeInFigma, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onClearBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles, isSessionTabPending, onStartReviewSession],
|
||||||
)
|
)
|
||||||
|
|
||||||
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
|
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
|
||||||
@@ -671,6 +695,7 @@ export function IssueDetailPanel({
|
|||||||
prototypeProfilePath: matchProfileOption(issue.prototypeProfilePath, profiles) || null,
|
prototypeProfilePath: matchProfileOption(issue.prototypeProfilePath, profiles) || null,
|
||||||
specFile: issue.specFile ?? null,
|
specFile: issue.specFile ?? null,
|
||||||
planFile: issue.planFile ?? null,
|
planFile: issue.planFile ?? null,
|
||||||
|
prototypeFile: issue.prototypeFile ?? null,
|
||||||
prDiffFile: issue.prDiffFile ?? null,
|
prDiffFile: issue.prDiffFile ?? null,
|
||||||
pr: issue.pr
|
pr: issue.pr
|
||||||
? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
|
? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
|
||||||
|
|||||||
@@ -994,10 +994,11 @@ export function SettingsModal({
|
|||||||
label="头脑风暴续聊提示词"
|
label="头脑风暴续聊提示词"
|
||||||
hint={(
|
hint={(
|
||||||
<>
|
<>
|
||||||
可用占位符
|
可用占位符:
|
||||||
{' '}
|
|
||||||
<code>{'{issueNumber}'}</code>
|
<code>{'{issueNumber}'}</code>
|
||||||
;用于对已有工单继续讨论 / 写 spec/plan 的会话初始指令。
|
、
|
||||||
|
<code>{'{prototypeRef}'}</code>
|
||||||
|
(工单挂了原型插件才填,没挂时该段整段删掉);用于对已有工单继续讨论 / 写 spec/plan 的会话初始指令。
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export interface UseIssuesResult {
|
|||||||
resumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
resumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||||||
focusSession: (issueNumber: number) => void
|
focusSession: (issueNumber: number) => void
|
||||||
openFile: (path: string) => void
|
openFile: (path: string) => void
|
||||||
|
/** 把工单的 Figma 插件导入本机 Figma 桌面版并拉起 Figma。 */
|
||||||
|
openPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||||
implement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
implement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||||
generatePrDiffSummary: (issueNumber: number) => void
|
generatePrDiffSummary: (issueNumber: number) => void
|
||||||
isPrDiffSummaryRunning: (issueNumber: number) => boolean
|
isPrDiffSummaryRunning: (issueNumber: number) => boolean
|
||||||
@@ -588,6 +590,10 @@ export function useIssues(): UseIssuesResult {
|
|||||||
postMessage({ type: 'editor/open-file', path })
|
postMessage({ type: 'editor/open-file', path })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const openPrototypeInFigma = useCallback((issueNumber: number, path: string): void => {
|
||||||
|
postMessage({ type: 'prototype/open-in-figma', issueNumber, path })
|
||||||
|
}, [])
|
||||||
|
|
||||||
const implement = useCallback((
|
const implement = useCallback((
|
||||||
issueNumber: number,
|
issueNumber: number,
|
||||||
planFile: string,
|
planFile: string,
|
||||||
@@ -1037,5 +1043,5 @@ export function useIssues(): UseIssuesResult {
|
|||||||
return cleanup
|
return cleanup
|
||||||
}, [clearPrDiffSummaryRunning, markHandoffRunning])
|
}, [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, 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 }
|
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, openPrototypeInFigma, 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 }
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export type ExtensionToWebview
|
|||||||
| { type: 'issues/loaded' }
|
| { type: 'issues/loaded' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, 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 } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: 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。 */
|
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||||
| { type: 'session/tab-settled', sessionId: string }
|
| { type: 'session/tab-settled', sessionId: string }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
@@ -234,6 +234,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'session/start-review', issueNumber: number }
|
| { type: 'session/start-review', issueNumber: number }
|
||||||
| { type: 'session/start-test', issueNumber: number }
|
| { type: 'session/start-test', issueNumber: number }
|
||||||
| { type: 'session/start-prototype', issueNumber: number }
|
| { type: 'session/start-prototype', issueNumber: number }
|
||||||
|
| { type: 'prototype/open-in-figma', issueNumber: number, path: string }
|
||||||
| { type: 'editor/open-file', path: string }
|
| { type: 'editor/open-file', path: string }
|
||||||
| { type: 'profiles/list' }
|
| { type: 'profiles/list' }
|
||||||
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
|
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export interface Issue {
|
|||||||
* `.claude/plans/<stem>.md`.
|
* `.claude/plans/<stem>.md`.
|
||||||
*/
|
*/
|
||||||
planFile?: string
|
planFile?: string
|
||||||
|
/**
|
||||||
|
* 原型会话产出的 Figma 插件 `manifest.json` 路径(仓库相对或绝对)。
|
||||||
|
* cc 画完原型后用 `spx issue marker --type prototype` 挂上来。
|
||||||
|
*/
|
||||||
|
prototypeFile?: string
|
||||||
/** Optional workspace-relative path to the generated PR diff summary. */
|
/** Optional workspace-relative path to the generated PR diff summary. */
|
||||||
prDiffFile?: string
|
prDiffFile?: string
|
||||||
/** PR number associated with this issue (set after webhook fires). */
|
/** PR number associated with this issue (set after webhook fires). */
|
||||||
|
|||||||
Reference in new issue
Block a user