✨ feat(vscode): 头脑风暴 profile 独立 + codex 命令统一组装收尾
- 头脑风暴/实施/测试会话 profile 彻底独立,互不回退,各自空值回落默认 - 新建工单弹窗的 profile 选择改为头脑风暴专用 brainstormProfilePath - 详情面板新增「头脑风暴配置文件」下拉 - 删除从未接线的死代码 reviewFlow.ts(runReview 全仓库无调用方) - codexCommand 精简为仅 resume/interactive 两种终端命令形态 - 版本号 0.2.55 → 0.2.60
This commit is contained in:
@@ -33,6 +33,7 @@ project_name: "superwork"
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.55",
|
||||
"version": "0.2.60",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* Single source of truth for assembling `codex` invocations.
|
||||
*
|
||||
* The review flow shells codex three different ways — headless
|
||||
* `codex exec review` (spawned as an arg array), `codex resume <id>` and the
|
||||
* interactive `codex '<prompt>'` TUI (both sent to a terminal). Building the
|
||||
* The review flow shells codex two ways — `codex resume <id>` and the
|
||||
* interactive `codex '<prompt>'` TUI — both sent to a terminal. Building the
|
||||
* flags in one place keeps `--dangerously-bypass-approvals-and-sandbox` and the
|
||||
* optional model / reasoning-effort `-c` overrides from drifting apart.
|
||||
*
|
||||
@@ -12,18 +11,16 @@
|
||||
*/
|
||||
|
||||
export interface CodexCommandOpts {
|
||||
/** 'exec-review' = `codex exec review ...`;'resume' = `codex resume <id>`;'interactive' = `codex '<prompt>'` */
|
||||
mode: 'exec-review' | 'resume' | 'interactive'
|
||||
/** 'resume' = `codex resume <id>`;'interactive' = `codex '<prompt>'` */
|
||||
mode: 'resume' | 'interactive'
|
||||
/** resume 模式必填:codex thread id */
|
||||
sessionId?: string
|
||||
/** interactive / exec-review 模式的 prompt(positional)。调用方保证 interactive 模式下不含单引号。 */
|
||||
/** interactive 模式的 prompt(positional)。调用方保证不含单引号。 */
|
||||
prompt?: string
|
||||
/** 空 = 不传 `-c model=`,用 codex 默认 */
|
||||
model?: string
|
||||
/** 空 = 不传 `-c model_reasoning_effort=`,用 codex 默认。合法值 minimal/low/medium/high/xhigh */
|
||||
reasoningEffort?: string
|
||||
/** exec-review 模式:追加 --json */
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
const BYPASS_FLAG = '--dangerously-bypass-approvals-and-sandbox'
|
||||
@@ -43,39 +40,11 @@ function configOverrides(opts: CodexCommandOpts): string[] {
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared assembly for both output shapes. `quotePrompt` wraps the positional
|
||||
* prompt in single quotes for the shell-string form; the spawn-array form
|
||||
* passes it raw (no shell, so no quoting).
|
||||
*/
|
||||
function buildParts(opts: CodexCommandOpts, quotePrompt: boolean): string[] {
|
||||
const overrides = configOverrides(opts)
|
||||
const prompt = opts.prompt
|
||||
? (quotePrompt ? `'${opts.prompt}'` : opts.prompt)
|
||||
: undefined
|
||||
switch (opts.mode) {
|
||||
case 'exec-review':
|
||||
return [
|
||||
'exec',
|
||||
'review',
|
||||
...overrides,
|
||||
BYPASS_FLAG,
|
||||
...(opts.json ? ['--json'] : []),
|
||||
...(prompt !== undefined ? [prompt] : []),
|
||||
]
|
||||
case 'resume':
|
||||
return ['resume', ...overrides, BYPASS_FLAG, ...(opts.sessionId ? [opts.sessionId] : [])]
|
||||
case 'interactive':
|
||||
return [...overrides, BYPASS_FLAG, ...(prompt !== undefined ? [prompt] : [])]
|
||||
}
|
||||
}
|
||||
|
||||
/** Argument array (without the leading `codex`) for `spawn('codex', args)`. */
|
||||
export function buildCodexArgs(opts: CodexCommandOpts): string[] {
|
||||
return buildParts(opts, false)
|
||||
}
|
||||
|
||||
/** Full shell command string (with `codex` prefix) for `terminal.sendText`. */
|
||||
export function buildCodexCommandString(opts: CodexCommandOpts): string {
|
||||
return ['codex', ...buildParts(opts, true)].join(' ')
|
||||
const overrides = configOverrides(opts)
|
||||
const parts = opts.mode === 'resume'
|
||||
? ['resume', ...overrides, BYPASS_FLAG, ...(opts.sessionId ? [opts.sessionId] : [])]
|
||||
: [...overrides, BYPASS_FLAG, ...(opts.prompt ? [`'${opts.prompt}'`] : [])]
|
||||
return ['codex', ...parts].join(' ')
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Spawns `codex exec review --json` headlessly and waits for it to finish.
|
||||
*
|
||||
* Output handling: codex itself is responsible for posting the review back
|
||||
* as a PR comment (instructed via the review prompt). This wrapper only
|
||||
* scans codex's NDJSON stdout for the very first `thread.started` event so
|
||||
* the caller can persist the `thread_id` (review session id) for manual
|
||||
* resume; everything after that is ignored. Failures are logged but never
|
||||
* thrown — the webhook coordinator treats review runs as fire-and-forget.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { logger } from '../logging/logger'
|
||||
import { buildCodexArgs } from './codexCommand'
|
||||
|
||||
export interface RunReviewOpts {
|
||||
workspaceRoot: string
|
||||
/** Already-substituted prompt — placeholders must be resolved by the caller. */
|
||||
prompt: string
|
||||
/** 空 = 不传 `-c model=`,用 codex config.toml 默认。 */
|
||||
model?: string
|
||||
/** 空 = 不传 `-c model_reasoning_effort=`,用 codex 默认。 */
|
||||
reasoningEffort?: string
|
||||
/** Spawn timeout in milliseconds. Defaults to 5 minutes. */
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Called once with the codex `thread_id` parsed from the first
|
||||
* `thread.started` NDJSON event. Errors thrown inside this callback are
|
||||
* caught and logged — they never propagate out of `runReview`.
|
||||
*/
|
||||
onThreadId?: (id: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000
|
||||
|
||||
/**
|
||||
* Run `codex exec review --json` and wait for it to exit. Errors are
|
||||
* logged as warnings; this function never throws.
|
||||
*/
|
||||
export async function runReview(opts: RunReviewOpts): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const args = buildCodexArgs({
|
||||
mode: 'exec-review',
|
||||
prompt: opts.prompt,
|
||||
model: opts.model,
|
||||
reasoningEffort: opts.reasoningEffort,
|
||||
json: true,
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
try {
|
||||
child = spawn('codex', args, {
|
||||
cwd: opts.workspaceRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'codex exec review 启动失败',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
// NDJSON parsing: accumulate stdout, split on newline, parse each line.
|
||||
// Stop parsing after the first `thread.started` event — let the rest
|
||||
// of stdout drain to /dev/null so codex can keep running.
|
||||
let buf = ''
|
||||
let threadIdHandled = false
|
||||
let stderrTail = ''
|
||||
|
||||
let diagLogged = 0
|
||||
const handleLine = (line: string): void => {
|
||||
if (threadIdHandled || !line.trim())
|
||||
return
|
||||
// Diagnostic: log the first 3 lines so we can inspect codex's actual
|
||||
// event shape if thread id detection misses.
|
||||
if (diagLogged < 3) {
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'webhook',
|
||||
message: `codex stdout[${diagLogged}]`,
|
||||
details: line.slice(0, 500),
|
||||
})
|
||||
diagLogged += 1
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object')
|
||||
return
|
||||
const obj = parsed as { type?: unknown, thread_id?: unknown, session_id?: unknown, id?: unknown }
|
||||
// Try multiple field names: thread_id (current), session_id (older
|
||||
// codex), id (fallback when the event is a "session created" shape).
|
||||
// Accept any event so long as it carries one of these as a string.
|
||||
const candidate
|
||||
= (typeof obj.thread_id === 'string' && obj.thread_id.length > 0 && obj.thread_id)
|
||||
|| (typeof obj.session_id === 'string' && obj.session_id.length > 0 && obj.session_id)
|
||||
|| (typeof obj.id === 'string' && obj.id.length > 0 && /^[0-9a-f-]{16,}$/i.test(obj.id) && obj.id)
|
||||
|| ''
|
||||
if (candidate) {
|
||||
threadIdHandled = true
|
||||
const id = candidate
|
||||
if (opts.onThreadId) {
|
||||
try {
|
||||
const ret = opts.onThreadId(id)
|
||||
if (ret && typeof (ret as Promise<unknown>).then === 'function') {
|
||||
;(ret as Promise<unknown>).catch((err) => {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'onThreadId 回调异常',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'onThreadId 回调抛错',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.setEncoding('utf-8')
|
||||
child.stdout?.on('data', (chunk: string) => {
|
||||
if (threadIdHandled)
|
||||
return
|
||||
buf += chunk
|
||||
let idx = buf.indexOf('\n')
|
||||
while (idx >= 0) {
|
||||
const line = buf.slice(0, idx)
|
||||
buf = buf.slice(idx + 1)
|
||||
handleLine(line)
|
||||
if (threadIdHandled) {
|
||||
buf = ''
|
||||
break
|
||||
}
|
||||
idx = buf.indexOf('\n')
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.setEncoding('utf-8')
|
||||
child.stderr?.on('data', (chunk: string) => {
|
||||
// Keep only the tail so we can include it in the failure message.
|
||||
stderrTail = (stderrTail + chunk).slice(-2000)
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: 'codex exec review 进程错误',
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
if (timedOut) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: `codex exec review 超时 (${timeoutMs}ms)`,
|
||||
details: stderrTail.slice(-500),
|
||||
})
|
||||
}
|
||||
else if ((code !== null && code !== 0) || (signal && !timedOut)) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'webhook',
|
||||
message: `codex exec review 退出码异常 code=${code} signal=${signal ?? '<none>'}`,
|
||||
details: stderrTail.slice(-500),
|
||||
})
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -73,6 +73,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
column: IssueColumn | null
|
||||
sessionId?: string
|
||||
profilePath?: string
|
||||
brainstormProfilePath?: string
|
||||
testProfilePath?: string
|
||||
specFile?: string
|
||||
planFile?: string
|
||||
@@ -116,6 +117,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
column: unknown
|
||||
sessionId?: unknown
|
||||
profilePath?: unknown
|
||||
brainstormProfilePath?: unknown
|
||||
testProfilePath?: unknown
|
||||
specFile?: unknown
|
||||
planFile?: unknown
|
||||
@@ -139,6 +141,9 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
const profilePath = typeof obj.profilePath === 'string' && obj.profilePath.length > 0
|
||||
? obj.profilePath
|
||||
: undefined
|
||||
const brainstormProfilePath = typeof obj.brainstormProfilePath === 'string' && obj.brainstormProfilePath.length > 0
|
||||
? obj.brainstormProfilePath
|
||||
: undefined
|
||||
const testProfilePath = typeof obj.testProfilePath === 'string' && obj.testProfilePath.length > 0
|
||||
? obj.testProfilePath
|
||||
: undefined
|
||||
@@ -180,6 +185,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
|
||||
column: obj.column,
|
||||
sessionId,
|
||||
profilePath,
|
||||
brainstormProfilePath,
|
||||
testProfilePath,
|
||||
specFile,
|
||||
planFile,
|
||||
@@ -305,6 +311,7 @@ async function buildIssue(opts: {
|
||||
column: fromComment,
|
||||
sessionId,
|
||||
profilePath,
|
||||
brainstormProfilePath,
|
||||
testProfilePath,
|
||||
specFile,
|
||||
planFile,
|
||||
@@ -369,6 +376,7 @@ async function buildIssue(opts: {
|
||||
column,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
...(profilePath ? { profilePath } : {}),
|
||||
...(brainstormProfilePath ? { brainstormProfilePath } : {}),
|
||||
...(testProfilePath ? { testProfilePath } : {}),
|
||||
...(specFile ? { specFile } : {}),
|
||||
...(planFile ? { planFile } : {}),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
|
||||
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
|
||||
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
|
||||
*/
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const
|
||||
|
||||
/**
|
||||
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
|
||||
|
||||
@@ -29,11 +29,11 @@ export interface Issue {
|
||||
/** Optional Claude Code session id stored alongside the column marker in
|
||||
* the issue's state-JSON comment. Used to resume the conversation. */
|
||||
sessionId?: string
|
||||
/** Absolute path to the Claude settings profile used at creation; passed
|
||||
* as --settings on resume. */
|
||||
/** 实施会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */
|
||||
profilePath?: string
|
||||
/** 测试会话专用的 Claude 配置文件,独立于实施会话的 `profilePath`;
|
||||
* 留空时测试会话回退到 `profilePath`,再回退默认。 */
|
||||
/** 头脑风暴会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */
|
||||
brainstormProfilePath?: string
|
||||
/** 测试会话专用的 Claude 配置文件;空 = DEFAULT_PROFILE_PATH,不回退其它字段。 */
|
||||
testProfilePath?: string
|
||||
/** Optional workspace-relative path to the spec file for this issue, as
|
||||
* surfaced from the Claude session transcript. Lives under
|
||||
|
||||
@@ -99,13 +99,13 @@ export class KanbanWebviewPanel {
|
||||
* terminal is shown; the session watcher fills in `sessionId` once cc
|
||||
* starts writing its jsonl. The webhook coordinator drains entries via
|
||||
* `takePendingIssueCreation` when the corresponding `issues opened`
|
||||
* payload arrives, then merges the column / sessionId / profilePath /
|
||||
* payload arrives, then merges the column / sessionId / brainstormProfilePath /
|
||||
* color into the state-JSON comment and cleans up the inbox tmpdir.
|
||||
*/
|
||||
// internal: handler 模块访问
|
||||
readonly pendingIssueCreations = new Map<string, {
|
||||
sessionId?: string
|
||||
profilePath?: string
|
||||
brainstormProfilePath?: string
|
||||
/** Palette id (e.g. `terminal.ansiBlue`) — same shape stored in state JSON. */
|
||||
color: string
|
||||
workspaceRoot: string
|
||||
@@ -372,7 +372,7 @@ export class KanbanWebviewPanel {
|
||||
return
|
||||
}
|
||||
if (msg.type === 'issue/create') {
|
||||
void issues.handleIssueCreate(this, msg.userRequest, msg.images, msg.profilePath)
|
||||
void issues.handleIssueCreate(this, msg.userRequest, msg.images, msg.brainstormProfilePath)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'profiles/list') {
|
||||
@@ -470,6 +470,10 @@ export class KanbanWebviewPanel {
|
||||
void issues.handleUpdateProfilePath(this, msg.issueNumber, msg.profilePath)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'issue/update-brainstorm-profile-path') {
|
||||
void issues.handleUpdateBrainstormProfilePath(this, msg.issueNumber, msg.brainstormProfilePath)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'issue/update-test-profile-path') {
|
||||
void issues.handleUpdateTestProfilePath(this, msg.issueNumber, msg.testProfilePath)
|
||||
return
|
||||
@@ -931,20 +935,6 @@ export class KanbanWebviewPanel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the profile.json that implement-class cc sessions
|
||||
* (handleImplement / handleResumeSession when sessionKind === 'implement')
|
||||
* should launch with. Priority:
|
||||
* 1. The per-issue `profilePath` recorded in state JSON (preserves the
|
||||
* brainstorm-time choice, overridable from the issue detail panel).
|
||||
* 2. The hard-coded `DEFAULT_PROFILE_PATH` fallback.
|
||||
*
|
||||
* Conflict-resolution sessions use the global
|
||||
* `settings.conflictResolutionProfilePath` instead of this helper.
|
||||
* Brainstorm sessions deliberately don't go through this helper — they
|
||||
* keep the legacy `profilePath || DEFAULT_PROFILE_PATH` so creators can
|
||||
* still pick a profile per issue at brainstorm time.
|
||||
*/
|
||||
/**
|
||||
* Resolve an issue number to its source identity, for routing state
|
||||
* persistence. Unknown numbers default to gitea (back-compat / pre-load).
|
||||
@@ -1058,7 +1048,7 @@ export class KanbanWebviewPanel {
|
||||
// internal: handler 模块访问
|
||||
takePendingIssueCreation(nonce: string): {
|
||||
sessionId?: string
|
||||
profilePath?: string
|
||||
brainstormProfilePath?: string
|
||||
color: string
|
||||
workspaceRoot: string
|
||||
inboxDir: string
|
||||
|
||||
@@ -1007,7 +1007,7 @@ export async function handleIssueCreate(
|
||||
panel: KanbanWebviewPanel,
|
||||
userRequest: string,
|
||||
images?: Array<{ mediaType: string, base64: string }>,
|
||||
profilePath?: string,
|
||||
brainstormProfilePath?: string,
|
||||
): Promise<void> {
|
||||
const trimmed = userRequest.trim()
|
||||
if (!trimmed) {
|
||||
@@ -1141,13 +1141,13 @@ export async function handleIssueCreate(
|
||||
}
|
||||
|
||||
const effectiveProfilePath
|
||||
= profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH
|
||||
= brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath : DEFAULT_PROFILE_PATH
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: `创建失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
message: `创建失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
dismissOnTimer: 8000,
|
||||
})
|
||||
try {
|
||||
@@ -1185,8 +1185,12 @@ export async function handleIssueCreate(
|
||||
message: `已创建终端 "${terminal.name}"`,
|
||||
})
|
||||
|
||||
// 新建工单会话是头脑风暴;只把用户选中的 brainstorm profile 写入 pending,
|
||||
// 空串不落盘(webhook 侧也会跳过空值),实施/测试 profile 各自独立配置。
|
||||
const storedBrainstormProfile
|
||||
= brainstormProfilePath && brainstormProfilePath.trim() !== '' ? brainstormProfilePath.trim() : undefined
|
||||
panel.pendingIssueCreations.set(nonce, {
|
||||
profilePath: effectiveProfilePath,
|
||||
brainstormProfilePath: storedBrainstormProfile,
|
||||
color,
|
||||
workspaceRoot,
|
||||
inboxDir,
|
||||
@@ -1572,10 +1576,10 @@ export async function handleUpdateAutoReview(panel: KanbanWebviewPanel, issueNum
|
||||
|
||||
/**
|
||||
* Persist a per-issue `profilePath` override into the issue's state JSON
|
||||
* comment. The implement / implement-resume / conflict-resolution cc sessions
|
||||
* launch with this profile (see resolveImplementProfilePath); empty falls back
|
||||
* to DEFAULT_PROFILE_PATH. Mirrors handleUpdateAutoReview: optimistic update on
|
||||
* the webview, rolled back via `issue/patch` if the persist fails.
|
||||
* comment. Implement / implement-resume sessions use this profile
|
||||
* (see resolveImplementProfilePath); empty = DEFAULT_PROFILE_PATH.
|
||||
* Mirrors handleUpdateAutoReview: optimistic update on the webview, rolled
|
||||
* back via `issue/patch` if the persist fails.
|
||||
*/
|
||||
export async function handleUpdateProfilePath(panel: KanbanWebviewPanel, issueNumber: number, profilePath: string): Promise<void> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
@@ -1661,12 +1665,94 @@ export async function handleUpdateProfilePath(panel: KanbanWebviewPanel, issueNu
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a per-issue `brainstormProfilePath` override into the issue's state
|
||||
* JSON. Brainstorm start/resume use this profile (see resolveBrainstormProfilePath);
|
||||
* empty = DEFAULT_PROFILE_PATH. 与实施/测试 profile 互不回退。
|
||||
*/
|
||||
export async function handleUpdateBrainstormProfilePath(panel: KanbanWebviewPanel, issueNumber: number, brainstormProfilePath: string): Promise<void> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot) {
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: '请先打开一个工作区文件夹',
|
||||
dismissOnTimer: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
if (!remote) {
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: '当前工作区没有 Gitea 远程仓库',
|
||||
dismissOnTimer: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const token = await getToken(panel.context, remote.host)
|
||||
if (!token) {
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: '请先完成 Gitea 配置',
|
||||
dismissOnTimer: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let previousValue: string | undefined
|
||||
try {
|
||||
const existingState = await panel.readIssueState(issueNumber)
|
||||
previousValue = typeof existingState.brainstormProfilePath === 'string'
|
||||
? existingState.brainstormProfilePath
|
||||
: undefined
|
||||
}
|
||||
catch {
|
||||
previousValue = undefined
|
||||
}
|
||||
|
||||
try {
|
||||
await panel.mergeIssueState(issueNumber, { brainstormProfilePath })
|
||||
logger.add({
|
||||
level: 'info',
|
||||
source: 'panel',
|
||||
message: `工单 #${issueNumber} brainstormProfilePath=${brainstormProfilePath} 已持久化`,
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.add({
|
||||
level: 'error',
|
||||
source: 'panel',
|
||||
message: `持久化 brainstormProfilePath 失败 (issue #${issueNumber})`,
|
||||
details: message,
|
||||
})
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: `保存工单 #${issueNumber} 头脑风暴配置文件失败: ${message}`,
|
||||
dismissOnTimer: 6000,
|
||||
})
|
||||
panel.postMessage({
|
||||
type: 'issue/patch',
|
||||
issueNumber,
|
||||
patch: { brainstormProfilePath: previousValue },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a per-issue `testProfilePath` override into the issue's state JSON
|
||||
* comment. The manually-started test cc session launches with this profile
|
||||
* (see resolveTestProfilePath); empty falls back to the implement `profilePath`,
|
||||
* then DEFAULT_PROFILE_PATH. Mirrors handleUpdateProfilePath: optimistic update
|
||||
* on the webview, rolled back via `issue/patch` if the persist fails.
|
||||
* comment. Test start/resume use this profile (see resolveTestProfilePath);
|
||||
* empty = DEFAULT_PROFILE_PATH,不回退实施 profile。
|
||||
*/
|
||||
export async function handleUpdateTestProfilePath(panel: KanbanWebviewPanel, issueNumber: number, testProfilePath: string): Promise<void> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
|
||||
@@ -68,12 +68,11 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
||||
existing.show(false)
|
||||
return
|
||||
}
|
||||
// 实施 resume 走工单级 profilePath > 默认 的优先级(同 handleImplement);
|
||||
// 头脑风暴 resume 保持现状,只看工单级 + 默认。
|
||||
// 实施 / 头脑风暴各自独立:调用方传入对应字段,空 = DEFAULT。
|
||||
const effectiveProfilePath
|
||||
= sessionKind === 'implement'
|
||||
? panel.resolveImplementProfilePath(profilePath)
|
||||
: (profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH)
|
||||
: resolveBrainstormProfilePath(panel, profilePath)
|
||||
// Reject paths containing single quotes — we shell-quote with single
|
||||
// quotes below, and embedded quotes would break out of the wrap. In
|
||||
// practice profile paths live under `/home/<user>/...` so this is a
|
||||
@@ -977,14 +976,12 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
return
|
||||
}
|
||||
|
||||
// Pull profilePath from the issue's state JSON so the new cc session
|
||||
// reuses whatever profile the user set when the issue was first created.
|
||||
// Tolerated: missing/unparseable comment falls back to DEFAULT_PROFILE_PATH.
|
||||
let profilePath: string | undefined
|
||||
// 头脑风暴只用 brainstormProfilePath;空 = DEFAULT,不回退实施 profile。
|
||||
let brainstormProfilePath: string | undefined
|
||||
try {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (stateObj && typeof stateObj.profilePath === 'string' && stateObj.profilePath.length > 0)
|
||||
profilePath = stateObj.profilePath
|
||||
if (stateObj && typeof stateObj.brainstormProfilePath === 'string' && stateObj.brainstormProfilePath.length > 0)
|
||||
brainstormProfilePath = stateObj.brainstormProfilePath
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
@@ -995,11 +992,10 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
})
|
||||
}
|
||||
|
||||
const effectiveProfilePath
|
||||
= profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH
|
||||
const effectiveProfilePath = resolveBrainstormProfilePath(panel, brainstormProfilePath)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`启动规划失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
`启动规划失败:brainstormProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1089,7 +1085,7 @@ export async function handleStartBrainstormSession(panel: KanbanWebviewPanel, is
|
||||
* 与 handleStartBrainstormSession 的差异:
|
||||
* - 首条 prompt 依赖 state JSON 里的 `pr`(合并的 PR 号);没有 PR 直接 toast 拒绝。
|
||||
* - cwd 优先用 worktree(存在时),否则退回 workspaceRoot——测试本质要读真实代码,
|
||||
* profile 走 resolveTestProfilePath(testProfilePath > profilePath > 默认)。
|
||||
* profile 走 resolveTestProfilePath(testProfilePath > 默认,不回退实施)。
|
||||
* - 捕获到的 session id 写进 state JSON 的 `testSessionId`。
|
||||
*
|
||||
* in-flight 锁 key `${issueNumber}:test`,防 createTerminal 期间用户重复点击。
|
||||
@@ -1136,11 +1132,10 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
return
|
||||
}
|
||||
|
||||
// 从 state JSON 读 pr / profile / worktree。pr 仅在主 worktree(已合并)
|
||||
// 从 state JSON 读 pr / testProfile / worktree。pr 仅在主 worktree(已合并)
|
||||
// 测试时必需——那条路径要靠 tea 了解合并进来的改动;worktree 内测试代码
|
||||
// 本就在工作目录,不依赖 PR。
|
||||
let pr: string | undefined
|
||||
let profilePath: string | undefined
|
||||
let testProfilePath: string | undefined
|
||||
let worktreePathRel: string | undefined
|
||||
let branch: string | undefined
|
||||
@@ -1149,8 +1144,6 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
if (stateObj) {
|
||||
if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0)
|
||||
pr = stateObj.pr
|
||||
if (typeof stateObj.profilePath === 'string' && stateObj.profilePath.length > 0)
|
||||
profilePath = stateObj.profilePath
|
||||
if (typeof stateObj.testProfilePath === 'string' && stateObj.testProfilePath.length > 0)
|
||||
testProfilePath = stateObj.testProfilePath
|
||||
if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0)
|
||||
@@ -1179,11 +1172,11 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
return
|
||||
}
|
||||
|
||||
// 测试会话优先用专用 testProfilePath,未设置时回退实施 profile,再回退默认。
|
||||
const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath, profilePath)
|
||||
// 测试会话只用 testProfilePath;空 = DEFAULT,不回退实施 profile。
|
||||
const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`启动测试失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
`启动测试失败:testProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1289,7 +1282,7 @@ export async function handleStartTestSession(panel: KanbanWebviewPanel, issueNum
|
||||
* `issue-${N}-测试`、命令 `claude ... --resume ${sessionId}`。
|
||||
*
|
||||
* cwd 优先用 worktree(relCwd 存在且在磁盘上),否则退回 workspaceRoot。
|
||||
* profile 与 start 对称:resolveTestProfilePath(testProfilePath > profilePath > 默认)。
|
||||
* profile 与 start 对称:resolveTestProfilePath(testProfilePath > 默认)。
|
||||
* in-flight 锁 key `${issueNumber}:test`。
|
||||
*/
|
||||
export async function handleResumeTestSession(panel: KanbanWebviewPanel, sessionId: string, issueNumber: number, _relCwd?: string): Promise<void> {
|
||||
@@ -1312,7 +1305,6 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
||||
let effectiveCwd = workspaceRoot
|
||||
// 顺带读出 branch 给 impl-tab-pre-create 钩子用;读不到就空字符串。
|
||||
let branchForHook = ''
|
||||
let profilePath: string | undefined
|
||||
let testProfilePath: string | undefined
|
||||
if (workspaceRoot) {
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
@@ -1323,8 +1315,6 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
||||
const worktreePathRel = typeof stateObj?.worktreePath === 'string' ? stateObj.worktreePath : undefined
|
||||
if (stateObj && typeof stateObj.branch === 'string')
|
||||
branchForHook = stateObj.branch
|
||||
if (typeof stateObj?.profilePath === 'string' && stateObj.profilePath.length > 0)
|
||||
profilePath = stateObj.profilePath
|
||||
if (typeof stateObj?.testProfilePath === 'string' && stateObj.testProfilePath.length > 0)
|
||||
testProfilePath = stateObj.testProfilePath
|
||||
effectiveCwd = await resolveTestSessionCwd(workspaceRoot, worktreePathRel)
|
||||
@@ -1341,10 +1331,10 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
||||
}
|
||||
|
||||
// --settings 决定 provider/model/鉴权,resume 也必须与 start 一致解析 profile。
|
||||
const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath, profilePath)
|
||||
const effectiveProfilePath = resolveTestProfilePath(panel, testProfilePath)
|
||||
if (effectiveProfilePath.includes('\'')) {
|
||||
void window.showErrorMessage(
|
||||
`resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
`resume 失败:testProfilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -1611,15 +1601,12 @@ export async function startConflictResolution(panel: KanbanWebviewPanel, opts: {
|
||||
* Pick the profile.json that implement-class cc sessions
|
||||
* (handleImplement / handleResumeSession when sessionKind === 'implement')
|
||||
* should launch with. Priority:
|
||||
* 1. The per-issue `profilePath` recorded in state JSON (preserves the
|
||||
* brainstorm-time choice, overridable from the issue detail panel).
|
||||
* 2. The hard-coded `DEFAULT_PROFILE_PATH` fallback.
|
||||
* 1. 工单级 `profilePath`(详情面板「实施配置文件」)
|
||||
* 2. DEFAULT_PROFILE_PATH
|
||||
*
|
||||
* Conflict-resolution sessions use the global
|
||||
* `settings.conflictResolutionProfilePath` instead of this helper.
|
||||
* Brainstorm sessions deliberately don't go through this helper — they
|
||||
* keep the legacy `profilePath || DEFAULT_PROFILE_PATH` so creators can
|
||||
* still pick a profile per issue at brainstorm time.
|
||||
* 冲突解决用全局 `settings.conflictResolutionProfilePath`。
|
||||
* 头脑风暴用 resolveBrainstormProfilePath;测试用 resolveTestProfilePath。
|
||||
* 三者互不回退。
|
||||
*/
|
||||
export function resolveImplementProfilePath(_panel: KanbanWebviewPanel, issueLevelProfilePath: string | undefined): string {
|
||||
const issueLevel = issueLevelProfilePath?.trim()
|
||||
@@ -1629,12 +1616,19 @@ export function resolveImplementProfilePath(_panel: KanbanWebviewPanel, issueLev
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试会话的 profile 解析:专用 `testProfilePath` 优先,未单独设置时回退到
|
||||
* 实施会话的 `profilePath`,再回退默认。让测试会话既能独立锁 profile,
|
||||
* 又默认与实施会话一致。
|
||||
* 头脑风暴会话 profile:工单级 `brainstormProfilePath` > DEFAULT。
|
||||
* 不回退实施 profile。
|
||||
*/
|
||||
export function resolveTestProfilePath(_panel: KanbanWebviewPanel, testProfilePath: string | undefined, implementProfilePath: string | undefined): string {
|
||||
return testProfilePath?.trim() || implementProfilePath?.trim() || DEFAULT_PROFILE_PATH
|
||||
export function resolveBrainstormProfilePath(_panel: KanbanWebviewPanel, brainstormProfilePath: string | undefined): string {
|
||||
return brainstormProfilePath?.trim() || DEFAULT_PROFILE_PATH
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试会话 profile:工单级 `testProfilePath` > DEFAULT。
|
||||
* 不回退实施 profile。
|
||||
*/
|
||||
export function resolveTestProfilePath(_panel: KanbanWebviewPanel, testProfilePath: string | undefined): string {
|
||||
return testProfilePath?.trim() || DEFAULT_PROFILE_PATH
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -346,7 +346,7 @@ export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: nu
|
||||
*/
|
||||
export function takePendingIssueCreation(panel: KanbanWebviewPanel, nonce: string): {
|
||||
sessionId?: string
|
||||
profilePath?: string
|
||||
brainstormProfilePath?: string
|
||||
color: string
|
||||
workspaceRoot: string
|
||||
inboxDir: string
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ExtensionToWebview
|
||||
= | { type: 'issues/loading' }
|
||||
| { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, testProfilePath?: string } }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||
| { type: 'issue/select-by-number', issueNumber: number }
|
||||
@@ -141,7 +141,7 @@ export type WebviewToExtension
|
||||
| { type: 'settings/edit-request' }
|
||||
| { type: 'youtrack/list-projects', baseUrl: string, token: string }
|
||||
| { type: 'youtrack/import' }
|
||||
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, profilePath?: string }
|
||||
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
|
||||
| { type: 'toast/open-url', url: string }
|
||||
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number }
|
||||
| { type: 'session/focus', issueNumber: number }
|
||||
@@ -161,6 +161,7 @@ export type WebviewToExtension
|
||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
||||
| { type: 'issue/update-profile-path', issueNumber: number, profilePath: string }
|
||||
| { type: 'issue/update-brainstorm-profile-path', issueNumber: number, brainstormProfilePath: string }
|
||||
| { type: 'issue/update-test-profile-path', issueNumber: number, testProfilePath: string }
|
||||
| { type: 'logs/fetch' }
|
||||
| { type: 'logs/clear' }
|
||||
|
||||
@@ -426,7 +426,7 @@ class WebhookCoordinator {
|
||||
* Handles a freshly opened gitea issue. Two paths:
|
||||
* - The issue body carries `<!-- spx:nonce=... -->` and matches a
|
||||
* pending creation tracked by the panel → merge column / sessionId /
|
||||
* profilePath / color into the state-JSON comment, append the card
|
||||
* brainstormProfilePath / color into the state-JSON comment, append the card
|
||||
* incrementally, then clean up the inbox tmpdir.
|
||||
* - No nonce / no match (external creation, e.g. manual `tea issues
|
||||
* create`) → just append the card so the kanban stays in sync; do
|
||||
@@ -490,8 +490,8 @@ class WebhookCoordinator {
|
||||
}
|
||||
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
|
||||
extra.sessionId = pending.sessionId
|
||||
if (typeof pending.profilePath === 'string' && pending.profilePath.length > 0)
|
||||
extra.profilePath = pending.profilePath
|
||||
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
|
||||
extra.brainstormProfilePath = pending.brainstormProfilePath
|
||||
|
||||
try {
|
||||
await mergeStateJsonComment({
|
||||
|
||||
@@ -69,6 +69,7 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
|
||||
reviewSessionId: str(state.reviewSessionId),
|
||||
testSessionId: str(state.testSessionId),
|
||||
profilePath: str(state.profilePath),
|
||||
brainstormProfilePath: str(state.brainstormProfilePath),
|
||||
testProfilePath: str(state.testProfilePath),
|
||||
specFile: str(state.specFile),
|
||||
planFile: str(state.planFile),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
|
||||
export const STATE_MARKER = '<!--spx-state-->'
|
||||
|
||||
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const
|
||||
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const
|
||||
|
||||
/**
|
||||
* Parse a comment body into a state object, or null if it isn't a state
|
||||
|
||||
@@ -55,6 +55,7 @@ export function App() {
|
||||
clearDependency,
|
||||
updateIssueAutoReview,
|
||||
updateIssueProfilePath,
|
||||
updateIssueBrainstormProfilePath,
|
||||
updateIssueTestProfilePath,
|
||||
logs,
|
||||
clearLogs,
|
||||
@@ -90,9 +91,9 @@ export function App() {
|
||||
// effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。
|
||||
const lastProgrammaticSelectRef = useRef<string | null>(null)
|
||||
|
||||
function handleSubmitNewIssue(userRequest: string, images: PastedImage[], profilePath?: string): void {
|
||||
function handleSubmitNewIssue(userRequest: string, images: PastedImage[], brainstormProfilePath?: string): void {
|
||||
const payload = images.map(({ mediaType, base64 }) => ({ mediaType, base64 }))
|
||||
createIssue(userRequest, payload.length > 0 ? payload : undefined, profilePath)
|
||||
createIssue(userRequest, payload.length > 0 ? payload : undefined, brainstormProfilePath)
|
||||
setShowNewIssueModal(false)
|
||||
}
|
||||
|
||||
@@ -195,7 +196,11 @@ export function App() {
|
||||
e.preventDefault()
|
||||
const isImpl = !!selectedIssue?.implementSessionId
|
||||
const cwd = isImpl ? selectedIssue?.worktreePath : undefined
|
||||
resumeSession(sid, selectedIssue?.profilePath, cwd, selectedIssue?.number)
|
||||
// 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。
|
||||
const profileForResume = isImpl
|
||||
? selectedIssue?.profilePath
|
||||
: selectedIssue?.brainstormProfilePath
|
||||
resumeSession(sid, profileForResume, cwd, selectedIssue?.number)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -355,6 +360,7 @@ export function App() {
|
||||
onStartBrainstormSession={startBrainstormSession}
|
||||
onUpdateAutoReview={updateIssueAutoReview}
|
||||
onUpdateProfilePath={updateIssueProfilePath}
|
||||
onUpdateBrainstormProfilePath={updateIssueBrainstormProfilePath}
|
||||
onUpdateTestProfilePath={updateIssueTestProfilePath}
|
||||
onOpenLogs={() => setShowLogs(true)}
|
||||
profileData={profileData}
|
||||
@@ -424,7 +430,6 @@ export function App() {
|
||||
onCancel={() => setShowNewIssueModal(false)}
|
||||
onSubmit={handleSubmitNewIssue}
|
||||
profiles={profiles}
|
||||
defaultProfileName="offical"
|
||||
/>
|
||||
<LogModal
|
||||
open={showLogs}
|
||||
|
||||
@@ -43,6 +43,7 @@ interface BottomTabsProps {
|
||||
onStartBrainstormSession: (issueNumber: number) => void
|
||||
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
||||
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
|
||||
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
||||
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||||
onOpenLogs: () => void
|
||||
|
||||
@@ -120,6 +121,7 @@ export function BottomTabs(props: BottomTabsProps) {
|
||||
onStartBrainstormSession={props.onStartBrainstormSession}
|
||||
onUpdateAutoReview={props.onUpdateAutoReview}
|
||||
onUpdateProfilePath={props.onUpdateProfilePath}
|
||||
onUpdateBrainstormProfilePath={props.onUpdateBrainstormProfilePath}
|
||||
onUpdateTestProfilePath={props.onUpdateTestProfilePath}
|
||||
profiles={props.profiles}
|
||||
onOpenLogs={props.onOpenLogs}
|
||||
|
||||
@@ -61,6 +61,8 @@ interface IssueDetailPanelProps {
|
||||
profiles: ClaudeProfile[]
|
||||
/** Persist a per-issue `profilePath` override into the state JSON. */
|
||||
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
|
||||
/** Persist a per-issue `brainstormProfilePath` override into the state JSON. */
|
||||
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
||||
/** Persist a per-issue `testProfilePath` override into the state JSON. */
|
||||
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||||
/** Open the in-webview log modal. */
|
||||
@@ -99,6 +101,7 @@ export function IssueDetailPanel({
|
||||
onUpdateAutoReview,
|
||||
profiles,
|
||||
onUpdateProfilePath,
|
||||
onUpdateBrainstormProfilePath,
|
||||
onUpdateTestProfilePath,
|
||||
onOpenLogs,
|
||||
}: IssueDetailPanelProps) {
|
||||
@@ -195,7 +198,7 @@ export function IssueDetailPanel({
|
||||
return
|
||||
if (!throttleBySessionId(v))
|
||||
return
|
||||
onResumeSession(v, issue?.profilePath, undefined, issue?.number)
|
||||
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
|
||||
},
|
||||
secondaryActionIcon,
|
||||
secondaryActionTitle,
|
||||
@@ -321,40 +324,46 @@ export function IssueDetailPanel({
|
||||
description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色',
|
||||
},
|
||||
{
|
||||
key: 'profilePath',
|
||||
label: '实施配置文件',
|
||||
key: 'brainstormProfilePath',
|
||||
label: '头脑风暴配置文件',
|
||||
type: 'select',
|
||||
options: (() => {
|
||||
// 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何
|
||||
// option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发
|
||||
// onChange、永远存不进去;占位项让未设置态有真实匹配项。
|
||||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||||
const current = issue?.profilePath
|
||||
// 自定义路径兜底:state JSON 里存的 profile 不在已知列表时,
|
||||
// 额外追加一项以免下拉显示空白、丢失当前选择。
|
||||
const current = issue?.brainstormProfilePath
|
||||
if (current && !opts.some(o => o.value === current))
|
||||
opts.push({ label: `自定义:${current}`, value: current })
|
||||
return opts
|
||||
})(),
|
||||
description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认(offical),选具体 profile 则覆盖(持久化 state JSON)',
|
||||
description: '头脑风暴会话使用的 Claude 配置文件;选「(默认)」用内置默认,与实施/测试互不回退',
|
||||
},
|
||||
{
|
||||
key: 'profilePath',
|
||||
label: '实施配置文件',
|
||||
type: 'select',
|
||||
options: (() => {
|
||||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||||
const current = issue?.profilePath
|
||||
if (current && !opts.some(o => o.value === current))
|
||||
opts.push({ label: `自定义:${current}`, value: current })
|
||||
return opts
|
||||
})(),
|
||||
description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/测试互不回退',
|
||||
},
|
||||
{
|
||||
key: 'testProfilePath',
|
||||
label: '测试配置文件',
|
||||
type: 'select',
|
||||
options: (() => {
|
||||
// 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何
|
||||
// option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发
|
||||
// onChange、永远存不进去;占位项让未设置态有真实匹配项。
|
||||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||||
const current = issue?.testProfilePath
|
||||
// 自定义路径兜底:state JSON 里存的 profile 不在已知列表时,
|
||||
// 额外追加一项以免下拉显示空白、丢失当前选择。
|
||||
if (current && !opts.some(o => o.value === current))
|
||||
opts.push({ label: `自定义:${current}`, value: current })
|
||||
return opts
|
||||
})(),
|
||||
description: '测试会话使用的 Claude 配置文件;留空时回退实施配置文件',
|
||||
description: '测试会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/实施互不回退',
|
||||
},
|
||||
{
|
||||
key: 'specFile',
|
||||
@@ -476,6 +485,7 @@ export function IssueDetailPanel({
|
||||
testSessionId: issue.testSessionId ?? null,
|
||||
autoReview: issue.autoReview ?? globalAutoReview,
|
||||
color: issue.color ?? null,
|
||||
brainstormProfilePath: issue.brainstormProfilePath ?? null,
|
||||
profilePath: issue.profilePath ?? null,
|
||||
testProfilePath: issue.testProfilePath ?? null,
|
||||
specFile: issue.specFile ?? null,
|
||||
@@ -582,6 +592,8 @@ export function IssueDetailPanel({
|
||||
onChange={(key, value) => {
|
||||
if (key === 'autoReview' && issue && typeof value === 'boolean')
|
||||
onUpdateAutoReview(issue.number, value)
|
||||
if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
|
||||
onUpdateBrainstormProfilePath(issue.number, value)
|
||||
if (key === 'profilePath' && issue && typeof value === 'string')
|
||||
onUpdateProfilePath(issue.number, value)
|
||||
if (key === 'testProfilePath' && issue && typeof value === 'string')
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ClaudeProfile {
|
||||
interface Props {
|
||||
open: boolean
|
||||
onCancel: () => void
|
||||
onSubmit: (userRequest: string, images: PastedImage[], profilePath?: string) => void
|
||||
onSubmit: (userRequest: string, images: PastedImage[], brainstormProfilePath?: string) => void
|
||||
profiles: ClaudeProfile[]
|
||||
defaultProfileName?: string
|
||||
}
|
||||
@@ -55,13 +55,10 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi
|
||||
setSelectedProfile(null)
|
||||
return
|
||||
}
|
||||
// Pick default profile when the modal opens. Prefer the one matching
|
||||
// `defaultProfileName`, otherwise fall back to the first alphabetically.
|
||||
if (profiles.length > 0) {
|
||||
const match = defaultProfileName
|
||||
? profiles.find(p => p.name === defaultProfileName)
|
||||
: undefined
|
||||
setSelectedProfile(match ? match.name : profiles[0].name)
|
||||
// 默认不预选 profile(空 = 系统默认);仅当显式传入 defaultProfileName 时预选。
|
||||
if (profiles.length > 0 && defaultProfileName) {
|
||||
const match = profiles.find(p => p.name === defaultProfileName)
|
||||
setSelectedProfile(match ? match.name : null)
|
||||
}
|
||||
else {
|
||||
setSelectedProfile(null)
|
||||
@@ -208,7 +205,7 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi
|
||||
function handleSubmit(): void {
|
||||
if (!canSubmit)
|
||||
return
|
||||
const profilePath = selectedProfile
|
||||
const brainstormProfilePath = selectedProfile
|
||||
? profiles.find(p => p.name === selectedProfile)?.path
|
||||
: undefined
|
||||
const pending = [...pastedTexts]
|
||||
@@ -224,7 +221,7 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi
|
||||
onSubmit(
|
||||
finalRequest,
|
||||
images.map(({ mediaType, base64, previewDataUrl }) => ({ mediaType, base64, previewDataUrl })),
|
||||
profilePath,
|
||||
brainstormProfilePath,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -248,8 +245,19 @@ export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfi
|
||||
|
||||
{profiles.length > 0 && (
|
||||
<fieldset className="mb-3 border-0 p-0">
|
||||
<legend className="mb-1 text-xs opacity-70">配置文件</legend>
|
||||
<legend className="mb-1 text-xs opacity-70">头脑风暴配置文件</legend>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<label className="flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="claude-profile"
|
||||
value=""
|
||||
checked={selectedProfile === null}
|
||||
onChange={() => setSelectedProfile(null)}
|
||||
className="accent-[var(--vscode-focusBorder)]"
|
||||
/>
|
||||
<span>(默认)</span>
|
||||
</label>
|
||||
{profiles.map(p => (
|
||||
<label key={p.name} className="flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
|
||||
@@ -127,7 +127,7 @@ export interface UseIssuesResult {
|
||||
youtrackProjects: YouTrackProjectsState
|
||||
dismissSettings: () => void
|
||||
requestEditAuth: () => void
|
||||
createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, profilePath?: string) => void
|
||||
createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void
|
||||
dismissToast: (id: string) => void
|
||||
openUrl: (url: string) => void
|
||||
resumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||||
@@ -153,8 +153,8 @@ export interface UseIssuesResult {
|
||||
* onDidCloseTerminal and clears `*TabOpen` so the X button disappears. */
|
||||
closeSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||||
/** Spawn a fresh "规划" cc tab for an existing issue whose sessionId is
|
||||
* still empty. The extension picks up profilePath from the issue's state
|
||||
* JSON, watches `~/.claude/projects/<encoded-workspaceRoot>` for the new
|
||||
* still empty. Extension 用工单级 brainstormProfilePath(空 = 默认),
|
||||
* watches `~/.claude/projects/<encoded-workspaceRoot>` for the new
|
||||
* session jsonl, then writes the id back as `sessionId`. */
|
||||
startBrainstormSession: (issueNumber: number) => void
|
||||
changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
|
||||
@@ -164,6 +164,9 @@ export interface UseIssuesResult {
|
||||
/** 覆盖该工单实施会话使用的 Claude 配置文件,乐观更新本地 state 后
|
||||
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。 */
|
||||
updateIssueProfilePath: (issueNumber: number, profilePath: string) => void
|
||||
/** 覆盖该工单头脑风暴会话专用的 Claude 配置文件,乐观更新本地 state 后
|
||||
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。 */
|
||||
updateIssueBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
||||
/** 覆盖该工单测试会话专用的 Claude 配置文件,乐观更新本地 state 后
|
||||
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。 */
|
||||
updateIssueTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||||
@@ -314,9 +317,9 @@ export function useIssues(): UseIssuesResult {
|
||||
const createIssue = useCallback((
|
||||
userRequest: string,
|
||||
images?: Array<{ mediaType: string, base64: string }>,
|
||||
profilePath?: string,
|
||||
brainstormProfilePath?: string,
|
||||
): void => {
|
||||
postMessage({ type: 'issue/create', userRequest, images, profilePath })
|
||||
postMessage({ type: 'issue/create', userRequest, images, brainstormProfilePath })
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: string): void => {
|
||||
@@ -458,6 +461,20 @@ export function useIssues(): UseIssuesResult {
|
||||
postMessage({ type: 'issue/update-profile-path', issueNumber, profilePath })
|
||||
}, [])
|
||||
|
||||
const updateIssueBrainstormProfilePath = useCallback((issueNumber: number, brainstormProfilePath: string): void => {
|
||||
setState((prev) => {
|
||||
if (prev.status !== 'ready')
|
||||
return prev
|
||||
return {
|
||||
status: 'ready',
|
||||
issues: prev.issues.map(i =>
|
||||
i.number === issueNumber ? { ...i, brainstormProfilePath } : i,
|
||||
),
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'issue/update-brainstorm-profile-path', issueNumber, brainstormProfilePath })
|
||||
}, [])
|
||||
|
||||
const updateIssueTestProfilePath = useCallback((issueNumber: number, testProfilePath: string): void => {
|
||||
// Optimistically update local state so the dropdown reflects immediately
|
||||
// without remounting the issues list / detail panel via a full reload.
|
||||
@@ -721,5 +738,5 @@ export function useIssues(): UseIssuesResult {
|
||||
return cleanup
|
||||
}, [clearPrDiffSummaryRunning])
|
||||
|
||||
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, 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, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, branchSyncBehind, branchSyncRunning, branchSyncDisabled, branchSyncTitle, runBranchSync, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock }
|
||||
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, 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, branchSyncBehind, branchSyncRunning, branchSyncDisabled, branchSyncTitle, runBranchSync, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock }
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export type ExtensionToWebview
|
||||
= | { type: 'issues/loading' }
|
||||
| { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, testProfilePath?: string } }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||
| { type: 'issue/select-by-number', issueNumber: number }
|
||||
@@ -156,7 +156,7 @@ export type WebviewToExtension
|
||||
| { type: 'settings/edit-request' }
|
||||
| { type: 'youtrack/list-projects', baseUrl: string, token: string }
|
||||
| { type: 'youtrack/import' }
|
||||
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, profilePath?: string }
|
||||
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string }
|
||||
| { type: 'toast/open-url', url: string }
|
||||
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number }
|
||||
| { type: 'session/focus', issueNumber: number }
|
||||
@@ -176,6 +176,7 @@ export type WebviewToExtension
|
||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
|
||||
| { type: 'issue/update-profile-path', issueNumber: number, profilePath: string }
|
||||
| { type: 'issue/update-brainstorm-profile-path', issueNumber: number, brainstormProfilePath: string }
|
||||
| { type: 'issue/update-test-profile-path', issueNumber: number, testProfilePath: string }
|
||||
| { type: 'logs/fetch' }
|
||||
| { type: 'logs/clear' }
|
||||
|
||||
@@ -16,9 +16,11 @@ export interface Issue {
|
||||
sessionId?: string
|
||||
/** Absolute path to the Claude settings profile used at creation; passed
|
||||
* as --settings on resume. */
|
||||
/** 实施会话专用的 Claude 配置文件;空 = 默认,不回退其它字段。 */
|
||||
profilePath?: string
|
||||
/** 测试会话专用的 Claude 配置文件,独立于实施会话的 `profilePath`;
|
||||
* 留空时测试会话回退到 `profilePath`,再回退默认。 */
|
||||
/** 头脑风暴会话专用的 Claude 配置文件;空 = 默认,不回退其它字段。 */
|
||||
brainstormProfilePath?: string
|
||||
/** 测试会话专用的 Claude 配置文件;空 = 默认,不回退其它字段。 */
|
||||
testProfilePath?: string
|
||||
/** Optional workspace-relative path to the spec file for this issue, as
|
||||
* surfaced from the Claude session transcript. Lives under
|
||||
|
||||
Reference in New Issue
Block a user