Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b1d80105 | ||
|
|
ae875756a9 |
No files matched your search
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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(' ')
|
||||||
}
|
}
|
||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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 > 默认,不回退实施)。
|
||||||
|
|||||||
@@ -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:看板关闭时实施终端
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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\'')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[] = [
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in new issue
Block a user