Compare commits

..
2 Commits
Author SHA1 Message Date
cruldra f4b1d80105 🐛 fix(vscode): worktree 复用测试在 macOS 上按真实路径比较,顺手清掉旧的代码风格错误
Claude-Session: https://claude.ai/code/session_01KDcq9HcrVLs2c9tkRxasub
2026-09-03 05:43:01 +08:00
cruldra ae875756a9 ✨ feat(vscode): 头脑风暴会话一键续聊,终端开着直接打进去、关了带提示词恢复
详情面板「头脑风暴会话id」行的 ▶ 改为续聊:规划终端还开着就把续聊提示词
打进去;终端关了但有会话 id 就 `claude --resume <id>` 带提示词恢复;没会话
才新开。属性行右侧按钮改为数组,续聊与关闭 tab 可并存。

Claude-Session: https://claude.ai/code/session_01KDcq9HcrVLs2c9tkRxasub
2026-09-03 01:16:41 +08:00
19 changed files with 318 additions and 306 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra", "publisher": "clurdra",
"name": "superpowers-vscode-clurdra", "name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra", "displayName": "Superpowers-clurdra",
"version": "0.2.116", "version": "0.2.118",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer", "description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra", "author": "clurdra",
+7 -5
View File
@@ -16,9 +16,13 @@ export interface CcCommandOpts {
profilePath: string profilePath: string
/** e.g. 'high' → `--effort high`; omitted means no `--effort`. */ /** e.g. 'high' → `--effort high`; omitted means no `--effort`. */
effort?: string effort?: string
/** When set, append `--resume <id>`. Mutually exclusive with prompt; resume wins. */ /** When set, append `--resume <id>` so cc restores that conversation. */
resumeSessionId?: string resumeSessionId?: string
/** When set (and no resume), append the prompt as a positional `'<prompt>'` arg. */ /**
* When set, append the prompt as a positional `'<prompt>'` arg. Combined
* with `resumeSessionId` it is submitted as the next message of the
* restored conversation.
*/
prompt?: string prompt?: string
} }
@@ -31,11 +35,9 @@ export function buildCcCommand(opts: CcCommandOpts): string {
if (opts.effort) if (opts.effort)
parts.push(`--effort ${opts.effort}`) parts.push(`--effort ${opts.effort}`)
parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`) 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) if (opts.resumeSessionId)
parts.push(`--resume ${opts.resumeSessionId}`) parts.push(`--resume ${opts.resumeSessionId}`)
else if (opts.prompt) if (opts.prompt)
parts.push(`'${opts.prompt}'`) parts.push(`'${opts.prompt}'`)
return parts.join(' ') return parts.join(' ')
} }
+1 -1
View File
@@ -268,7 +268,7 @@ export async function listUnreleasedCommits(o: { cwd: string, devBranch: string,
) )
if (!out) if (!out)
return [] return []
const commits = out.split('\x1e').flatMap((chunk) => { const commits = out.split('\x1E').flatMap((chunk) => {
const trimmed = chunk.replace(/^\n+/, '').trimEnd() const trimmed = chunk.replace(/^\n+/, '').trimEnd()
if (!trimmed) if (!trimmed)
return [] return []
+2 -2
View File
@@ -562,8 +562,8 @@ export class KanbanWebviewPanel {
void issues.handleCloseIssue(this, msg.issueNumber) void issues.handleCloseIssue(this, msg.issueNumber)
return return
} }
if (msg.type === 'brainstorm/start') { if (msg.type === 'brainstorm/continue') {
void sessions.handleStartBrainstormSession(this, msg.issueNumber) void sessions.handleContinueBrainstorm(this, msg.issueNumber)
return return
} }
if (msg.type === 'profiles/get') { if (msg.type === 'profiles/get') {
+14
View File
@@ -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'
}
+59 -64
View File
@@ -22,8 +22,10 @@ import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store' import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator' import { webhookCoordinator } from '../../webhook/coordinator'
import { decideAutoReviewPath } from '../autoReviewDecision' import { decideAutoReviewPath } from '../autoReviewDecision'
import { pickBrainstormContinuePath } from '../brainstormContinue'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup' import { cleanupFeatureBranch } from './branchCleanup'
import { submitToSessionTerminal } from './terminals'
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> { export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造 // kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
@@ -446,15 +448,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
terminal.show(false) terminal.show(false)
// codex TUI already running inside this tab; sending the full // codex TUI already running inside this tab; sending the full
// startup command would be received as a long redundant user message. // startup command would be received as a long redundant user message.
// Mirror the two-step submit pattern from injectIntoImplTerminal: submitToSessionTerminal(terminal, 'PR 更新了,再审一下')
// 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)
logger.add({ logger.add({
level: 'info', level: 'info',
source: 'terminal', source: 'terminal',
@@ -493,12 +487,8 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
// before the TUI accepts input — empirically much slower than the // before the TUI accepts input — empirically much slower than the
// 250ms gap the reuse path uses. 8s is a conservative guess that // 250ms gap the reuse path uses. 8s is a conservative guess that
// covers a cold codex start without feeling laggy. // covers a cold codex start without feeling laggy.
const body = 'PR 更新了,再审一下'
setTimeout(() => { setTimeout(() => {
terminal.sendText(body, false) submitToSessionTerminal(terminal, 'PR 更新了,再审一下')
setTimeout(() => {
terminal.sendText('\r', false)
}, 250)
}, 8000) }, 8000)
logger.add({ logger.add({
level: 'info', level: 'info',
@@ -954,34 +944,13 @@ export async function handleImplement(
} }
/** /**
* Start a fresh "规划" (brainstorm continuation) cc tab for an existing * 详情面板「▶ 续聊」入口:把续聊 prompt 送进这个工单的头脑风暴会话。
* issue whose body has no sessionId yet. Used when the issue was created * - 规划终端还开着 → 直接打进去(含新建工单流程留下的 issue-new-* tab)
* outside the panel (e.g. user ran spx from another cc session, body * - 终端关了但 state JSON 有 sessionId → `--resume <id>` 并带上 prompt
* lacks the nonce marker so the webhook took the "external" branch and * - 两者都没有 → 新开会话,捕获到的 id 写回 `sessionId`
* didn't link a terminal). Click the Play button next to the empty * 全程在 workspaceRoot,不建 worktree / 分支(这是讨论,不是编码)。
* "头脑风暴会话 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`).
*/ */
export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> { export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
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
}
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) { if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹') void window.showErrorMessage('请先打开一个工作区文件夹')
@@ -1000,10 +969,13 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。 // 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
let brainstormProfilePath: string | undefined let brainstormProfilePath: string | undefined
let sessionId: 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)
sessionId = stateObj.sessionId
} }
catch (err) { catch (err) {
logger.add({ 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) const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath)
if (effectiveProfilePath.includes('\'')) { if (effectiveProfilePath.includes('\'')) {
void window.showErrorMessage( void window.showErrorMessage(
`启动规划失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`, `续聊失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
) )
return return
} }
const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId })
if (prompt.includes('\'')) { if (prompt.includes('\'')) {
void window.showErrorMessage('启动规划失败:prompt 含单引号,拒绝执行') void window.showErrorMessage('续聊失败:prompt 含单引号,拒绝执行')
return return
} }
// Ensure the projects dir exists so the watcher can't miss the create // resume 沿用原 id,只有新开会话才要盯着 session jsonl 出现;
// event. Workspace root (no worktree here). // 监听要在 createTerminal 之前挂上,否则会漏掉创建事件。
const projDir = projectsDirFor(workspaceRoot) let watchPromise: ReturnType<typeof watchForNewSession> | undefined
try { if (continuePath === 'fresh') {
await fsp.mkdir(projDir, { recursive: true }) 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 { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
const terminal = window.createTerminal({ const terminal = window.createTerminal({
@@ -1049,6 +1039,8 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
iconPath: iconUri, iconPath: iconUri,
color: themeColor, color: themeColor,
}) })
if (continuePath === 'resume' && sessionId)
panel.terminals.set(sessionId, terminal)
panel.trackSessionTerminal(terminal, issueNumber, 'brainstorm') panel.trackSessionTerminal(terminal, issueNumber, 'brainstorm')
terminal.show(false) terminal.show(false)
logger.add({ logger.add({
@@ -1057,18 +1049,21 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
message: `已创建终端 "${terminal.name}"`, message: `已创建终端 "${terminal.name}"`,
}) })
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt }) terminal.sendText(buildCcCommand({
terminal.sendText(cmd) profilePath: effectiveProfilePath,
resumeSessionId: continuePath === 'resume' ? sessionId : undefined,
prompt,
}))
logger.add({ logger.add({
level: 'info', level: 'info',
source: 'brainstorm', source: 'brainstorm',
message: `已发送规划 prompt 到终端 #${issueNumber}`, message: continuePath === 'resume'
? `已带续聊 prompt 恢复 #${issueNumber} 规划会话 ${sessionId}`
: `已发送规划 prompt 到终端 #${issueNumber}`,
}) })
// Fire-and-forget: when the session jsonl materializes, persist the id // 拿到 session jsonl 后把 id 写回 state JSON,详情面板据此切到 resume 入口。
// as the issue's brainstorm `sessionId` so the X / Play button toggles watchPromise?.then(async (sid) => {
// and the user can later resume via the existing UI.
watchPromise.then(async (sid) => {
if (!sid) { if (!sid) {
logger.add({ logger.add({
level: 'warn', level: 'warn',
@@ -1104,7 +1099,7 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
* 手动启动一个"测试"cc 会话:工单 PR 合并后,用户在详情面板点测试会话行的 * 手动启动一个"测试"cc 会话:工单 PR 合并后,用户在详情面板点测试会话行的
* 启动按钮,开一个 claude tab 让 cc 先了解代码、再告诉用户怎么测试。 * 启动按钮,开一个 claude tab 让 cc 先了解代码、再告诉用户怎么测试。
* *
* 与 handleStartBrainstormSession 的差异: * 与 handleContinueBrainstorm 的差异:
* - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。 * - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。
* - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码, * - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码,
* profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。 * profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。
+16 -10
View File
@@ -56,19 +56,25 @@ export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: n
} }
if (!terminal) if (!terminal)
return false return false
// cc 的 TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r) submitToSessionTerminal(terminal, `\n[审查反馈]\n${text}`)
// 才会被识别为 Enter(提交消息)。实测把多行内容 + 末尾 \r 在同一次
// sendText 里发出去时,cc 进入多行输入模式后并不会把紧跟的 \r 当成
// 提交键,结果就是反馈只粘贴到输入框、没提交。
// 拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
const body = `\n[审查反馈]\n${text}`
terminal.sendText(body, false)
setTimeout(() => {
terminal!.sendText('\r', false)
}, 250)
return true 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 门禁对 * 本机是否开着该工单的存活终端(exitStatus undefined)——webhook 门禁对
* 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端 * 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端
+1 -1
View File
@@ -220,7 +220,7 @@ export type WebviewToExtension
| { type: 'env-lock/toggle' } | { type: 'env-lock/toggle' }
| { type: 'issue/delete', issueNumber: number } | { type: 'issue/delete', issueNumber: number }
| { type: 'issue/close', issueNumber: number } | { type: 'issue/close', issueNumber: number }
| { type: 'brainstorm/start', issueNumber: number } | { type: 'brainstorm/continue', issueNumber: number }
| { type: 'profiles/get' } | { type: 'profiles/get' }
| { type: 'profiles/save', data: ProfilesData } | { type: 'profiles/save', data: ProfilesData }
| { type: 'profiles/open', value: string } | { type: 'profiles/open', value: string }
+23
View File
@@ -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\'')
})
})
+5 -5
View File
@@ -7,14 +7,14 @@ import {
cherryPickToRelease, cherryPickToRelease,
cutRelease, cutRelease,
getReleaseStatus, getReleaseStatus,
groupCommitsByPr,
indexUnreleasedByMergePr,
listReleaseBranches, listReleaseBranches,
listUnreleasedCommits, listUnreleasedCommits,
normalizeReleasePrefix, normalizeReleasePrefix,
parseGiteaMergeSubject,
pickCurrentRelease, pickCurrentRelease,
todayReleaseBranch, todayReleaseBranch,
groupCommitsByPr,
parseGiteaMergeSubject,
indexUnreleasedByMergePr,
} from '../../src/git/release' } from '../../src/git/release'
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
@@ -60,7 +60,7 @@ describe('纯函数', () => {
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined() expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
}) })
it('parseGiteaMergeSubject 认 Gitea 合并说明', () => { it('parseGiteaMergeSubject 认 Gitea 合并说明', () => {
expect(parseGiteaMergeSubject("Merge pull request '连续包月会员订阅体系' (#260) from feature/monthly-membership into main")).toEqual({ expect(parseGiteaMergeSubject('Merge pull request \'连续包月会员订阅体系\' (#260) from feature/monthly-membership into main')).toEqual({
number: 260, number: 260,
title: '连续包月会员订阅体系', title: '连续包月会员订阅体系',
}) })
@@ -135,7 +135,7 @@ describe('集成', () => {
const a = commit(fx.peer, 'b.txt', 'b', 'fix(admin): 会员档位隐藏') const a = commit(fx.peer, 'b.txt', 'b', 'fix(admin): 会员档位隐藏')
const b = commit(fx.peer, 'c.txt', 'c', 'fix(membership): 年付月度发卡') const b = commit(fx.peer, 'c.txt', 'c', 'fix(membership): 年付月度发卡')
git(fx.peer, ['checkout', 'main']) git(fx.peer, ['checkout', 'main'])
git(fx.peer, ['merge', '--no-ff', '-m', "Merge pull request '连续包月会员订阅体系' (#260) from feature into main", 'feature']) git(fx.peer, ['merge', '--no-ff', '-m', 'Merge pull request \'连续包月会员订阅体系\' (#260) from feature into main', 'feature'])
git(fx.peer, ['push', 'origin', 'main']) git(fx.peer, ['push', 'origin', 'main'])
git(fx.repo, ['fetch', 'origin']) git(fx.repo, ['fetch', 'origin'])
const map = await indexUnreleasedByMergePr({ const map = await indexUnreleasedByMergePr({
+4 -2
View File
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import { existsSync, promises as fsp, rmSync, writeFileSync } from 'node:fs' import { existsSync, promises as fsp, realpathSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os' import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
@@ -24,7 +24,9 @@ interface Fixture { root: string, origin: string, repo: string, templatePath: st
/** origin(bare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */ /** origin(bare)← 同事的 clone push feature 分支;repo 是接管方的 clone,只有 main。 */
async function setup(): Promise<Fixture> { async function setup(): Promise<Fixture> {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-remote-')) // macOS 的 tmpdir 是 /var → /private/var 的软链,git worktree list 报的是真实路径;
// 夹具根目录先取真实路径,复用分支返回的路径才能和模板路径按字符串相等。
const root = realpathSync(await fsp.mkdtemp(path.join(os.tmpdir(), 'sw-remote-')))
const origin = path.join(root, 'origin.git') const origin = path.join(root, 'origin.git')
git(root, ['init', '--bare', '-b', 'main', origin]) git(root, ['init', '--bare', '-b', 'main', origin])
const peer = path.join(root, 'peer') const peer = path.join(root, 'peer')
@@ -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')
})
})
+2 -2
View File
@@ -53,7 +53,7 @@ export function App() {
deleteIssue, deleteIssue,
closeIssue, closeIssue,
closeSessionTab, closeSessionTab,
startBrainstormSession, continueBrainstorm,
changeColumn, changeColumn,
setDependency, setDependency,
clearDependency, clearDependency,
@@ -381,7 +381,7 @@ export function App() {
onCloseIssue={closeIssue} onCloseIssue={closeIssue}
onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)} onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)}
onCloseSessionTab={closeSessionTab} onCloseSessionTab={closeSessionTab}
onStartBrainstormSession={startBrainstormSession} onContinueBrainstorm={continueBrainstorm}
onUpdateAutoReview={updateIssueAutoReview} onUpdateAutoReview={updateIssueAutoReview}
onUpdateProfilePath={updateIssueProfilePath} onUpdateProfilePath={updateIssueProfilePath}
onUpdateBrainstormProfilePath={updateIssueBrainstormProfilePath} onUpdateBrainstormProfilePath={updateIssueBrainstormProfilePath}
@@ -44,7 +44,7 @@ interface BottomTabsProps {
onCloseIssue: (issueNumber: number) => void onCloseIssue: (issueNumber: number) => void
onResetToTodo: (issueNumber: number) => void onResetToTodo: (issueNumber: number) => void
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
onStartBrainstormSession: (issueNumber: number) => void onContinueBrainstorm: (issueNumber: number) => void
onUpdateAutoReview: (issueNumber: number, value: boolean) => void onUpdateAutoReview: (issueNumber: number, value: boolean) => void
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
@@ -139,7 +139,7 @@ export function BottomTabs(props: BottomTabsProps) {
onCloseIssue={props.onCloseIssue} onCloseIssue={props.onCloseIssue}
onResetToTodo={props.onResetToTodo} onResetToTodo={props.onResetToTodo}
onCloseSessionTab={props.onCloseSessionTab} onCloseSessionTab={props.onCloseSessionTab}
onStartBrainstormSession={props.onStartBrainstormSession} onContinueBrainstorm={props.onContinueBrainstorm}
onUpdateAutoReview={props.onUpdateAutoReview} onUpdateAutoReview={props.onUpdateAutoReview}
onUpdateProfilePath={props.onUpdateProfilePath} onUpdateProfilePath={props.onUpdateProfilePath}
onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath} onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath}
@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import type { ClaudeProfile } from '../hooks/useIssues' import type { ClaudeProfile } from '../hooks/useIssues'
import type { Issue, IssueColumn } from '../types' 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 { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { isIssueLocked } from '../lib/dependencies' import { isIssueLocked } from '../lib/dependencies'
@@ -87,11 +86,10 @@ interface IssueDetailPanelProps {
*/ */
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
/** /**
* Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty * 「▶ 续聊」:由插件把续聊提示词送进头脑风暴会话——规划 tab 开着就直接
* (created out-of-band via spx without webhook linking). Bound to the Play * 打进去,关了就 `claude --resume <id>` 带着提示词恢复,没会话就新开一个。
* button next to the "头脑风暴会话 id" row.
*/ */
onStartBrainstormSession: (issueNumber: number) => void onContinueBrainstorm: (issueNumber: number) => void
/** Persist a per-issue `autoReview` override into the state JSON. */ /** Persist a per-issue `autoReview` override into the state JSON. */
onUpdateAutoReview: (issueNumber: number, value: boolean) => void onUpdateAutoReview: (issueNumber: number, value: boolean) => void
/** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */ /** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */
@@ -140,7 +138,7 @@ export function IssueDetailPanel({
onCloseIssue, onCloseIssue,
onResetToTodo, onResetToTodo,
onCloseSessionTab, onCloseSessionTab,
onStartBrainstormSession, onContinueBrainstorm,
onUpdateAutoReview, onUpdateAutoReview,
profiles, profiles,
onUpdateProfilePath, onUpdateProfilePath,
@@ -206,39 +204,39 @@ export function IssueDetailPanel({
})), })),
}, },
(() => { (() => {
// 头脑风暴会话 id 行有三种状态: // 头脑风暴会话 id 行:
// 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab // - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话:
// 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮) // 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。
// 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab) // - brainstormTabOpen → 再加一个 X 关闭 tab
// 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0) const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
const canStart = !hasSession && issue?.column !== 'done' const secondaryActions: RowAction[] = []
let secondaryActionIcon: ReactNode | undefined if (issue?.column !== 'done') {
let secondaryActionTitle: string | undefined secondaryActions.push({
let onSecondaryAction: (() => void) | undefined icon: <Play className="size-3.5" />,
if (hasSession && issue?.brainstormTabOpen) { title: '续聊:把续聊提示词发给头脑风暴会话',
secondaryActionIcon = <X className="size-3.5" /> onClick: () => {
secondaryActionTitle = '关闭头脑风暴 tab' if (issue)
onSecondaryAction = () => { onContinueBrainstorm(issue.number)
if (issue) },
onCloseSessionTab(issue.number, 'brainstorm') })
}
} }
else if (canStart) { if (hasSession && issue?.brainstormTabOpen) {
secondaryActionIcon = <Play className="size-3.5" /> secondaryActions.push({
secondaryActionTitle = '启动头脑风暴会话' icon: <X className="size-3.5" />,
onSecondaryAction = () => { title: '关闭头脑风暴 tab',
if (issue) onClick: () => {
onStartBrainstormSession(issue.number) if (issue)
} onCloseSessionTab(issue.number, 'brainstorm')
},
})
} }
return { return {
key: 'sessionId', key: 'sessionId',
label: '头脑风暴会话id', label: '头脑风暴会话id',
type: 'action' as const, type: 'action' as const,
description: hasSession description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复对话' ? '双击在新终端运行 claude --resume <id> 恢复对话;点 ▶ 续聊'
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab', : '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊',
actionIcon: <Terminal className="size-3.5" />, actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => { onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0) if (typeof v !== 'string' || v.length === 0)
@@ -247,9 +245,7 @@ export function IssueDetailPanel({
return return
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number) onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
}, },
secondaryActionIcon, secondaryActions,
secondaryActionTitle,
onSecondaryAction,
} }
})(), })(),
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口: // 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
@@ -271,14 +267,13 @@ export function IssueDetailPanel({
return return
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number) onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
}, },
secondaryActionIcon: issue?.implementTabOpen secondaryActions: issue?.implementTabOpen
? <X className="size-3.5" /> ? [{
: undefined, icon: <X className="size-3.5" />,
secondaryActionTitle: '关闭实施 tab', title: '关闭实施 tab',
onSecondaryAction: () => { onClick: () => onCloseSessionTab(issue.number, 'implement'),
if (issue) }]
onCloseSessionTab(issue.number, 'implement') : [],
},
}, },
issue?.reviewSessionFileExists issue?.reviewSessionFileExists
? { ? {
@@ -294,14 +289,13 @@ export function IssueDetailPanel({
return return
onResumeReviewSession(v, issue.number, issue?.worktreePath) onResumeReviewSession(v, issue.number, issue?.worktreePath)
}, },
secondaryActionIcon: issue?.reviewTabOpen secondaryActions: issue.reviewTabOpen
? <X className="size-3.5" /> ? [{
: undefined, icon: <X className="size-3.5" />,
secondaryActionTitle: '关闭审查 tab', title: '关闭审查 tab',
onSecondaryAction: () => { onClick: () => onCloseSessionTab(issue.number, 'review'),
if (issue) }]
onCloseSessionTab(issue.number, 'review') : [],
},
} }
: { : {
key: 'reviewSessionId', key: 'reviewSessionId',
@@ -318,24 +312,26 @@ export function IssueDetailPanel({
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮) // (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0) const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
const canStart = !hasSession && !!issue?.pr const canStart = !hasSession && !!issue?.pr
let secondaryActionIcon: ReactNode | undefined const secondaryActions: RowAction[] = []
let secondaryActionTitle: string | undefined
let onSecondaryAction: (() => void) | undefined
if (hasSession && issue?.testTabOpen) { if (hasSession && issue?.testTabOpen) {
secondaryActionIcon = <X className="size-3.5" /> secondaryActions.push({
secondaryActionTitle = '关闭测试 tab' icon: <X className="size-3.5" />,
onSecondaryAction = () => { title: '关闭测试 tab',
if (issue) onClick: () => {
onCloseSessionTab(issue.number, 'test') if (issue)
} onCloseSessionTab(issue.number, 'test')
},
})
} }
else if (canStart) { else if (canStart) {
secondaryActionIcon = <Play className="size-3.5" /> secondaryActions.push({
secondaryActionTitle = '启动测试会话' icon: <Play className="size-3.5" />,
onSecondaryAction = () => { title: '启动测试会话',
if (issue) onClick: () => {
onStartTestSession(issue.number) if (issue)
} onStartTestSession(issue.number)
},
})
} }
return { return {
key: 'testSessionId', key: 'testSessionId',
@@ -352,9 +348,7 @@ export function IssueDetailPanel({
return return
onResumeTestSession(v, issue.number) onResumeTestSession(v, issue.number)
}, },
secondaryActionIcon, secondaryActions,
secondaryActionTitle,
onSecondaryAction,
} }
})(), })(),
{ {
@@ -427,26 +421,27 @@ export function IssueDetailPanel({
description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步', description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
onOpen: (p: string) => onOpenFile(p), onOpen: (p: string) => onOpenFile(p),
// Hide the "实施" button entirely when there's no plan file. // Hide the "实施" button entirely when there's no plan file.
secondaryActionIcon: issue?.planFile secondaryActions: issue?.planFile
? <Play className="size-3.5" /> ? [{
: undefined, icon: <Play className="size-3.5" />,
secondaryActionTitle: locked ? lockTitle : '实施此计划', title: locked ? lockTitle : '实施此计划',
// Disable while running or already done. The user can still click // Disable while running or already done. The user can still click
// when status === 'failed' or never been run. Re-clicking while // when status === 'failed' or never been run. Re-clicking while
// 'running' is blocked at the UI to avoid registering duplicate // 'running' is blocked at the UI to avoid registering duplicate
// webhooks. To retry after a perceived failure with status still // webhooks. To retry after a perceived failure with status still
// 'running', edit the state-JSON comment manually for now. // 'running', edit the state-JSON comment manually for now.
// 锁定态(前置工单未完成)同样禁止启动实施。 // 锁定态(前置工单未完成)同样禁止启动实施。
secondaryDisabled: disabled:
!issue?.planFile issue.implementStatus === 'running'
|| issue?.implementStatus === 'running' || issue.implementStatus === 'done'
|| issue?.implementStatus === 'done' || locked,
|| locked, onClick: () => {
onSecondaryAction: () => { if (!issue?.planFile)
if (!issue?.planFile) return
return onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId) },
}, }]
: [],
}, },
{ {
key: 'pr', key: 'pr',
@@ -461,17 +456,19 @@ export function IssueDetailPanel({
if (pr.length > 0) if (pr.length > 0)
onOpenPr(pr) onOpenPr(pr)
}, },
secondaryActionIcon: issue?.pr && !issue?.prDiffFile secondaryActions: issue?.pr && !issue?.prDiffFile
? <Play className="size-3.5" /> ? [{
: undefined, icon: <Play className="size-3.5" />,
secondaryActionTitle: prDiffSummaryRunning title: prDiffSummaryRunning
? 'PR 变更摘要生成中' ? 'PR 变更摘要生成中'
: '生成 PR 变更摘要', : '生成 PR 变更摘要',
secondaryDisabled: prDiffSummaryRunning, disabled: prDiffSummaryRunning,
onSecondaryAction: () => { onClick: () => {
if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning) if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
onGeneratePrDiffSummary(issue.number) onGeneratePrDiffSummary(issue.number)
}, },
}]
: [],
}, },
{ {
key: 'prDiffFile', key: 'prDiffFile',
@@ -486,12 +483,16 @@ export function IssueDetailPanel({
type: 'string', type: 'string',
readOnly: true, readOnly: true,
description: '实施流程创建的分支名', description: '实施流程创建的分支名',
secondaryActionIcon: issue?.branch ? <GitMerge className="size-3.5" /> : undefined, secondaryActions: issue?.branch
secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)', ? [{
onSecondaryAction: () => { icon: <GitMerge className="size-3.5" />,
if (issue?.branch) title: '本地预合并 (git merge --no-commit --no-ff)',
onMergeBranch(issue.number, issue.branch) onClick: () => {
}, if (issue?.branch)
onMergeBranch(issue.number, issue.branch)
},
}]
: [],
}, },
issue?.worktreeExists issue?.worktreeExists
? { ? {
@@ -500,9 +501,11 @@ export function IssueDetailPanel({
type: 'file-link', type: 'file-link',
description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree', description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
onOpen: (p: string) => onOpenWorktree(p), onOpen: (p: string) => onOpenWorktree(p),
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''), secondaryActions: [{
secondaryActionIcon: <Trash2 className="size-3.5" />, icon: <Trash2 className="size-3.5" />,
secondaryActionTitle: '删除 worktree', title: '删除 worktree',
onClick: () => onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
}],
} }
: { : {
key: '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<Record<string, unknown> | null>(() => { const data = useMemo<Record<string, unknown> | null>(() => {
@@ -66,14 +66,41 @@ export interface PropertyDef {
onReload?: () => void onReload?: () => void
/** type='file-link' 专用:重新加载按钮的图标,默认为 RefreshCw */ /** type='file-link' 专用:重新加载按钮的图标,默认为 RefreshCw */
reloadIcon?: ReactNode reloadIcon?: ReactNode
/** type='file-link' / 'action' 通用:第二个右侧按钮的图标,缺省则不渲染该按钮 */ /** type='file-link' / 'action' / 'pr-link' / 只读 string 通用:值右侧的图标按钮,按数组顺序渲染 */
secondaryActionIcon?: ReactNode secondaryActions?: RowAction[]
/** type='file-link' / 'action' 通用:第二个右侧按钮的 tooltip */ }
secondaryActionTitle?: string
/** type='file-link' / 'action' 通用:第二个右侧按钮的点击回调 */ /** 属性行右侧的图标按钮 */
onSecondaryAction?: () => void export interface RowAction {
/** type='file-link' / 'action' 通用:第二个右侧按钮是否禁用 */ icon: ReactNode
secondaryDisabled?: boolean /** 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) => (
<button
key={i}
type="button"
onClick={() => {
if (action.disabled)
return
action.onClick()
}}
disabled={action.disabled}
title={action.title}
aria-label={action.title ?? '次要操作'}
className={`grid size-5 shrink-0 place-items-center ${className ?? ''} ${
action.disabled ? 'cursor-not-allowed opacity-30' : 'opacity-60 hover:opacity-100'
}`}
>
{action.icon}
</button>
))
} }
/** 属性分组定义 */ /** 属性分组定义 */
@@ -294,8 +321,7 @@ function PropertyRow({
) )
case 'action': { case 'action': {
const hasSecondaryAction = propDef.secondaryActionIcon != null const hasSecondaryAction = (propDef.secondaryActions?.length ?? 0) > 0
const secondaryDisabledAction = !!propDef.secondaryDisabled
const hasValue = value != null && value !== '' const hasValue = value != null && value !== ''
// Empty-value branch: still render the secondary button when defined // Empty-value branch: still render the secondary button when defined
// (e.g. "Play" to start a brainstorm session for an issue that has // (e.g. "Play" to start a brainstorm session for an issue that has
@@ -329,26 +355,7 @@ function PropertyRow({
: ( : (
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-xs opacity-50">—</span> <span className="min-w-0 flex-1 truncate px-2 py-1.5 text-xs opacity-50">—</span>
)} )}
{hasSecondaryAction && ( <RowActionButtons actions={propDef.secondaryActions} className="mr-2" />
<button
type="button"
onClick={() => {
if (secondaryDisabledAction)
return
propDef.onSecondaryAction?.()
}}
disabled={secondaryDisabledAction}
title={propDef.secondaryActionTitle}
aria-label={propDef.secondaryActionTitle ?? '次要操作'}
className={`mr-2 grid size-5 shrink-0 place-items-center ${
secondaryDisabledAction
? 'cursor-not-allowed opacity-30'
: 'opacity-60 hover:opacity-100'
}`}
>
{propDef.secondaryActionIcon}
</button>
)}
</div> </div>
) )
} }
@@ -356,8 +363,6 @@ function PropertyRow({
case 'file-link': { case 'file-link': {
const hasValue = typeof value === 'string' && value.length > 0 const hasValue = typeof value === 'string' && value.length > 0
const reloadIcon = propDef.reloadIcon ?? <RefreshCw className="size-3.5" /> const reloadIcon = propDef.reloadIcon ?? <RefreshCw className="size-3.5" />
const hasSecondary = propDef.secondaryActionIcon != null
const secondaryDisabled = !!propDef.secondaryDisabled
return ( return (
<div className="flex w-full items-center gap-1.5 px-2 py-1.5"> <div className="flex w-full items-center gap-1.5 px-2 py-1.5">
{hasValue {hasValue
@@ -389,34 +394,14 @@ function PropertyRow({
{reloadIcon} {reloadIcon}
</button> </button>
)} )}
{hasSecondary && ( <RowActionButtons actions={propDef.secondaryActions} />
<button
type="button"
onClick={() => {
if (secondaryDisabled)
return
propDef.onSecondaryAction?.()
}}
disabled={secondaryDisabled}
title={propDef.secondaryActionTitle}
aria-label={propDef.secondaryActionTitle ?? '次要操作'}
className={`grid size-5 shrink-0 place-items-center ${
secondaryDisabled
? 'cursor-not-allowed opacity-30'
: 'opacity-60 hover:opacity-100'
}`}
>
{propDef.secondaryActionIcon}
</button>
)}
</div> </div>
) )
} }
case 'pr-link': { case 'pr-link': {
const hasValue = typeof value === 'string' && value.length > 0 const hasValue = typeof value === 'string' && value.length > 0
const hasSecondary = propDef.secondaryActionIcon != null const hasSecondary = (propDef.secondaryActions?.length ?? 0) > 0
const secondaryDisabled = !!propDef.secondaryDisabled
if (!hasValue && !hasSecondary) { if (!hasValue && !hasSecondary) {
return ( return (
<span className="block truncate px-2 py-1.5 text-xs opacity-50">未关联 PR</span> <span className="block truncate px-2 py-1.5 text-xs opacity-50">未关联 PR</span>
@@ -442,26 +427,7 @@ function PropertyRow({
: ( : (
<span className="min-w-0 flex-1 truncate text-xs opacity-50">未关联 PR</span> <span className="min-w-0 flex-1 truncate text-xs opacity-50">未关联 PR</span>
)} )}
{hasSecondary && ( <RowActionButtons actions={propDef.secondaryActions} />
<button
type="button"
onClick={() => {
if (secondaryDisabled)
return
propDef.onSecondaryAction?.()
}}
disabled={secondaryDisabled}
title={propDef.secondaryActionTitle}
aria-label={propDef.secondaryActionTitle ?? '次要操作'}
className={`grid size-5 shrink-0 place-items-center ${
secondaryDisabled
? 'cursor-not-allowed opacity-30'
: 'opacity-60 hover:opacity-100'
}`}
>
{propDef.secondaryActionIcon}
</button>
)}
</div> </div>
) )
} }
@@ -469,30 +435,11 @@ function PropertyRow({
case 'string': case 'string':
default: default:
if (ro) { if (ro) {
const hasSecondary = propDef.secondaryActionIcon != null if ((propDef.secondaryActions?.length ?? 0) > 0) {
const secondaryDisabled = !!propDef.secondaryDisabled
if (hasSecondary) {
return ( return (
<div className="flex w-full items-center gap-1.5 px-2 py-1.5"> <div className="flex w-full items-center gap-1.5 px-2 py-1.5">
<span className="min-w-0 flex-1 truncate text-xs">{String(value ?? '')}</span> <span className="min-w-0 flex-1 truncate text-xs">{String(value ?? '')}</span>
<button <RowActionButtons actions={propDef.secondaryActions} />
type="button"
onClick={() => {
if (secondaryDisabled)
return
propDef.onSecondaryAction?.()
}}
disabled={secondaryDisabled}
title={propDef.secondaryActionTitle}
aria-label={propDef.secondaryActionTitle ?? '次要操作'}
className={`grid size-5 shrink-0 place-items-center ${
secondaryDisabled
? 'cursor-not-allowed opacity-30'
: 'opacity-60 hover:opacity-100'
}`}
>
{propDef.secondaryActionIcon}
</button>
</div> </div>
) )
} }
+4 -4
View File
@@ -202,7 +202,7 @@ export interface UseIssuesResult {
* watches `~/.claude/projects/<encoded-workspaceRoot>` for the new * watches `~/.claude/projects/<encoded-workspaceRoot>` for the new
* session jsonl, then writes the id back as `sessionId`. * 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 changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
setDependency: (issueNumber: number, prerequisiteNumber: number) => void setDependency: (issueNumber: number, prerequisiteNumber: number) => void
clearDependency: (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 => { const continueBrainstorm = useCallback((issueNumber: number): void => {
postMessage({ type: 'brainstorm/start', issueNumber }) postMessage({ type: 'brainstorm/continue', issueNumber })
}, []) }, [])
const changeColumn = useCallback((issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string): void => { const changeColumn = useCallback((issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string): void => {
@@ -894,5 +894,5 @@ export function useIssues(): UseIssuesResult {
return cleanup return cleanup
}, [clearPrDiffSummaryRunning, markHandoffRunning]) }, [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 }
} }
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { ReleaseCommitGroup } from './messages' import type { ReleaseCommitGroup } from './messages'
import { describe, expect, it } from 'vitest'
import { filterCherryPickGroups, uniqueAuthors } from './cherryPickView' import { filterCherryPickGroups, uniqueAuthors } from './cherryPickView'
const groups: ReleaseCommitGroup[] = [ const groups: ReleaseCommitGroup[] = [
+1 -1
View File
@@ -249,7 +249,7 @@ export type WebviewToExtension
| { type: 'env-lock/toggle' } | { type: 'env-lock/toggle' }
| { type: 'issue/delete', issueNumber: number } | { type: 'issue/delete', issueNumber: number }
| { type: 'issue/close', issueNumber: number } | { type: 'issue/close', issueNumber: number }
| { type: 'brainstorm/start', issueNumber: number } | { type: 'brainstorm/continue', issueNumber: number }
| { type: 'profiles/get' } | { type: 'profiles/get' }
| { type: 'profiles/save', data: ProfilesData } | { type: 'profiles/save', data: ProfilesData }
| { type: 'profiles/open', value: string } | { type: 'profiles/open', value: string }