✨ 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

@@ -44,7 +44,7 @@ interface BottomTabsProps {
onCloseIssue: (issueNumber: number) => void
onResetToTodo: (issueNumber: number) => void
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
onStartBrainstormSession: (issueNumber: number) => void
onContinueBrainstorm: (issueNumber: number) => void
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
@@ -139,7 +139,7 @@ export function BottomTabs(props: BottomTabsProps) {
onCloseIssue={props.onCloseIssue}
onResetToTodo={props.onResetToTodo}
onCloseSessionTab={props.onCloseSessionTab}
onStartBrainstormSession={props.onStartBrainstormSession}
onContinueBrainstorm={props.onContinueBrainstorm}
onUpdateAutoReview={props.onUpdateAutoReview}
onUpdateProfilePath={props.onUpdateProfilePath}
onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath}
@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import type { ClaudeProfile } from '../hooks/useIssues'
import type { Issue, IssueColumn } from '../types'
import type { PropertyGroup } from './property-grid'
import type { PropertyGroup, RowAction } from './property-grid'
import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { isIssueLocked } from '../lib/dependencies'
@@ -87,11 +86,10 @@ interface IssueDetailPanelProps {
*/
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
/**
* Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty
* (created out-of-band via spx without webhook linking). Bound to the Play
* button next to the "头脑风暴会话 id" row.
* 「▶ 续聊」:由插件把续聊提示词送进头脑风暴会话——规划 tab 开着就直接
* 打进去,关了就 `claude --resume <id>` 带着提示词恢复,没会话就新开一个。
*/
onStartBrainstormSession: (issueNumber: number) => void
onContinueBrainstorm: (issueNumber: number) => void
/** Persist a per-issue `autoReview` override into the state JSON. */
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
/** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */
@@ -140,7 +138,7 @@ export function IssueDetailPanel({
onCloseIssue,
onResetToTodo,
onCloseSessionTab,
onStartBrainstormSession,
onContinueBrainstorm,
onUpdateAutoReview,
profiles,
onUpdateProfilePath,
@@ -206,39 +204,39 @@ export function IssueDetailPanel({
})),
},
(() => {
// 头脑风暴会话 id 行有三种状态:
// 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab
// 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮)
// 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab)
// 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮
// 头脑风暴会话 id 行:
// - 工单未完成 → 一直有 ▶ 续聊。插件把续聊提示词送进对应会话:
// 规划终端开着就直接打进去,关了就 --resume 带提示词,没会话就新开。
// - brainstormTabOpen → 再加一个 X 关闭 tab
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
const canStart = !hasSession && issue?.column !== 'done'
let secondaryActionIcon: ReactNode | undefined
let secondaryActionTitle: string | undefined
let onSecondaryAction: (() => void) | undefined
if (hasSession && issue?.brainstormTabOpen) {
secondaryActionIcon = <X className="size-3.5" />
secondaryActionTitle = '关闭头脑风暴 tab'
onSecondaryAction = () => {
if (issue)
onCloseSessionTab(issue.number, 'brainstorm')
}
const secondaryActions: RowAction[] = []
if (issue?.column !== 'done') {
secondaryActions.push({
icon: <Play className="size-3.5" />,
title: '续聊:把续聊提示词发给头脑风暴会话',
onClick: () => {
if (issue)
onContinueBrainstorm(issue.number)
},
})
}
else if (canStart) {
secondaryActionIcon = <Play className="size-3.5" />
secondaryActionTitle = '启动头脑风暴会话'
onSecondaryAction = () => {
if (issue)
onStartBrainstormSession(issue.number)
}
if (hasSession && issue?.brainstormTabOpen) {
secondaryActions.push({
icon: <X className="size-3.5" />,
title: '关闭头脑风暴 tab',
onClick: () => {
if (issue)
onCloseSessionTab(issue.number, 'brainstorm')
},
})
}
return {
key: 'sessionId',
label: '头脑风暴会话id',
type: 'action' as const,
description: hasSession
? '双击在新终端运行 claude --resume <id> 恢复对话'
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab',
? '双击在新终端运行 claude --resume <id> 恢复对话;点 ▶ 续聊'
: '工单尚未关联 cc 会话;点 ▶ 新开一个规划会话并续聊',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0)
@@ -247,9 +245,7 @@ export function IssueDetailPanel({
return
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
},
secondaryActionIcon,
secondaryActionTitle,
onSecondaryAction,
secondaryActions,
}
})(),
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
@@ -271,14 +267,13 @@ export function IssueDetailPanel({
return
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
},
secondaryActionIcon: issue?.implementTabOpen
? <X className="size-3.5" />
: undefined,
secondaryActionTitle: '关闭实施 tab',
onSecondaryAction: () => {
if (issue)
onCloseSessionTab(issue.number, 'implement')
},
secondaryActions: issue?.implementTabOpen
? [{
icon: <X className="size-3.5" />,
title: '关闭实施 tab',
onClick: () => onCloseSessionTab(issue.number, 'implement'),
}]
: [],
},
issue?.reviewSessionFileExists
? {
@@ -294,14 +289,13 @@ export function IssueDetailPanel({
return
onResumeReviewSession(v, issue.number, issue?.worktreePath)
},
secondaryActionIcon: issue?.reviewTabOpen
? <X className="size-3.5" />
: undefined,
secondaryActionTitle: '关闭审查 tab',
onSecondaryAction: () => {
if (issue)
onCloseSessionTab(issue.number, 'review')
},
secondaryActions: issue.reviewTabOpen
? [{
icon: <X className="size-3.5" />,
title: '关闭审查 tab',
onClick: () => onCloseSessionTab(issue.number, 'review'),
}]
: [],
}
: {
key: 'reviewSessionId',
@@ -318,24 +312,26 @@ export function IssueDetailPanel({
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
const canStart = !hasSession && !!issue?.pr
let secondaryActionIcon: ReactNode | undefined
let secondaryActionTitle: string | undefined
let onSecondaryAction: (() => void) | undefined
const secondaryActions: RowAction[] = []
if (hasSession && issue?.testTabOpen) {
secondaryActionIcon = <X className="size-3.5" />
secondaryActionTitle = '关闭测试 tab'
onSecondaryAction = () => {
if (issue)
onCloseSessionTab(issue.number, 'test')
}
secondaryActions.push({
icon: <X className="size-3.5" />,
title: '关闭测试 tab',
onClick: () => {
if (issue)
onCloseSessionTab(issue.number, 'test')
},
})
}
else if (canStart) {
secondaryActionIcon = <Play className="size-3.5" />
secondaryActionTitle = '启动测试会话'
onSecondaryAction = () => {
if (issue)
onStartTestSession(issue.number)
}
secondaryActions.push({
icon: <Play className="size-3.5" />,
title: '启动测试会话',
onClick: () => {
if (issue)
onStartTestSession(issue.number)
},
})
}
return {
key: 'testSessionId',
@@ -352,9 +348,7 @@ export function IssueDetailPanel({
return
onResumeTestSession(v, issue.number)
},
secondaryActionIcon,
secondaryActionTitle,
onSecondaryAction,
secondaryActions,
}
})(),
{
@@ -427,26 +421,27 @@ export function IssueDetailPanel({
description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
onOpen: (p: string) => onOpenFile(p),
// Hide the "实施" button entirely when there's no plan file.
secondaryActionIcon: issue?.planFile
? <Play className="size-3.5" />
: undefined,
secondaryActionTitle: locked ? lockTitle : '实施此计划',
// Disable while running or already done. The user can still click
// when status === 'failed' or never been run. Re-clicking while
// 'running' is blocked at the UI to avoid registering duplicate
// webhooks. To retry after a perceived failure with status still
// 'running', edit the state-JSON comment manually for now.
// 锁定态(前置工单未完成)同样禁止启动实施。
secondaryDisabled:
!issue?.planFile
|| issue?.implementStatus === 'running'
|| issue?.implementStatus === 'done'
|| locked,
onSecondaryAction: () => {
if (!issue?.planFile)
return
onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
},
secondaryActions: issue?.planFile
? [{
icon: <Play className="size-3.5" />,
title: locked ? lockTitle : '实施此计划',
// Disable while running or already done. The user can still click
// when status === 'failed' or never been run. Re-clicking while
// 'running' is blocked at the UI to avoid registering duplicate
// webhooks. To retry after a perceived failure with status still
// 'running', edit the state-JSON comment manually for now.
// 锁定态(前置工单未完成)同样禁止启动实施。
disabled:
issue.implementStatus === 'running'
|| issue.implementStatus === 'done'
|| locked,
onClick: () => {
if (!issue?.planFile)
return
onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
},
}]
: [],
},
{
key: 'pr',
@@ -461,17 +456,19 @@ export function IssueDetailPanel({
if (pr.length > 0)
onOpenPr(pr)
},
secondaryActionIcon: issue?.pr && !issue?.prDiffFile
? <Play className="size-3.5" />
: undefined,
secondaryActionTitle: prDiffSummaryRunning
? 'PR 变更摘要生成中'
: '生成 PR 变更摘要',
secondaryDisabled: prDiffSummaryRunning,
onSecondaryAction: () => {
if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
onGeneratePrDiffSummary(issue.number)
},
secondaryActions: issue?.pr && !issue?.prDiffFile
? [{
icon: <Play className="size-3.5" />,
title: prDiffSummaryRunning
? 'PR 变更摘要生成中'
: '生成 PR 变更摘要',
disabled: prDiffSummaryRunning,
onClick: () => {
if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
onGeneratePrDiffSummary(issue.number)
},
}]
: [],
},
{
key: 'prDiffFile',
@@ -486,12 +483,16 @@ export function IssueDetailPanel({
type: 'string',
readOnly: true,
description: '实施流程创建的分支名',
secondaryActionIcon: issue?.branch ? <GitMerge className="size-3.5" /> : undefined,
secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)',
onSecondaryAction: () => {
if (issue?.branch)
onMergeBranch(issue.number, issue.branch)
},
secondaryActions: issue?.branch
? [{
icon: <GitMerge className="size-3.5" />,
title: '本地预合并 (git merge --no-commit --no-ff)',
onClick: () => {
if (issue?.branch)
onMergeBranch(issue.number, issue.branch)
},
}]
: [],
},
issue?.worktreeExists
? {
@@ -500,9 +501,11 @@ export function IssueDetailPanel({
type: 'file-link',
description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
onOpen: (p: string) => onOpenWorktree(p),
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
secondaryActionIcon: <Trash2 className="size-3.5" />,
secondaryActionTitle: '删除 worktree',
secondaryActions: [{
icon: <Trash2 className="size-3.5" />,
title: '删除 worktree',
onClick: () => onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
}],
}
: {
key: 'worktreePath',
@@ -514,7 +517,7 @@ export function IssueDetailPanel({
],
},
],
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
)
const data = useMemo<Record<string, unknown> | null>(() => {
@@ -66,14 +66,41 @@ export interface PropertyDef {
onReload?: () => void
/** type='file-link' 专用:重新加载按钮的图标,默认为 RefreshCw */
reloadIcon?: ReactNode
/** type='file-link' / 'action' 通用:第二个右侧按钮的图标,缺省则不渲染该按钮 */
secondaryActionIcon?: ReactNode
/** type='file-link' / 'action' 通用:第二个右侧按钮的 tooltip */
secondaryActionTitle?: string
/** type='file-link' / 'action' 通用:第二个右侧按钮的点击回调 */
onSecondaryAction?: () => void
/** type='file-link' / 'action' 通用:第二个右侧按钮是否禁用 */
secondaryDisabled?: boolean
/** type='file-link' / 'action' / 'pr-link' / 只读 string 通用:值右侧的图标按钮,按数组顺序渲染 */
secondaryActions?: RowAction[]
}
/** 属性行右侧的图标按钮 */
export interface RowAction {
icon: ReactNode
/** tooltip,同时作为 aria-label */
title?: string
onClick: () => void
disabled?: boolean
}
function RowActionButtons({ actions, className }: { actions: RowAction[] | undefined, className?: string }): ReactNode {
if (!actions || actions.length === 0)
return null
return actions.map((action, i) => (
<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': {
const hasSecondaryAction = propDef.secondaryActionIcon != null
const secondaryDisabledAction = !!propDef.secondaryDisabled
const hasSecondaryAction = (propDef.secondaryActions?.length ?? 0) > 0
const hasValue = value != null && value !== ''
// Empty-value branch: still render the secondary button when defined
// (e.g. "Play" to start a brainstorm session for an issue that has
@@ -329,26 +355,7 @@ function PropertyRow({
: (
<span className="min-w-0 flex-1 truncate px-2 py-1.5 text-xs opacity-50">—</span>
)}
{hasSecondaryAction && (
<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>
)}
<RowActionButtons actions={propDef.secondaryActions} className="mr-2" />
</div>
)
}
@@ -356,8 +363,6 @@ function PropertyRow({
case 'file-link': {
const hasValue = typeof value === 'string' && value.length > 0
const reloadIcon = propDef.reloadIcon ?? <RefreshCw className="size-3.5" />
const hasSecondary = propDef.secondaryActionIcon != null
const secondaryDisabled = !!propDef.secondaryDisabled
return (
<div className="flex w-full items-center gap-1.5 px-2 py-1.5">
{hasValue
@@ -389,34 +394,14 @@ function PropertyRow({
{reloadIcon}
</button>
)}
{hasSecondary && (
<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>
)}
<RowActionButtons actions={propDef.secondaryActions} />
</div>
)
}
case 'pr-link': {
const hasValue = typeof value === 'string' && value.length > 0
const hasSecondary = propDef.secondaryActionIcon != null
const secondaryDisabled = !!propDef.secondaryDisabled
const hasSecondary = (propDef.secondaryActions?.length ?? 0) > 0
if (!hasValue && !hasSecondary) {
return (
<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>
)}
{hasSecondary && (
<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>
)}
<RowActionButtons actions={propDef.secondaryActions} />
</div>
)
}
@@ -469,30 +435,11 @@ function PropertyRow({
case 'string':
default:
if (ro) {
const hasSecondary = propDef.secondaryActionIcon != null
const secondaryDisabled = !!propDef.secondaryDisabled
if (hasSecondary) {
if ((propDef.secondaryActions?.length ?? 0) > 0) {
return (
<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>
<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>
<RowActionButtons actions={propDef.secondaryActions} />
</div>
)
}