From 142af57d9d5612bfd60d59bd1e633ed34019ccec Mon Sep 17 00:00:00 2001 From: cruldra Date: Tue, 15 Sep 2026 00:11:49 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E6=8C=82=E5=8E=9F=E5=9E=8B=E6=8F=92=E4=BB=B6=EF=BC=8C=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=9D=A2=E6=9D=BF=E4=B8=80=E9=94=AE=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=20Figma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- vscode/prompts/brainstorm-continue.md | 2 + vscode/prompts/prototype.md | 2 + vscode/src/cc/prompts.ts | 19 +- vscode/src/figma/importPlugin.ts | 196 ++++++++++++++++++ vscode/src/figma/localExtensions.test.ts | 115 ++++++++++ vscode/src/figma/localExtensions.ts | 116 +++++++++++ vscode/src/gitea/issueLoader.ts | 19 ++ vscode/src/gitea/stateJson.ts | 2 +- vscode/src/gitea/types.ts | 5 + vscode/src/panel/KanbanPanel.ts | 10 + vscode/src/panel/handlers/issues.ts | 3 +- vscode/src/panel/handlers/sessions.ts | 10 +- vscode/src/panel/messages.ts | 3 +- vscode/src/settings/store.ts | 13 +- vscode/src/webhook/coordinator.ts | 141 +++++++------ vscode/src/youtrack/issueLoader.ts | 1 + vscode/src/youtrack/stateComment.ts | 2 +- vscode/webview-ui/src/App.tsx | 2 + .../webview-ui/src/components/BottomTabs.tsx | 2 + .../src/components/IssueDetailPanel.tsx | 27 ++- .../src/components/SettingsModal.tsx | 7 +- vscode/webview-ui/src/hooks/useIssues.ts | 8 +- vscode/webview-ui/src/lib/messages.ts | 3 +- vscode/webview-ui/src/types.ts | 5 + 24 files changed, 628 insertions(+), 85 deletions(-) create mode 100644 vscode/src/figma/importPlugin.ts create mode 100644 vscode/src/figma/localExtensions.test.ts create mode 100644 vscode/src/figma/localExtensions.ts diff --git a/vscode/prompts/brainstorm-continue.md b/vscode/prompts/brainstorm-continue.md index 0a462ca..6961d78 100644 --- a/vscode/prompts/brainstorm-continue.md +++ b/vscode/prompts/brainstorm-continue.md @@ -2,6 +2,8 @@ 用 tea 命令找工单,用 `ecc:grilling` 技能讨论细节,用表单形式和我交互,每次只问一个问题(成组的问题也拆成一次一问)。 +{prototypeRef} + ## 产物与顺序 1. 需求讨论到共识后,用 Skill 工具调 `ecc:spec` 写设计文档到 `.claude/specs/.md`,让我确认。 diff --git a/vscode/prompts/prototype.md b/vscode/prompts/prototype.md index e97a422..f08c1ab 100644 --- a/vscode/prompts/prototype.md +++ b/vscode/prompts/prototype.md @@ -1 +1,3 @@ /ecc:figma-plugin-prototyping 给 {issueNumber} 号工单画原型:先读工单正文锁定要画的界面和状态,再写本地 Figma 插件把画板和点击流做出来。画完汇报插件路径和怎么导入,不要改业务代码。 + +画完用 `opencli spx issue marker --issue {issueNumber} --type prototype --value ` 把原型插件挂到工单上,再汇报。 diff --git a/vscode/src/cc/prompts.ts b/vscode/src/cc/prompts.ts index 54e082f..7a47184 100644 --- a/vscode/src/cc/prompts.ts +++ b/vscode/src/cc/prompts.ts @@ -29,12 +29,27 @@ export function getBrainstormPrompt( 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 // 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 // `opencli yt issues show `), 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 { diff --git a/vscode/src/figma/importPlugin.ts b/vscode/src/figma/importPlugin.ts new file mode 100644 index 0000000..7f3b618 --- /dev/null +++ b/vscode/src/figma/importPlugin.ts @@ -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 { + const { stdout } = await run('pgrep', ['-x', 'Figma']) + return stdout.trim().length > 0 +} + +/** + * 优雅退出 Figma 并轮询等它真的没了。超时返回 false(调用方报错让用户手动关), + * 不强杀——Figma 被 SIGKILL 会丢用户没保存的画布。 + */ +async function quitFigmaAndWait(): Promise { + 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 { + 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 { + 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 + 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 + } + 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 里跑它`, + ) +} diff --git a/vscode/src/figma/localExtensions.test.ts b/vscode/src/figma/localExtensions.test.ts new file mode 100644 index 0000000..95f1ec2 --- /dev/null +++ b/vscode/src/figma/localExtensions.test.ts @@ -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) + }) +}) diff --git a/vscode/src/figma/localExtensions.ts b/vscode/src/figma/localExtensions.ts new file mode 100644 index 0000000..30cf102 --- /dev/null +++ b/vscode/src/figma/localExtensions.ts @@ -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 } +} diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 09b7b8f..4e24852 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -45,6 +45,19 @@ export function isValidSpxFilePath(v: unknown): v is string { && 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 { return typeof v === 'string' && /^docs\/pr-diff\/\S+\.md$/.test(v) @@ -73,6 +86,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { prototypeProfilePath?: string specFile?: string planFile?: string + prototypeFile?: string prDiffFile?: string pr?: string prMerged?: boolean @@ -121,6 +135,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { prototypeProfilePath?: unknown specFile?: unknown planFile?: unknown + prototypeFile?: unknown prDiffFile?: unknown pr?: unknown prMerged?: unknown @@ -155,6 +170,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { : undefined const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : 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 pr = typeof obj.pr === 'string' && obj.pr.length > 0 ? obj.pr @@ -205,6 +221,7 @@ function parseColumnFromComments(comments: GiteaComment[]): { prototypeProfilePath, specFile, planFile, + prototypeFile, prDiffFile, pr, prMerged, @@ -348,6 +365,7 @@ async function buildIssue(opts: { prototypeProfilePath, specFile, planFile, + prototypeFile, prDiffFile, pr, prMerged: persistedMerged, @@ -412,6 +430,7 @@ async function buildIssue(opts: { ...(prototypeProfilePath ? { prototypeProfilePath } : {}), ...(specFile ? { specFile } : {}), ...(planFile ? { planFile } : {}), + ...(prototypeFile ? { prototypeFile } : {}), ...(prDiffFile ? { prDiffFile } : {}), ...(pr ? { pr } : {}), ...(prMerged !== undefined ? { prMerged } : {}), diff --git a/vscode/src/gitea/stateJson.ts b/vscode/src/gitea/stateJson.ts index a85ab55..cf1a649 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', '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。 diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index 4661d6a..beb214b 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -59,6 +59,11 @@ export interface Issue { * `.claude/plans/.md`. */ planFile?: string + /** + * 原型会话产出的 Figma 插件 `manifest.json` 路径(仓库相对或绝对)。 + * cc 画完原型后用 `spx issue marker --type prototype` 挂上来。 + */ + prototypeFile?: string /** Optional workspace-relative path to the generated PR diff summary. */ prDiffFile?: string /** PR number associated with this issue (set after webhook fires). */ diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 5be552e..1bd1e7e 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -16,6 +16,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode' import { deleteToken, getToken } from '../auth/secrets' +import { importPluginIntoFigma } from '../figma/importPlugin' import { detectRepo } from '../git/remote' import { GiteaApiError, @@ -464,6 +465,15 @@ export class KanbanWebviewPanel { void issues.handleOpenFile(this, msg.path) 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') { void this.handleImplement(msg.issueNumber, msg.planFile, msg.profilePath, msg.sessionId) return diff --git a/vscode/src/panel/handlers/issues.ts b/vscode/src/panel/handlers/issues.ts index 520c73a..9a3a722 100644 --- a/vscode/src/panel/handlers/issues.ts +++ b/vscode/src/panel/handlers/issues.ts @@ -2314,7 +2314,8 @@ export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string) }) return } - const abs = path.join(workspaceRoot, relPath) + // 多数字段存的是仓库相对路径,但 prototypeFile 允许绝对路径(插件可能在仓库外)。 + const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath) const isMarkdown = abs.toLowerCase().endsWith('.md') try { if (isMarkdown) { diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index 0f9f4d5..dff6048 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -947,12 +947,16 @@ export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueN // 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。 let brainstormProfilePath: string | undefined let sessionId: string | undefined + // 工单已有原型插件时,续聊提示词里让 cc 先读它,别把已经画定的界面又讨论一遍。 + let prototypeFile: string | undefined try { const stateObj = await panel.readIssueState(issueNumber) if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0) brainstormProfilePath = stateObj.brainstormProfilePath if (stateObj && typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0) sessionId = stateObj.sessionId + if (stateObj && typeof stateObj.prototypeFile === 'string' && stateObj.prototypeFile.length > 0) + prototypeFile = stateObj.prototypeFile } catch (err) { 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}-规划` // 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。 const linked = panel.newIssueTerminals.get(issueNumber) diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 2725d36..c18b477 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, 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。 */ | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } @@ -203,6 +203,7 @@ export type WebviewToExtension | { type: 'session/start-review', issueNumber: number } | { type: 'session/start-test', issueNumber: number } | { type: 'session/start-prototype', issueNumber: number } + | { type: 'prototype/open-in-figma', issueNumber: number, path: string } | { type: 'editor/open-file', path: string } | { type: 'profiles/list' } | { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string } diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index 2c6141c..7580c61 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -26,7 +26,11 @@ import { normalizeReleasePrefix } from '../git/release' */ const FALLBACK_BRAINSTORM_PROMPT = `我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。工单 body 末尾必须包含 。创建完工单立即停下汇报,不要擅自实施。` -const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumber} 号工单。达成共识后用 ecc:spec 写 .claude/specs/.md,我确认后用 ecc:plan 出 .claude/plans/.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 PR。` +const FALLBACK_BRAINSTORM_CONTINUE_PROMPT = `/ecc:grilling 讨论下 {issueNumber} 号工单。 + +{prototypeRef} + +达成共识后用 ecc:spec 写 .claude/specs/.md,我确认后用 ecc:plan 出 .claude/plans/.md,两个文件在 main 上提交并用 spx issue marker 同步 marker。不要 git checkout / 不要写代码 / 不要建 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 第一行写 。` -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 \` 把原型插件挂到工单上,再汇报。` 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 * (when the user later wants to discuss / write spec/plan in the panel). - * `{issueNumber}` placeholder. + * 占位符 `{issueNumber}` / `{prototypeRef}`(后者由扩展按工单有无原型插件填, + * 无则连同所在段落一起删掉)。 */ brainstormContinuePrompt: string /** Prompt template for the implement-plan flow. `{planFile}` placeholder. */ diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 2a8df8a..4ae6961 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -48,6 +48,38 @@ export interface PendingHook { feature: string } +/** + * issue body 里的 spx marker → state JSON 字段。三种 marker 走同一套同步 + * 逻辑,这张表是唯一真源(与 cli 的 markerTypeToStateField 对应)。 + * + * 路径必须含 `/` 且以对应后缀结尾:cc 在头脑风暴阶段会往 body 里写 + * `` 这种占位说明文,不卡死就会被当成真路径存进去。 + */ +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``) + 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 * 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 `` - * / `` annotation lines as it discovers spec/plan files. - * We scan the body for those markers, diff against the state-JSON comment, - * and (if anything changed) merge the new paths in + patch the open panel - * so the detail view refreshes without a full kanban reload. + * Issue `edited` payload: cc 会往 issue body 里追加 `` + * / `` / `` 三种 marker。 + * 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对, + * 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。 */ private async handleIssueEdited(event: IssueWebhookEvent): Promise { if (!this.ctx) @@ -817,17 +848,12 @@ class WebhookCoordinator { return } - // 路径必须含 `/` 且以 `.md` 结尾,避免 cc 写的 `` 占位被当真。 - const specMatch = event.body.match(//) - const planMatch = event.body.match(//) - const specFile = specMatch ? specMatch[1] : undefined - const planFile = planMatch ? planMatch[1] : undefined - - if (!specFile && !planFile) { + const found = parseBodyMarkers(event.body) + if (Object.keys(found).length === 0) { logger.add({ level: 'info', source: 'webhook', - message: `issue=#${event.issueNumber} edited body 无 spx:spec/spx:plan 注释,跳过`, + message: `issue=#${event.issueNumber} edited body 无 spx marker 注释,跳过`, }) return } @@ -852,15 +878,19 @@ class WebhookCoordinator { }) } - const currentSpec = typeof currentState.specFile === 'string' ? currentState.specFile : undefined - const currentPlan = typeof currentState.planFile === 'string' ? currentState.planFile : undefined - - // cc only adds or updates; it never deletes. Treat an absent marker - // (undefined) as "no signal", not "clear" — that way deleting a spec - // line by mistake won't lose the existing pointer. - const nextSpec = specFile ?? currentSpec - const nextPlan = planFile ?? currentPlan - if (currentSpec === nextSpec && currentPlan === nextPlan) { + // cc 只增不删:body 里没有某个 marker 当「没信号」,不当「清空」, + // 免得手滑删掉一行就把已有指针丢了。 + const next: MarkerPatch = {} + let changed = false + for (const { field } of BODY_MARKERS) { + const current = typeof currentState[field] === 'string' ? currentState[field] as string : undefined + const value = found[field] ?? current + if (value) + next[field] = value + if (value !== current) + changed = true + } + if (!changed) { // Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment // 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠 // coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去, @@ -868,24 +898,17 @@ class WebhookCoordinator { logger.add({ level: 'info', 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 } - // spec/plan 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据 + // marker 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据 // 额外认规划终端。observe 机器只把新值推给本机 webview。 const ownership = await this.resolveOwnership( event.issueNumber, @@ -893,27 +916,14 @@ class WebhookCoordinator { ['实施', '测试', '规划'], ) if (ownership === 'observe') { - if (this.activePanel) { - const patch: { specFile?: string, planFile?: string } = {} - if (specFile) - patch.specFile = specFile - if (planFile) - patch.planFile = planFile - this.activePanel.postMessage({ - type: 'issue/patch', - issueNumber: event.issueNumber, - patch, - }) - } + this.activePanel?.postMessage({ + type: 'issue/patch', + issueNumber: event.issueNumber, + patch: found, + }) return } - const extra: Record = {} - if (specFile) - extra.specFile = specFile - if (planFile) - extra.planFile = planFile - try { await mergeStateJsonComment({ host: remote.host, @@ -921,7 +931,7 @@ class WebhookCoordinator { repo: remote.repo, token, issueNumber: event.issueNumber, - extra, + extra: found, }) } catch (err) { @@ -938,22 +948,15 @@ class WebhookCoordinator { logger.add({ level: 'info', source: 'webhook', - message: `issue=#${event.issueNumber} edited spec/plan 已同步`, - details: `spec=${specFile ?? '<未变>'} plan=${planFile ?? '<未变>'}`, + message: `issue=#${event.issueNumber} edited marker 已同步`, + details: describeMarkers(found), }) - if (this.activePanel) { - const patch: { specFile?: string, planFile?: string } = {} - if (specFile) - patch.specFile = specFile - if (planFile) - patch.planFile = planFile - this.activePanel.postMessage({ - type: 'issue/patch', - issueNumber: event.issueNumber, - patch, - }) - } + this.activePanel?.postMessage({ + type: 'issue/patch', + issueNumber: event.issueNumber, + patch: found, + }) } /** diff --git a/vscode/src/youtrack/issueLoader.ts b/vscode/src/youtrack/issueLoader.ts index aac719c..66461ca 100644 --- a/vscode/src/youtrack/issueLoader.ts +++ b/vscode/src/youtrack/issueLoader.ts @@ -75,6 +75,7 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue { prototypeProfilePath: str(state.prototypeProfilePath), specFile: str(state.specFile), planFile: str(state.planFile), + prototypeFile: str(state.prototypeFile), prDiffFile: str(state.prDiffFile), pr: str(state.pr), branch: str(state.branch), diff --git a/vscode/src/youtrack/stateComment.ts b/vscode/src/youtrack/stateComment.ts index 63a180a..102bdec 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', '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 diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 84ba0c1..31446ff 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -49,6 +49,7 @@ export function App() { resumePrototypeSession, focusSession, openFile, + openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, @@ -386,6 +387,7 @@ export function App() { onStartPrototypeSession={startPrototypeSession} onResumePrototypeSession={resumePrototypeSession} onOpenFile={openFile} + onOpenPrototypeInFigma={openPrototypeInFigma} onImplement={implement} onOpenPr={openPr} onGeneratePrDiffSummary={generatePrDiffSummary} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index 198a4e1..efee702 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -37,6 +37,7 @@ interface BottomTabsProps { onStartPrototypeSession: (issueNumber: number) => void onResumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void onOpenFile: (path: string) => void + onOpenPrototypeInFigma: (issueNumber: number, path: string) => void onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void onOpenPr: (pr: string) => void onGeneratePrDiffSummary: (issueNumber: number) => void @@ -140,6 +141,7 @@ export function BottomTabs(props: BottomTabsProps) { onStartPrototypeSession={props.onStartPrototypeSession} onResumePrototypeSession={props.onResumePrototypeSession} onOpenFile={props.onOpenFile} + onOpenPrototypeInFigma={props.onOpenPrototypeInFigma} onImplement={props.onImplement} onOpenPr={props.onOpenPr} onGeneratePrDiffSummary={props.onGeneratePrDiffSummary} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index cace5a9..51fe728 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -69,6 +69,11 @@ interface IssueDetailPanelProps { onStartPrototypeSession: (issueNumber: number) => void /** Open a workspace-relative file in the editor. */ 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. */ onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void /** Open the gitea PR page in the browser. */ @@ -151,6 +156,7 @@ export function IssueDetailPanel({ onStartTestSession, onStartPrototypeSession, onOpenFile, + onOpenPrototypeInFigma, onImplement, onOpenPr, onGeneratePrDiffSummary, @@ -567,6 +573,24 @@ export function IssueDetailPanel({ }] : [], }, + { + key: 'prototypeFile', + label: '原型插件路径', + type: 'file-link', + description: '双击在编辑器打开 Figma 插件的 manifest.json;点右侧按钮把插件导入本机 Figma 并打开;cc 通过 issue body 的 注释同步', + onOpen: (p: string) => onOpenFile(p), + // 没挂原型插件时整个按钮不渲染,行只显示空态。 + secondaryActions: issue?.prototypeFile + ? [{ + icon: , + title: '在 Figma 中导入并打开', + onClick: () => { + if (issue?.prototypeFile) + onOpenPrototypeInFigma(issue.number, issue.prototypeFile) + }, + }] + : [], + }, { key: 'pr', 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, specFile: issue.specFile ?? null, planFile: issue.planFile ?? null, + prototypeFile: issue.prototypeFile ?? null, prDiffFile: issue.prDiffFile ?? null, pr: issue.pr ? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}` diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 522c2c3..66815aa 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -994,10 +994,11 @@ export function SettingsModal({ label="头脑风暴续聊提示词" hint={( <> - 可用占位符 - {' '} + 可用占位符: {'{issueNumber}'} - ;用于对已有工单继续讨论 / 写 spec/plan 的会话初始指令。 + 、 + {'{prototypeRef}'} + (工单挂了原型插件才填,没挂时该段整段删掉);用于对已有工单继续讨论 / 写 spec/plan 的会话初始指令。 )} > diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 1e4ea2b..c64fa9f 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -200,6 +200,8 @@ export interface UseIssuesResult { resumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void focusSession: (issueNumber: number) => void openFile: (path: string) => void + /** 把工单的 Figma 插件导入本机 Figma 桌面版并拉起 Figma。 */ + openPrototypeInFigma: (issueNumber: number, path: string) => void implement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void generatePrDiffSummary: (issueNumber: number) => void isPrDiffSummaryRunning: (issueNumber: number) => boolean @@ -588,6 +590,10 @@ export function useIssues(): UseIssuesResult { 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(( issueNumber: number, planFile: string, @@ -1037,5 +1043,5 @@ export function useIssues(): UseIssuesResult { return cleanup }, [clearPrDiffSummaryRunning, markHandoffRunning]) - return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, 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 } } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 8b7a09f..45817f1 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -83,7 +83,7 @@ export type ExtensionToWebview | { type: 'issues/loaded' } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record } | { 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。 */ | { type: 'session/tab-settled', sessionId: string } | { type: 'issue/pr-diff-summary-done', issueNumber: number } @@ -234,6 +234,7 @@ export type WebviewToExtension | { type: 'session/start-review', issueNumber: number } | { type: 'session/start-test', issueNumber: number } | { type: 'session/start-prototype', issueNumber: number } + | { type: 'prototype/open-in-figma', issueNumber: number, path: string } | { type: 'editor/open-file', path: string } | { type: 'profiles/list' } | { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string } diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index 9adfbab..b90e226 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -40,6 +40,11 @@ export interface Issue { * `.claude/plans/.md`. */ planFile?: string + /** + * 原型会话产出的 Figma 插件 `manifest.json` 路径(仓库相对或绝对)。 + * cc 画完原型后用 `spx issue marker --type prototype` 挂上来。 + */ + prototypeFile?: string /** Optional workspace-relative path to the generated PR diff summary. */ prDiffFile?: string /** PR number associated with this issue (set after webhook fires). */