✨ feat(vscode): 头脑风暴会话一键续聊,终端开着直接打进去、关了带提示词恢复

详情面板「头脑风暴会话id」行的 ▶ 改为续聊:规划终端还开着就把续聊提示词
打进去;终端关了但有会话 id 就 `claude --resume <id>` 带提示词恢复;没会话
才新开。属性行右侧按钮改为数组,续聊与关闭 tab 可并存。

Claude-Session: https://claude.ai/code/session_01KDcq9HcrVLs2c9tkRxasub
This commit is contained in:
cruldra committed 2026-09-03 01:16:41 +08:00
1 parent 90600cf070
commit ae875756a9
15 files changed
+307 -297

No files matched your search

+7 -5
View File
@@ -16,9 +16,13 @@ export interface CcCommandOpts {
profilePath: string
/** e.g. 'high' → `--effort high`; omitted means no `--effort`. */
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
/** 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
}
@@ -31,11 +35,9 @@ export function buildCcCommand(opts: CcCommandOpts): string {
if (opts.effort)
parts.push(`--effort ${opts.effort}`)
parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`)
// resume and prompt are mutually exclusive; a resumed session restores its
// own conversation, so a fresh prompt would be meaningless.
if (opts.resumeSessionId)
parts.push(`--resume ${opts.resumeSessionId}`)
else if (opts.prompt)
if (opts.prompt)
parts.push(`'${opts.prompt}'`)
return parts.join(' ')
}
+2 -2
View File
@@ -562,8 +562,8 @@ export class KanbanWebviewPanel {
void issues.handleCloseIssue(this, msg.issueNumber)
return
}
if (msg.type === 'brainstorm/start') {
void sessions.handleStartBrainstormSession(this, msg.issueNumber)
if (msg.type === 'brainstorm/continue') {
void sessions.handleContinueBrainstorm(this, msg.issueNumber)
return
}
if (msg.type === 'profiles/get') {
+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 { webhookCoordinator } from '../../webhook/coordinator'
import { decideAutoReviewPath } from '../autoReviewDecision'
import { pickBrainstormContinuePath } from '../brainstormContinue'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import { submitToSessionTerminal } from './terminals'
export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId: string, profilePath?: string, relCwd?: string, issueNumber?: number): Promise<void> {
// kind 跟着 sessionRole 提前判定(原来散落在方法中部,提到入口是为了构造
@@ -446,15 +448,7 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
terminal.show(false)
// codex TUI already running inside this tab; sending the full
// startup command would be received as a long redundant user message.
// Mirror the two-step submit pattern from injectIntoImplTerminal:
// push the body first, then send a standalone \r 250ms later so codex
// TUI registers it as Enter (single-shot \r packed with the body is
// sometimes consumed as a newline, not a submit).
const body = 'PR 更新了,再审一下'
terminal.sendText(body, false)
setTimeout(() => {
terminal.sendText('\r', false)
}, 250)
submitToSessionTerminal(terminal, 'PR 更新了,再审一下')
logger.add({
level: 'info',
source: 'terminal',
@@ -493,12 +487,8 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
// before the TUI accepts input — empirically much slower than the
// 250ms gap the reuse path uses. 8s is a conservative guess that
// covers a cold codex start without feeling laggy.
const body = 'PR 更新了,再审一下'
setTimeout(() => {
terminal.sendText(body, false)
setTimeout(() => {
terminal.sendText('\r', false)
}, 250)
submitToSessionTerminal(terminal, 'PR 更新了,再审一下')
}, 8000)
logger.add({
level: 'info',
@@ -954,34 +944,13 @@ export async function handleImplement(
}
/**
* Start a fresh "规划" (brainstorm continuation) cc tab for an existing
* issue whose body has no sessionId yet. Used when the issue was created
* outside the panel (e.g. user ran spx from another cc session, body
* lacks the nonce marker so the webhook took the "external" branch and
* didn't link a terminal). Click the Play button next to the empty
* "头脑风暴会话 id" row to spawn a new cc session anchored to this issue.
*
* Differs from `handleImplement`:
* - No worktree / no feature branch (this is discussion, not coding).
* - cwd = workspaceRoot (not a worktree).
* - Watches the workspace-root `~/.claude/projects/...` dir for the new
* session jsonl and writes the captured id back as `sessionId`
* (not `implementSessionId`).
* 详情面板「▶ 续聊」入口:把续聊 prompt 送进这个工单的头脑风暴会话。
* - 规划终端还开着 → 直接打进去(含新建工单流程留下的 issue-new-* tab)
* - 终端关了但 state JSON 有 sessionId → `--resume <id>` 并带上 prompt
* - 两者都没有 → 新开会话,捕获到的 id 写回 `sessionId`
* 全程在 workspaceRoot,不建 worktree / 分支(这是讨论,不是编码)。
*/
export async function handleStartBrainstormSession(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
}
export async function handleContinueBrainstorm(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
@@ -1000,10 +969,13 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
let brainstormProfilePath: string | undefined
let sessionId: string | undefined
try {
const stateObj = await panel.readIssueState(issueNumber)
if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0)
brainstormProfilePath = stateObj.brainstormProfilePath
if (stateObj && typeof stateObj.sessionId === 'string' && stateObj.sessionId.length > 0)
sessionId = stateObj.sessionId
}
catch (err) {
logger.add({
@@ -1014,32 +986,50 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
})
}
const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId })
const terminalName = `issue-${issueNumber}-规划`
// 新建工单流程的 tab 叫 issue-new-{nonce}-规划,只能靠 newIssueTerminals 反查。
const linked = panel.newIssueTerminals.get(issueNumber)
const live = linked && linked.exitStatus === undefined ? linked : panel.findExistingTerminal(terminalName)
const continuePath = pickBrainstormContinuePath({ liveTerminal: !!live, sessionId })
if (continuePath === 'inject' && live) {
panel.trackSessionTerminal(live, issueNumber, 'brainstorm')
live.show(false)
submitToSessionTerminal(live, prompt)
logger.add({
level: 'info',
source: 'brainstorm',
message: `已把续聊 prompt 打进 #${issueNumber} 的规划终端`,
})
return
}
const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath)
if (effectiveProfilePath.includes('\'')) {
void window.showErrorMessage(
`启动规划失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
`续聊失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
)
return
}
const prompt = getBrainstormContinuePrompt(panel.context, { issueNumber, issueRef: panel.refFor(issueNumber).externalId })
if (prompt.includes('\'')) {
void window.showErrorMessage('启动规划失败:prompt 含单引号,拒绝执行')
void window.showErrorMessage('续聊失败:prompt 含单引号,拒绝执行')
return
}
// Ensure the projects dir exists so the watcher can't miss the create
// event. Workspace root (no worktree here).
const projDir = projectsDirFor(workspaceRoot)
try {
await fsp.mkdir(projDir, { recursive: true })
// resume 沿用原 id,只有新开会话才要盯着 session jsonl 出现;
// 监听要在 createTerminal 之前挂上,否则会漏掉创建事件。
let watchPromise: ReturnType<typeof watchForNewSession> | undefined
if (continuePath === 'fresh') {
const projDir = projectsDirFor(workspaceRoot)
try {
await fsp.mkdir(projDir, { recursive: true })
}
catch (err) {
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
}
watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
}
catch (err) {
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
}
// Kick off the watcher before spawning the terminal so we don't race.
const watchPromise = watchForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
const { themeColor, iconUri } = await panel.resolveIssueIcon(issueNumber)
const terminal = window.createTerminal({
@@ -1049,6 +1039,8 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
iconPath: iconUri,
color: themeColor,
})
if (continuePath === 'resume' && sessionId)
panel.terminals.set(sessionId, terminal)
panel.trackSessionTerminal(terminal, issueNumber, 'brainstorm')
terminal.show(false)
logger.add({
@@ -1057,18 +1049,21 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
message: `已创建终端 "${terminal.name}"`,
})
const cmd = buildCcCommand({ profilePath: effectiveProfilePath, prompt })
terminal.sendText(cmd)
terminal.sendText(buildCcCommand({
profilePath: effectiveProfilePath,
resumeSessionId: continuePath === 'resume' ? sessionId : undefined,
prompt,
}))
logger.add({
level: 'info',
source: 'brainstorm',
message: `已发送规划 prompt 到终端 #${issueNumber}`,
message: continuePath === 'resume'
? `已带续聊 prompt 恢复 #${issueNumber} 规划会话 ${sessionId}`
: `已发送规划 prompt 到终端 #${issueNumber}`,
})
// Fire-and-forget: when the session jsonl materializes, persist the id
// as the issue's brainstorm `sessionId` so the X / Play button toggles
// and the user can later resume via the existing UI.
watchPromise.then(async (sid) => {
// 拿到 session jsonl 后把 id 写回 state JSON,详情面板据此切到 resume 入口。
watchPromise?.then(async (sid) => {
if (!sid) {
logger.add({
level: 'warn',
@@ -1104,7 +1099,7 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
* 手动启动一个"测试"cc 会话:工单 PR 合并后,用户在详情面板点测试会话行的
* 启动按钮,开一个 claude tab 让 cc 先了解代码、再告诉用户怎么测试。
*
* 与 handleStartBrainstormSession 的差异:
* 与 handleContinueBrainstorm 的差异:
* - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。
* - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码,
* profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。
+16 -10
View File
@@ -56,19 +56,25 @@ export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: n
}
if (!terminal)
return false
// cc 的 TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r)
// 才会被识别为 Enter(提交消息)。实测把多行内容 + 末尾 \r 在同一次
// sendText 里发出去时,cc 进入多行输入模式后并不会把紧跟的 \r 当成
// 提交键,结果就是反馈只粘贴到输入框、没提交。
// 拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
const body = `\n[审查反馈]\n${text}`
terminal.sendText(body, false)
setTimeout(() => {
terminal!.sendText('\r', false)
}, 250)
submitToSessionTerminal(terminal, `\n[审查反馈]\n${text}`)
return true
}
/**
* 往正在跑 cc / codex TUI 的终端里提交一条消息。
*
* TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r) 才会被识别为
* Enter。实测多行内容 + 末尾 \r 在同一次 sendText 里发出去时,TUI 进入
* 多行输入模式后不会把紧跟的 \r 当成提交键,内容只粘贴到输入框、没提交。
* 所以拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
*/
export function submitToSessionTerminal(terminal: Terminal, text: string): void {
terminal.sendText(text, false)
setTimeout(() => {
terminal.sendText('\r', false)
}, 250)
}
/**
* 本机是否开着该工单的存活终端(exitStatus undefined)——webhook 门禁对
* 无 assignee 存量工单的兜底证据。不依赖 panel:看板关闭时实施终端
+1 -1
View File
@@ -220,7 +220,7 @@ export type WebviewToExtension
| { type: 'env-lock/toggle' }
| { type: 'issue/delete', issueNumber: number }
| { type: 'issue/close', issueNumber: number }
| { type: 'brainstorm/start', issueNumber: number }
| { type: 'brainstorm/continue', issueNumber: number }
| { type: 'profiles/get' }
| { type: 'profiles/save', data: ProfilesData }
| { type: 'profiles/open', value: string }