diff --git a/vscode/package.json b/vscode/package.json index 1bcd78b..f2f2efe 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "publisher": "clurdra", "name": "superpowers-vscode-clurdra", "displayName": "Superpowers-clurdra", - "version": "0.2.116", + "version": "0.2.117", "packageManager": "pnpm@10.27.0", "description": "Superpowers specs and plans Kanban explorer", "author": "clurdra", diff --git a/vscode/src/cc/ccCommand.ts b/vscode/src/cc/ccCommand.ts index 8337738..1041c53 100644 --- a/vscode/src/cc/ccCommand.ts +++ b/vscode/src/cc/ccCommand.ts @@ -16,9 +16,13 @@ export interface CcCommandOpts { profilePath: string /** e.g. 'high' → `--effort high`; omitted means no `--effort`. */ effort?: string - /** When set, append `--resume `. Mutually exclusive with prompt; resume wins. */ + /** When set, append `--resume ` so cc restores that conversation. */ resumeSessionId?: string - /** When set (and no resume), append the prompt as a positional `''` arg. */ + /** + * When set, append the prompt as a positional `''` arg. Combined + * with `resumeSessionId` it is submitted as the next message of the + * restored conversation. + */ prompt?: string } @@ -31,11 +35,9 @@ export function buildCcCommand(opts: CcCommandOpts): string { if (opts.effort) parts.push(`--effort ${opts.effort}`) parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`) - // resume and prompt are mutually exclusive; a resumed session restores its - // own conversation, so a fresh prompt would be meaningless. if (opts.resumeSessionId) parts.push(`--resume ${opts.resumeSessionId}`) - else if (opts.prompt) + if (opts.prompt) parts.push(`'${opts.prompt}'`) return parts.join(' ') } diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index a82f95b..950dcfc 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -562,8 +562,8 @@ export class KanbanWebviewPanel { void issues.handleCloseIssue(this, msg.issueNumber) return } - if (msg.type === 'brainstorm/start') { - void sessions.handleStartBrainstormSession(this, msg.issueNumber) + if (msg.type === 'brainstorm/continue') { + void sessions.handleContinueBrainstorm(this, msg.issueNumber) return } if (msg.type === 'profiles/get') { diff --git a/vscode/src/panel/brainstormContinue.ts b/vscode/src/panel/brainstormContinue.ts new file mode 100644 index 0000000..6a4b51f --- /dev/null +++ b/vscode/src/panel/brainstormContinue.ts @@ -0,0 +1,14 @@ +/** + * 「续聊」按钮的三条路径。终端还活着就直接把提示词打进去(不管有没有 + * 记录到 sessionId,新建工单流程里 id 可能还没落盘);终端没了但有 + * sessionId 就 `--resume` 并带上提示词;两者都没有才新开会话。 + */ +export type BrainstormContinuePath = 'inject' | 'resume' | 'fresh' + +export function pickBrainstormContinuePath(opts: { liveTerminal: boolean, sessionId: string | undefined }): BrainstormContinuePath { + if (opts.liveTerminal) + return 'inject' + if (opts.sessionId) + return 'resume' + return 'fresh' +} diff --git a/vscode/src/panel/handlers/sessions.ts b/vscode/src/panel/handlers/sessions.ts index fd7911f..bc170b4 100644 --- a/vscode/src/panel/handlers/sessions.ts +++ b/vscode/src/panel/handlers/sessions.ts @@ -22,8 +22,10 @@ import { logger } from '../../logging/logger' import { getSettings } from '../../settings/store' import { webhookCoordinator } from '../../webhook/coordinator' import { decideAutoReviewPath } from '../autoReviewDecision' +import { pickBrainstormContinuePath } from '../brainstormContinue' import { makeNonce } from '../KanbanPanel' import { cleanupFeatureBranch } from './branchCleanup' +import { submitToSessionTerminal } from './terminals' export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise { // kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造 @@ -446,15 +448,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { terminal.show(false) // codex TUI already running inside this tab; sending the full // startup command would be received as a long redundant user message. - // Mirror the two-step submit pattern from injectIntoImplTerminal: - // push the body first, then send a standalone \r 250ms later so codex - // TUI registers it as Enter (single-shot \r packed with the body is - // sometimes consumed as a newline, not a submit). - const body = 'PR 更新了,再审一下' - terminal.sendText(body, false) - setTimeout(() => { - terminal.sendText('\r', false) - }, 250) + submitToSessionTerminal(terminal, 'PR 更新了,再审一下') logger.add({ level: 'info', source: 'terminal', @@ -493,12 +487,8 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: { // before the TUI accepts input — empirically much slower than the // 250ms gap the reuse path uses. 8s is a conservative guess that // covers a cold codex start without feeling laggy. - const body = 'PR 更新了,再审一下' setTimeout(() => { - terminal.sendText(body, false) - setTimeout(() => { - terminal.sendText('\r', false) - }, 250) + submitToSessionTerminal(terminal, 'PR 更新了,再审一下') }, 8000) logger.add({ level: 'info', @@ -954,34 +944,13 @@ export async function handleImplement( } /** - * Start a fresh "规划" (brainstorm continuation) cc tab for an existing - * issue whose body has no sessionId yet. Used when the issue was created - * outside the panel (e.g. user ran spx from another cc session, body - * lacks the nonce marker so the webhook took the "external" branch and - * didn't link a terminal). Click the Play button next to the empty - * "头脑风暴会话 id" row to spawn a new cc session anchored to this issue. - * - * Differs from `handleImplement`: - * - No worktree / no feature branch (this is discussion, not coding). - * - cwd = workspaceRoot (not a worktree). - * - Watches the workspace-root `~/.claude/projects/...` dir for the new - * session jsonl and writes the captured id back as `sessionId` - * (not `implementSessionId`). + * 详情面板「▶ 续聊」入口:把续聊 prompt 送进这个工单的头脑风暴会话。 + * - 规划终端还开着 → 直接打进去(含新建工单流程留下的 issue-new-* tab) + * - 终端关了但 state JSON 有 sessionId → `--resume ` 并带上 prompt + * - 两者都没有 → 新开会话,捕获到的 id 写回 `sessionId` + * 全程在 workspaceRoot,不建 worktree / 分支(这是讨论,不是编码)。 */ -export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, issueNumber: number): Promise { - const terminalName = `issue-${issueNumber}-规划` - const existing = panel.findExistingTerminal(terminalName) - if (existing) { - panel.trackSessionTerminal(existing, issueNumber, 'brainstorm') - existing.show(false) - logger.add({ - level: 'info', - source: 'brainstorm', - message: `复用已有规划终端 #${issueNumber},跳过 cc 启动`, - }) - return - } - +export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueNumber: number): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) { void window.showErrorMessage('请先打开一个工作区文件夹') @@ -1000,10 +969,13 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is // 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。 let brainstormProfilePath: string | undefined + let sessionId: 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 } catch (err) { logger.add({ @@ -1014,32 +986,50 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is }) } + const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId }) + const terminalName = `issue-${issueNumber}-规划` + // 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。 + const linked = panel.newIssueTerminals.get(issueNumber) + const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName) + const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId }) + + if (continuePath === 'inject' && live) { + panel.trackSessionTerminal(live, issueNumber, 'brainstorm') + live.show(false) + submitToSessionTerminal(live, prompt) + logger.add({ + level: 'info', + source: 'brainstorm', + message: `已把续聊 prompt 打进 #${issueNumber} 的规划终端`, + }) + return + } + const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath) if (effectiveProfilePath.includes('\'')) { void window.showErrorMessage( - `启动规划失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, + `续聊失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, ) return } - - const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId }) if (prompt.includes('\'')) { - void window.showErrorMessage('启动规划失败:prompt 含单引号,拒绝执行') + void window.showErrorMessage('续聊失败:prompt 含单引号,拒绝执行') return } - // Ensure the projects dir exists so the watcher can't miss the create - // event. Workspace root (no worktree here). - const projDir = projectsDirFor(workspaceRoot) - try { - await fsp.mkdir(projDir, { recursive: true }) + // resume 沿用原 id,只有新开会话才要盯着 session jsonl 出现; + // 监听要在 createTerminal 之前挂上,否则会漏掉创建事件。 + let watchPromise: ReturnType | undefined + if (continuePath === 'fresh') { + const projDir = projectsDirFor(workspaceRoot) + try { + await fsp.mkdir(projDir, { recursive: true }) + } + catch (err) { + console.warn('[superpowers] failed to mkdir claude projects dir:', err) + } + watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 }) } - catch (err) { - console.warn('[superpowers] failed to mkdir claude projects dir:', err) - } - - // Kick off the watcher before spawning the terminal so we don't race. - const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 }) const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber) const terminal = window.createTerminal({ @@ -1049,6 +1039,8 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is iconPath: iconUri, color: themeColor, }) + if (continuePath === 'resume' && sessionId) + panel.terminals.set(sessionId, terminal) panel.trackSessionTerminal(terminal, issueNumber, 'brainstorm') terminal.show(false) logger.add({ @@ -1057,18 +1049,21 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is message: `已创建终端 "${terminal.name}"`, }) - const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt }) - terminal.sendText(cmd) + terminal.sendText(buildCcCommand({ + profilePath: effectiveProfilePath, + resumeSessionId: continuePath === 'resume' ? sessionId : undefined, + prompt, + })) logger.add({ level: 'info', source: 'brainstorm', - message: `已发送规划 prompt 到终端 #${issueNumber}`, + message: continuePath === 'resume' + ? `已带续聊 prompt 恢复 #${issueNumber} 规划会话 ${sessionId}` + : `已发送规划 prompt 到终端 #${issueNumber}`, }) - // Fire-and-forget: when the session jsonl materializes, persist the id - // as the issue's brainstorm `sessionId` so the X / Play button toggles - // and the user can later resume via the existing UI. - watchPromise.then(async (sid) => { + // 拿到 session jsonl 后把 id 写回 state JSON,详情面板据此切到 resume 入口。 + watchPromise?.then(async (sid) => { if (!sid) { logger.add({ level: 'warn', @@ -1104,7 +1099,7 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is * 手动启动一个"测试"cc 会话:工单 PR 合并后,用户在详情面板点测试会话行的 * 启动按钮,开一个 claude tab 让 cc 先了解代码、再告诉用户怎么测试。 * - * 与 handleStartBrainstormSession 的差异: + * 与 handleContinueBrainstorm 的差异: * - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。 * - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码, * profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。 diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 2a74786..67bb6be 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -56,19 +56,25 @@ export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: n } if (!terminal) return false - // cc 的 TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r) - // 才会被识别为 Enter(提交消息)。实测把多行内容 + 末尾 \r 在同一次 - // sendText 里发出去时,cc 进入多行输入模式后并不会把紧跟的 \r 当成 - // 提交键,结果就是反馈只粘贴到输入框、没提交。 - // 拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。 - const body = `\n[审查反馈]\n${text}` - terminal.sendText(body, false) - setTimeout(() => { - terminal!.sendText('\r', false) - }, 250) + submitToSessionTerminal(terminal, `\n[审查反馈]\n${text}`) return true } +/** + * 往正在跑 cc / codex TUI 的终端里提交一条消息。 + * + * TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r) 才会被识别为 + * Enter。实测多行内容 + 末尾 \r 在同一次 sendText 里发出去时,TUI 进入 + * 多行输入模式后不会把紧跟的 \r 当成提交键,内容只粘贴到输入框、没提交。 + * 所以拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。 + */ +export function submitToSessionTerminal(terminal: Terminal, text: string): void { + terminal.sendText(text, false) + setTimeout(() => { + terminal.sendText('\r', false) + }, 250) +} + /** * 本机是否开着该工单的存活终端(exitStatus undefined)——webhook 门禁对 * 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端 diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 535bbf7..a30d437 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -220,7 +220,7 @@ export type WebviewToExtension | { type: 'env-lock/toggle' } | { type: 'issue/delete', issueNumber: number } | { type: 'issue/close', issueNumber: number } - | { type: 'brainstorm/start', issueNumber: number } + | { type: 'brainstorm/continue', issueNumber: number } | { type: 'profiles/get' } | { type: 'profiles/save', data: ProfilesData } | { type: 'profiles/open', value: string } diff --git a/vscode/test/cc/ccCommand.test.ts b/vscode/test/cc/ccCommand.test.ts new file mode 100644 index 0000000..9d4d83b --- /dev/null +++ b/vscode/test/cc/ccCommand.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildCcCommand } from '../../src/cc/ccCommand' + +vi.mock('../../src/cc/claudeBin', () => ({ + resolveClaudeBin: () => 'claude', +})) + +describe('buildCcCommand', () => { + it('appends the prompt as a positional arg for a fresh session', () => { + expect(buildCcCommand({ profilePath: '/p.json', prompt: 'hello' })) + .toBe('claude --dangerously-skip-permissions --settings \'/p.json\' \'hello\'') + }) + + it('resumes without a prompt when only a session id is given', () => { + expect(buildCcCommand({ profilePath: '/p.json', resumeSessionId: 'abc' })) + .toBe('claude --dangerously-skip-permissions --settings \'/p.json\' --resume abc') + }) + + it('resumes and hands the prompt to the restored session', () => { + expect(buildCcCommand({ profilePath: '/p.json', resumeSessionId: 'abc', prompt: 'go on' })) + .toBe('claude --dangerously-skip-permissions --settings \'/p.json\' --resume abc \'go on\'') + }) +}) diff --git a/vscode/test/panel/brainstormContinue.test.ts b/vscode/test/panel/brainstormContinue.test.ts new file mode 100644 index 0000000..0519577 --- /dev/null +++ b/vscode/test/panel/brainstormContinue.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { pickBrainstormContinuePath } from '../../src/panel/brainstormContinue' + +describe('pickBrainstormContinuePath', () => { + it('types the prompt into the live terminal when the brainstorm tab is still open', () => { + expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: 'abc' })).toBe('inject') + }) + + it('resumes the recorded session with the prompt when the tab is closed', () => { + expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: 'abc' })).toBe('resume') + }) + + it('starts a fresh session when the issue has no brainstorm session yet', () => { + expect(pickBrainstormContinuePath({ liveTerminal: false, sessionId: undefined })).toBe('fresh') + }) + + it('prefers the live terminal even when no session id has been recorded yet', () => { + expect(pickBrainstormContinuePath({ liveTerminal: true, sessionId: undefined })).toBe('inject') + }) +}) diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index e0dac38..4e74f99 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -53,7 +53,7 @@ export function App() { deleteIssue, closeIssue, closeSessionTab, - startBrainstormSession, + continueBrainstorm, changeColumn, setDependency, clearDependency, @@ -381,7 +381,7 @@ export function App() { onCloseIssue={closeIssue} onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)} onCloseSessionTab={closeSessionTab} - onStartBrainstormSession={startBrainstormSession} + onContinueBrainstorm={continueBrainstorm} onUpdateAutoReview={updateIssueAutoReview} onUpdateProfilePath={updateIssueProfilePath} onUpdateBrainstormProfilePath={updateIssueBrainstormProfilePath} diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index b9a28c5..19ab5bc 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -44,7 +44,7 @@ interface BottomTabsProps { onCloseIssue: (issueNumber: number) => void onResetToTodo: (issueNumber: number) => void onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void - onStartBrainstormSession: (issueNumber: number) => void + onContinueBrainstorm: (issueNumber: number) => void onUpdateAutoReview: (issueNumber: number, value: boolean) => void onUpdateProfilePath: (issueNumber: number, profilePath: string) => void onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void @@ -139,7 +139,7 @@ export function BottomTabs(props: BottomTabsProps) { onCloseIssue={props.onCloseIssue} onResetToTodo={props.onResetToTodo} onCloseSessionTab={props.onCloseSessionTab} - onStartBrainstormSession={props.onStartBrainstormSession} + onContinueBrainstorm={props.onContinueBrainstorm} onUpdateAutoReview={props.onUpdateAutoReview} onUpdateProfilePath={props.onUpdateProfilePath} onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath} diff --git a/vscode/webview-ui/src/components/IssueDetailPanel.tsx b/vscode/webview-ui/src/components/IssueDetailPanel.tsx index 0eae89c..47ce25f 100644 --- a/vscode/webview-ui/src/components/IssueDetailPanel.tsx +++ b/vscode/webview-ui/src/components/IssueDetailPanel.tsx @@ -1,7 +1,6 @@ -import type { ReactNode } from 'react' import type { ClaudeProfile } from '../hooks/useIssues' import type { Issue, IssueColumn } from '../types' -import type { PropertyGroup } from './property-grid' +import type { PropertyGroup, RowAction } from './property-grid' import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react' import { useMemo, useRef } from 'react' import { isIssueLocked } from '../lib/dependencies' @@ -87,11 +86,10 @@ interface IssueDetailPanelProps { */ onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void /** - * Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty - * (created out-of-band via spx without webhook linking). Bound to the Play - * button next to the "头脑风暴会话 id" row. + * 「▶ 续聊」:由插件把续聊提示词送进头脑风暴会话——规划 tab 开着就直接 + * 打进去,关了就 `claude --resume ` 带着提示词恢复,没会话就新开一个。 */ - onStartBrainstormSession: (issueNumber: number) => void + onContinueBrainstorm: (issueNumber: number) => void /** Persist a per-issue `autoReview` override into the state JSON. */ onUpdateAutoReview: (issueNumber: number, value: boolean) => void /** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */ @@ -140,7 +138,7 @@ export function IssueDetailPanel({ onCloseIssue, onResetToTodo, onCloseSessionTab, - onStartBrainstormSession, + onContinueBrainstorm, onUpdateAutoReview, profiles, onUpdateProfilePath, @@ -206,39 +204,39 @@ export function IssueDetailPanel({ })), }, (() => { - // 头脑风暴会话 id 行有三种状态: - // 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab - // 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮) - // 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab) - // 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮 + // 头脑风暴会话 id 行: + // - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话: + // 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。 + // - brainstormTabOpen → 再加一个 X 关闭 tab const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) - const canStart = !hasSession && issue?.column !== 'done' - let secondaryActionIcon: ReactNode | undefined - let secondaryActionTitle: string | undefined - let onSecondaryAction: (() => void) | undefined - if (hasSession && issue?.brainstormTabOpen) { - secondaryActionIcon = - secondaryActionTitle = '关闭头脑风暴 tab' - onSecondaryAction = () => { - if (issue) - onCloseSessionTab(issue.number, 'brainstorm') - } + const secondaryActions: RowAction[] = [] + if (issue?.column !== 'done') { + secondaryActions.push({ + icon: , + title: '续聊:把续聊提示词发给头脑风暴会话', + onClick: () => { + if (issue) + onContinueBrainstorm(issue.number) + }, + }) } - else if (canStart) { - secondaryActionIcon = - secondaryActionTitle = '启动头脑风暴会话' - onSecondaryAction = () => { - if (issue) - onStartBrainstormSession(issue.number) - } + if (hasSession && issue?.brainstormTabOpen) { + secondaryActions.push({ + icon: , + title: '关闭头脑风暴 tab', + onClick: () => { + if (issue) + onCloseSessionTab(issue.number, 'brainstorm') + }, + }) } return { key: 'sessionId', label: '头脑风暴会话id', type: 'action' as const, description: hasSession - ? '双击在新终端运行 claude --resume 恢复对话' - : '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab', + ? '双击在新终端运行 claude --resume 恢复对话;点 ▶ 续聊' + : '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊', actionIcon: , onAction: (v: unknown) => { if (typeof v !== 'string' || v.length === 0) @@ -247,9 +245,7 @@ export function IssueDetailPanel({ return onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number) }, - secondaryActionIcon, - secondaryActionTitle, - onSecondaryAction, + secondaryActions, } })(), // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口: @@ -271,14 +267,13 @@ export function IssueDetailPanel({ return onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number) }, - secondaryActionIcon: issue?.implementTabOpen - ? - : undefined, - secondaryActionTitle: '关闭实施 tab', - onSecondaryAction: () => { - if (issue) - onCloseSessionTab(issue.number, 'implement') - }, + secondaryActions: issue?.implementTabOpen + ? [{ + icon: , + title: '关闭实施 tab', + onClick: () => onCloseSessionTab(issue.number, 'implement'), + }] + : [], }, issue?.reviewSessionFileExists ? { @@ -294,14 +289,13 @@ export function IssueDetailPanel({ return onResumeReviewSession(v, issue.number, issue?.worktreePath) }, - secondaryActionIcon: issue?.reviewTabOpen - ? - : undefined, - secondaryActionTitle: '关闭审查 tab', - onSecondaryAction: () => { - if (issue) - onCloseSessionTab(issue.number, 'review') - }, + secondaryActions: issue.reviewTabOpen + ? [{ + icon: , + title: '关闭审查 tab', + onClick: () => onCloseSessionTab(issue.number, 'review'), + }] + : [], } : { key: 'reviewSessionId', @@ -318,24 +312,26 @@ export function IssueDetailPanel({ // (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮) const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) const canStart = !hasSession && !!issue?.pr - let secondaryActionIcon: ReactNode | undefined - let secondaryActionTitle: string | undefined - let onSecondaryAction: (() => void) | undefined + const secondaryActions: RowAction[] = [] if (hasSession && issue?.testTabOpen) { - secondaryActionIcon = - secondaryActionTitle = '关闭测试 tab' - onSecondaryAction = () => { - if (issue) - onCloseSessionTab(issue.number, 'test') - } + secondaryActions.push({ + icon: , + title: '关闭测试 tab', + onClick: () => { + if (issue) + onCloseSessionTab(issue.number, 'test') + }, + }) } else if (canStart) { - secondaryActionIcon = - secondaryActionTitle = '启动测试会话' - onSecondaryAction = () => { - if (issue) - onStartTestSession(issue.number) - } + secondaryActions.push({ + icon: , + title: '启动测试会话', + onClick: () => { + if (issue) + onStartTestSession(issue.number) + }, + }) } return { key: 'testSessionId', @@ -352,9 +348,7 @@ export function IssueDetailPanel({ return onResumeTestSession(v, issue.number) }, - secondaryActionIcon, - secondaryActionTitle, - onSecondaryAction, + secondaryActions, } })(), { @@ -427,26 +421,27 @@ export function IssueDetailPanel({ description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 注释实时同步', onOpen: (p: string) => onOpenFile(p), // Hide the "实施" button entirely when there's no plan file. - secondaryActionIcon: issue?.planFile - ? - : undefined, - secondaryActionTitle: locked ? lockTitle : '实施此计划', - // Disable while running or already done. The user can still click - // when status === 'failed' or never been run. Re-clicking while - // 'running' is blocked at the UI to avoid registering duplicate - // webhooks. To retry after a perceived failure with status still - // 'running', edit the state-JSON comment manually for now. - // 锁定态(前置工单未完成)同样禁止启动实施。 - secondaryDisabled: - !issue?.planFile - || issue?.implementStatus === 'running' - || issue?.implementStatus === 'done' - || locked, - onSecondaryAction: () => { - if (!issue?.planFile) - return - onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId) - }, + secondaryActions: issue?.planFile + ? [{ + icon: , + title: locked ? lockTitle : '实施此计划', + // Disable while running or already done. The user can still click + // when status === 'failed' or never been run. Re-clicking while + // 'running' is blocked at the UI to avoid registering duplicate + // webhooks. To retry after a perceived failure with status still + // 'running', edit the state-JSON comment manually for now. + // 锁定态(前置工单未完成)同样禁止启动实施。 + disabled: + issue.implementStatus === 'running' + || issue.implementStatus === 'done' + || locked, + onClick: () => { + if (!issue?.planFile) + return + onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId) + }, + }] + : [], }, { key: 'pr', @@ -461,17 +456,19 @@ export function IssueDetailPanel({ if (pr.length > 0) onOpenPr(pr) }, - secondaryActionIcon: issue?.pr && !issue?.prDiffFile - ? - : undefined, - secondaryActionTitle: prDiffSummaryRunning - ? 'PR 变更摘要生成中' - : '生成 PR 变更摘要', - secondaryDisabled: prDiffSummaryRunning, - onSecondaryAction: () => { - if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning) - onGeneratePrDiffSummary(issue.number) - }, + secondaryActions: issue?.pr && !issue?.prDiffFile + ? [{ + icon: , + title: prDiffSummaryRunning + ? 'PR 变更摘要生成中' + : '生成 PR 变更摘要', + disabled: prDiffSummaryRunning, + onClick: () => { + if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning) + onGeneratePrDiffSummary(issue.number) + }, + }] + : [], }, { key: 'prDiffFile', @@ -486,12 +483,16 @@ export function IssueDetailPanel({ type: 'string', readOnly: true, description: '实施流程创建的分支名', - secondaryActionIcon: issue?.branch ? : undefined, - secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)', - onSecondaryAction: () => { - if (issue?.branch) - onMergeBranch(issue.number, issue.branch) - }, + secondaryActions: issue?.branch + ? [{ + icon: , + title: '本地预合并 (git merge --no-commit --no-ff)', + onClick: () => { + if (issue?.branch) + onMergeBranch(issue.number, issue.branch) + }, + }] + : [], }, issue?.worktreeExists ? { @@ -500,9 +501,11 @@ export function IssueDetailPanel({ type: 'file-link', description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree', onOpen: (p: string) => onOpenWorktree(p), - onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''), - secondaryActionIcon: , - secondaryActionTitle: '删除 worktree', + secondaryActions: [{ + icon: , + title: '删除 worktree', + onClick: () => onDeleteWorktree(issue.number, issue.worktreePath ?? ''), + }], } : { key: 'worktreePath', @@ -514,7 +517,7 @@ export function IssueDetailPanel({ ], }, ], - [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles], + [onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles], ) const data = useMemo | null>(() => { diff --git a/vscode/webview-ui/src/components/property-grid.tsx b/vscode/webview-ui/src/components/property-grid.tsx index 9ed2b16..ef158fd 100644 --- a/vscode/webview-ui/src/components/property-grid.tsx +++ b/vscode/webview-ui/src/components/property-grid.tsx @@ -66,14 +66,41 @@ export interface PropertyDef { onReload?: () => void /** type='file-link' 专用:重新加载按钮的图标,默认为 RefreshCw */ reloadIcon?: ReactNode - /** type='file-link' / 'action' 通用:第二个右侧按钮的图标,缺省则不渲染该按钮 */ - secondaryActionIcon?: ReactNode - /** type='file-link' / 'action' 通用:第二个右侧按钮的 tooltip */ - secondaryActionTitle?: string - /** type='file-link' / 'action' 通用:第二个右侧按钮的点击回调 */ - onSecondaryAction?: () => void - /** type='file-link' / 'action' 通用:第二个右侧按钮是否禁用 */ - secondaryDisabled?: boolean + /** type='file-link' / 'action' / 'pr-link' / 只读 string 通用:值右侧的图标按钮,按数组顺序渲染 */ + secondaryActions?: RowAction[] +} + +/** 属性行右侧的图标按钮 */ +export interface RowAction { + icon: ReactNode + /** tooltip,同时作为 aria-label */ + title?: string + onClick: () => void + disabled?: boolean +} + +function RowActionButtons({ actions, className }: { actions: RowAction[] | undefined, className?: string }): ReactNode { + if (!actions || actions.length === 0) + return null + return actions.map((action, i) => ( + + )) } /** 属性分组定义 */ @@ -294,8 +321,7 @@ function PropertyRow({ ) case 'action': { - const hasSecondaryAction = propDef.secondaryActionIcon != null - const secondaryDisabledAction = !!propDef.secondaryDisabled + const hasSecondaryAction = (propDef.secondaryActions?.length ?? 0) > 0 const hasValue = value != null && value !== '' // Empty-value branch: still render the secondary button when defined // (e.g. "Play" to start a brainstorm session for an issue that has @@ -329,26 +355,7 @@ function PropertyRow({ : ( — )} - {hasSecondaryAction && ( - - )} + ) } @@ -356,8 +363,6 @@ function PropertyRow({ case 'file-link': { const hasValue = typeof value === 'string' && value.length > 0 const reloadIcon = propDef.reloadIcon ?? - const hasSecondary = propDef.secondaryActionIcon != null - const secondaryDisabled = !!propDef.secondaryDisabled return (
{hasValue @@ -389,34 +394,14 @@ function PropertyRow({ {reloadIcon} )} - {hasSecondary && ( - - )} +
) } case 'pr-link': { const hasValue = typeof value === 'string' && value.length > 0 - const hasSecondary = propDef.secondaryActionIcon != null - const secondaryDisabled = !!propDef.secondaryDisabled + const hasSecondary = (propDef.secondaryActions?.length ?? 0) > 0 if (!hasValue && !hasSecondary) { return ( 未关联 PR @@ -442,26 +427,7 @@ function PropertyRow({ : ( 未关联 PR )} - {hasSecondary && ( - - )} + ) } @@ -469,30 +435,11 @@ function PropertyRow({ case 'string': default: if (ro) { - const hasSecondary = propDef.secondaryActionIcon != null - const secondaryDisabled = !!propDef.secondaryDisabled - if (hasSecondary) { + if ((propDef.secondaryActions?.length ?? 0) > 0) { return (
{String(value ?? '')} - +
) } diff --git a/vscode/webview-ui/src/hooks/useIssues.ts b/vscode/webview-ui/src/hooks/useIssues.ts index 2aaec29..97f9161 100644 --- a/vscode/webview-ui/src/hooks/useIssues.ts +++ b/vscode/webview-ui/src/hooks/useIssues.ts @@ -202,7 +202,7 @@ export interface UseIssuesResult { * watches `~/.claude/projects/` for the new * session jsonl, then writes the id back as `sessionId`. */ - startBrainstormSession: (issueNumber: number) => void + continueBrainstorm: (issueNumber: number) => void changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void setDependency: (issueNumber: number, prerequisiteNumber: number) => void clearDependency: (issueNumber: number, prerequisiteNumber: number) => void @@ -562,8 +562,8 @@ export function useIssues(): UseIssuesResult { [], ) - const startBrainstormSession = useCallback((issueNumber: number): void => { - postMessage({ type: 'brainstorm/start', issueNumber }) + const continueBrainstorm = useCallback((issueNumber: number): void => { + postMessage({ type: 'brainstorm/continue', issueNumber }) }, []) const changeColumn = useCallback((issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string): void => { @@ -894,5 +894,5 @@ export function useIssues(): UseIssuesResult { return cleanup }, [clearPrDiffSummaryRunning, markHandoffRunning]) - return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } + return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning } } diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 1a3b02b..7ad56c7 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -249,7 +249,7 @@ export type WebviewToExtension | { type: 'env-lock/toggle' } | { type: 'issue/delete', issueNumber: number } | { type: 'issue/close', issueNumber: number } - | { type: 'brainstorm/start', issueNumber: number } + | { type: 'brainstorm/continue', issueNumber: number } | { type: 'profiles/get' } | { type: 'profiles/save', data: ProfilesData } | { type: 'profiles/open', value: string }