Files
superwork/vscode/webview-ui/src/hooks/useIssues.ts
T
cruldra b62e58d739 ✨ feat(vscode): 新增第五种工单会话「原型会话」
照测试会话那条链路补一份 prototype:

- 消息与路由:session/start-prototype、session/resume-prototype、
  issue/update-prototype-profile-path,都进只读门禁
- 会话:handleStartPrototypeSession / handleResumePrototypeSession,
  终端名 issue-N-原型;cwd 有 worktree 用 worktree、没有退主仓库根
  (原 resolveTestSessionCwd 改名 resolveWorktreeOrRootCwd 两种会话共用)
- 不要求有 PR,任何阶段都能起;前置工单锁仍然拦
- 提示词:新增 prompts/prototype.md,设置里可改,占位符 {issueNumber}
- profile:工单级 prototypeProfilePath > 设置里的原型默认 profile >
  内置默认,与其它会话互不回退
- 状态字段 prototypeSessionId / prototypeProfilePath / prototypeTabOpen
  贯通 gitea、youtrack、本机 state 与移交包
- 详情面板加「原型会话id」「原型配置文件」两行,设置面板加对应两项

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
2026-09-14 10:20:30 +08:00

1042 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Subscribes to the extension host's issue stream.
*
* State machine (`UseIssuesState`):
* - loading : 无快照的首次挂载,或 host 推 `issues/loading` 且当前还没有列表
* - ready : host 推了 `issues/update`,或 HTML 里注入了上一屏快照
* - error : host pushed `issues/error`; carries the user-facing message
*
* Settings UI is a separate overlay (`settings: SettingsOverlayState | null`)
* that floats above the kanban, mirroring how LogModal / NewIssueModal
* coexist with the board state. When `canCancel === false` the kanban is
* still mounted underneath, so the user sees a placeholder rather than the
* usual "加载中…".
*
* `setIssues` is exposed so the Kanban board can reorder cards locally
* after a drag. Persisting drag moves back to Gitea is step 3+.
*/
import type { ToastItem } from '../components/ToastStack'
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
import type { Issue, IssueColumn } from '../types'
import { useCallback, useEffect, useRef, useState } from 'react'
import { compareIssuesInColumn } from '../lib/issueSort'
import { onMessage, postMessage } from '../lib/vscode'
/** postMessage 用 null 清空 optional 字段;合并时 delete 还原 optional,避免残留 null */
function applyIssuePatch(issue: Issue, patch: Record<string, unknown>): Issue {
const next = { ...issue, ...patch } as Issue & Record<string, unknown>
for (const [k, v] of Object.entries(patch)) {
if (v === null)
delete next[k]
}
return next as Issue
}
export interface SettingsValues {
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
jenkinsProdHost: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
splitNewTerminals: boolean
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
codexModel: string
codexReasoningEffort: string
profilesDirectory: string
youtrackBaseUrl: string
youtrackProjectShortName: string
youtrackCloseCommand: string
youtrackToken: string
}
export interface SettingsOverlayState {
host: string
errorMessage?: string
canCancel: boolean
tokenSaved: boolean
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
testPrompt: string
prototypePrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
jenkinsProdHost: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
splitNewTerminals: boolean
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
defaultPrototypeProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
codexModel: string
codexReasoningEffort: string
profilesDirectory: string
youtrackBaseUrl: string
youtrackProjectShortName: string
youtrackCloseCommand: string
youtrackTokenSaved: boolean
}
/** A YouTrack project option for the settings dropdown. */
export interface YouTrackProjectOption {
id: string
name: string
shortName: string
}
/** State of the settings modal's "load projects" action. */
export interface YouTrackProjectsState {
status: 'idle' | 'loading' | 'ready' | 'error'
projects: YouTrackProjectOption[]
error?: string
}
export type UseIssuesState
= | { status: 'loading' }
| { status: 'ready', issues: Issue[] }
| { status: 'error', message: string }
/** Mirror of the extension-side ClaudeProfile in src/cc/profiles.ts. */
export interface ClaudeProfile {
name: string
path: string
}
export type ReleaseStatus = Extract<ExtensionToWebview, { type: 'release/status' }>
export type ReleaseAction = 'cut' | 'cherry-pick' | 'deploy'
export interface UseIssuesResult {
state: UseIssuesState
settings: SettingsOverlayState | null
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */
scope: 'mine' | 'all'
toggleScope: () => void
/** Gitea login → 头像 data URI;同事工单的卡片按它贴头像。 */
avatars: Record<string, string>
/**
* Latest known global `autoReview` value pushed alongside `issues/update`.
* Used by the detail panel to display the fallback value when an issue has
* no per-issue override.
*/
globalAutoReview: boolean
/**
* Whether YouTrack is configured (base URL + project set), pushed alongside
* `issues/update`. Drives whether the「导入 YouTrack 工单」toolbar button shows.
*/
youtrackConfigured: boolean
/**
* Open the native multi-select dialog to pick which YouTrack issues to
* mirror onto the board.
*/
importYouTrack: () => void
toasts: ToastItem[]
profiles: ClaudeProfile[]
setIssues: (issues: Issue[]) => void
refresh: () => void
/**
* True while the host is reloading the board (手动刷新、切换范围、webhook
* 触发都算)。看板已有列表时不会退回 loading 空屏,靠它转刷新图标给反馈。
*/
refreshing: boolean
saveSettings: (values: SettingsValues) => void
/**
* Fetch the YouTrack project list for the settings dropdown using the
* in-form base URL + token (so the user can pick before saving).
*/
listYouTrackProjects: (baseUrl: string, token: string) => void
/** Result of the most recent `listYouTrackProjects` call. */
youtrackProjects: YouTrackProjectsState
dismissSettings: () => void
requestEditAuth: () => void
createIssue: (userRequest: string, images?: Array<{ mediaType: string, base64: string }>, brainstormProfilePath?: string) => void
dismissToast: (id: string) => void
openUrl: (url: string) => void
resumeSession: (sessionId: string, kind: 'implement' | 'brainstorm', profilePath?: string, cwd?: string, issueNumber?: number) => void
/** 该会话 id 的开 tab 请求还没被扩展处理完 → 行内转 spinner。 */
isSessionTabPending: (sessionId: string) => boolean
resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
startReviewSession: (issueNumber: number) => void
startTestSession: (issueNumber: number) => void
resumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
startPrototypeSession: (issueNumber: number) => void
resumePrototypeSession: (sessionId: string, issueNumber: number, cwd?: string) => void
focusSession: (issueNumber: number) => void
openFile: (path: string) => void
implement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
generatePrDiffSummary: (issueNumber: number) => void
isPrDiffSummaryRunning: (issueNumber: number) => boolean
openPr: (pr: string) => void
openWorktree: (path: string) => void
deleteWorktree: (issueNumber: number, path: string) => void
mergeBranch: (issueNumber: number, branch: string) => void
/**
* 硬删 Gitea 工单及关联资源(worktree / PR / feature branch / cc tabs)。
* 用户在详情面板顶部点垃圾桶按钮触发。扩展端做 modal confirm + 串行清理,
* 全部成功后 push `issue/remove`,webview 将该 issue 从 issues 数组移除。
*/
deleteIssue: (issueNumber: number) => void
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
closeIssue: (issueNumber: number) => void
/**
* Dispose the matching session terminal tab. Extension reacts via
* onDidCloseTerminal and clears `*TabOpen` so the X button disappears.
*/
closeSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype') => void
/**
* Spawn a fresh "规划" cc tab for an existing issue whose sessionId is
* still empty. Extension 用工单级 brainstormProfilePath(空 = 默认),
* watches `~/.claude/projects/<encoded-workspaceRoot>` for the new
* session jsonl, then writes the id back as `sessionId`.
*/
continueBrainstorm: (issueNumber: number) => void
changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
setDependency: (issueNumber: number, prerequisiteNumber: number) => void
clearDependency: (issueNumber: number, prerequisiteNumber: number) => void
updateIssueAutoReview: (issueNumber: number, value: boolean) => void
/**
* 覆盖该工单实施会话使用的 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
/** 清掉工单记的头脑风暴会话 id,下次续聊新开会话。 */
clearBrainstormSession: (issueNumber: number) => void
/**
* 覆盖该工单测试会话专用的 Claude 配置文件,乐观更新本地 state 后
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
*/
updateIssueTestProfilePath: (issueNumber: number, testProfilePath: string) => void
/**
* 覆盖该工单原型会话专用的 Claude 配置文件,乐观更新本地 state 后
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
*/
updateIssuePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
logs: LogEntry[]
fetchLogs: () => void
clearLogs: () => void
/**
* True while the "提交当前代码" claude -p run is in flight. Used to
* disable the toolbar button and swap its icon to a spinner.
*/
commitRunning: boolean
/**
* Trigger a background `claude -p "提交下代码"` run in the workspace
* root, gated by `commitRunning`. The extension responds with
* `commit/state` messages and a `toast/show` on completion.
*/
runCommit: () => void
/**
* Whether the workspace git working tree currently has any uncommitted
* changes (working tree / index / untracked). Pushed by the extension via
* `commit/has-changes` and used by `PanelHeader` to skip rendering the
* commit button when the tree is clean. Defaults to `false` until the
* extension reports its first observation.
*/
hasChanges: boolean
/** 最近一次 `release/status`;首次消息到达前为 undefined(按钮显示「正在检查」并禁用)。 */
release: ReleaseStatus | undefined
/** 发布按钮三个动作;running 由扩展侧状态决定,webview 不自置。 */
runRelease: (action: ReleaseAction) => void
/** 摘修复选单:扩展推来分组后的未发布提交。 */
cherryPick: { branch: string, groups: ReleaseCommitGroup[], firstAfterReleaseSha?: string } | null
dismissCherryPick: () => void
confirmCherryPick: (shas: string[]) => void
/**
* Whether the workspace's `.env*` files are currently chmod-locked
* according to `workspaceState`. Persisted per-workspace.
*/
envLocked: boolean
/** File count from the latest `env-lock/status`. 0 disables the toggle. */
envFileCount: number
/**
* True while a `env-lock/toggle` chmod batch is in flight. Cleared when
* the next `env-lock/status` arrives.
*/
envLockRunning: boolean
/**
* Tooltip text for the env-lock button, pre-composed from lock state +
* file count.
*/
envLockTitle: string
/** Flip the env-lock state: chmod all `.env*` files to 444 or 644. */
toggleEnvLock: () => void
/**
* ID of an issue that should be auto-selected after an `issue/append`
* with `select: true` (i.e. user-initiated webhook creation). Consumers
* read this in a `useEffect`, apply the selection, then call
* `clearPendingSelect()` to reset.
*/
pendingSelectId: string | null
clearPendingSelect: () => void
/** 当前 Gitea login,来自最近一次 `issues/update`;undefined = 身份未知。 */
me: string | undefined
/** 最近一次 `requestHandoffUsers` 的结果;`null` = 仍在加载或尚未请求。 */
handoffUsers: { issueNumber: number, users: string[] } | null
/** 拉取某工单可移交的候选用户列表,结果通过 `handoffUsers` 返回。 */
requestHandoffUsers: (issueNumber: number) => void
/** 发起移交:删本机 worktree、打包会话记录挂到附件、把工单指派给对方。 */
startHandoff: (issueNumber: number, to: string) => void
/** 接管一个待接管工单:拉取移交附件、恢复会话与 worktree。 */
acceptHandoff: (issueNumber: number) => void
/** 是否有移交/接管流程正在该工单上跑,直到扩展端回 `handoff/done` 才清除。 */
isHandoffRunning: (issueNumber: number) => boolean
}
function isAvatarMap(raw: unknown): raw is Record<string, string> {
return !!raw && typeof raw === 'object' && Object.values(raw).every(v => typeof v === 'string')
}
function readInjectedSnapshot(): {
issues: Issue[]
scope: 'mine' | 'all'
me?: string
avatars?: Record<string, string>
globalAutoReview: boolean
youtrackConfigured: boolean
} | null {
const el = document.getElementById('kanban-snapshot')
if (!el?.textContent)
return null
try {
const parsed: unknown = JSON.parse(el.textContent)
if (!parsed || typeof parsed !== 'object')
return null
const obj = parsed as Record<string, unknown>
if (!Array.isArray(obj.issues))
return null
if (obj.scope !== 'mine' && obj.scope !== 'all')
return null
return {
issues: obj.issues as Issue[],
scope: obj.scope,
me: typeof obj.me === 'string' ? obj.me : undefined,
avatars: isAvatarMap(obj.avatars) ? obj.avatars : undefined,
globalAutoReview: obj.globalAutoReview === true,
youtrackConfigured: obj.youtrackConfigured === true,
}
}
catch {
return null
}
}
export function useIssues(): UseIssuesResult {
const injected = readInjectedSnapshot()
const [state, setState] = useState<UseIssuesState>(
injected ? { status: 'ready', issues: injected.issues } : { status: 'loading' },
)
const [settings, setSettings] = useState<SettingsOverlayState | null>(null)
const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] })
const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(injected?.globalAutoReview ?? true)
// 默认团队全部,与扩展侧 DEFAULT_KANBAN_SCOPE 一致(两个包不共享源码)。
const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'all')
const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(injected?.youtrackConfigured ?? false)
const [refreshing, setRefreshing] = useState<boolean>(false)
const [toasts, setToasts] = useState<ToastItem[]>([])
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
const [logs, setLogs] = useState<LogEntry[]>([])
const [pendingSelectId, setPendingSelectId] = useState<string | null>(null)
const [commitRunning, setCommitRunning] = useState<boolean>(false)
const [hasChanges, setHasChanges] = useState<boolean>(false)
const [release, setRelease] = useState<ReleaseStatus>()
const [cherryPick, setCherryPick] = useState<{ branch: string, groups: ReleaseCommitGroup[], firstAfterReleaseSha?: string } | null>(null)
const [envLocked, setEnvLocked] = useState<boolean>(false)
const [envFileCount, setEnvFileCount] = useState<number>(0)
const [envLockRunning, setEnvLockRunning] = useState<boolean>(false)
const [envLockTitle, setEnvLockTitle] = useState<string>('正在检查 .env 锁定状态…')
const prDiffSummaryRunningRef = useRef<Set<number>>(new Set())
const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState<ReadonlySet<number>>(() => new Set())
const [me, setMe] = useState<string | undefined>(injected?.me)
const [avatars, setAvatars] = useState<Record<string, string>>(injected?.avatars ?? {})
const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null)
const handoffRunningRef = useRef<Set<number>>(new Set())
const [handoffRunning, setHandoffRunning] = useState<Set<number>>(new Set())
const clearPrDiffSummaryRunning = useCallback((issueNumber: number): void => {
if (!prDiffSummaryRunningRef.current.delete(issueNumber))
return
setPrDiffSummaryRunning(new Set(prDiffSummaryRunningRef.current))
}, [])
const clearPendingSelect = useCallback((): void => {
setPendingSelectId(null)
}, [])
const toggleScope = useCallback((): void => {
setState({ status: 'loading' })
setScope(prev => prev === 'mine' ? 'all' : 'mine')
postMessage({ type: 'issues/set-scope', scope: scope === 'mine' ? 'all' : 'mine' })
}, [scope])
const refresh = useCallback((): void => {
setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
postMessage({ type: 'issues/refresh' })
// 用户刷新时重查发布状态:远端分支可能已经动了。
postMessage({ type: 'release/check' })
// Re-poll env-lock state so newly added .env files surface in the count.
postMessage({ type: 'env-lock/check' })
}, [])
const saveSettings = useCallback((values: SettingsValues): void => {
// Optimistically close the modal; extension will re-post `settings/show`
// with an errorMessage if save fails server-side.
setSettings(null)
postMessage({
type: 'settings/save',
host: values.host,
token: values.token,
webhookPort: values.webhookPort,
webhookSecret: values.webhookSecret,
brainstormPrompt: values.brainstormPrompt,
brainstormContinuePrompt: values.brainstormContinuePrompt,
implementPlanPrompt: values.implementPlanPrompt,
autoReview: values.autoReview,
reviewPrompt: values.reviewPrompt,
testPrompt: values.testPrompt,
prototypePrompt: values.prototypePrompt,
devBranch: values.devBranch,
releaseBranchPrefix: values.releaseBranchPrefix,
jenkinsJob: values.jenkinsJob,
jenkinsProdHost: values.jenkinsProdHost,
worktreeDirectory: values.worktreeDirectory,
worktreeOpenWith: values.worktreeOpenWith,
pycharmCommand: values.pycharmCommand,
worktreePostCreateScript: values.worktreePostCreateScript,
worktreePreRemoveScript: values.worktreePreRemoveScript,
implTabPreCreateScript: values.implTabPreCreateScript,
implTabPostCloseScript: values.implTabPostCloseScript,
splitNewTerminals: values.splitNewTerminals,
defaultBrainstormProfilePath: values.defaultBrainstormProfilePath,
defaultImplementProfilePath: values.defaultImplementProfilePath,
defaultTestProfilePath: values.defaultTestProfilePath,
defaultPrototypeProfilePath: values.defaultPrototypeProfilePath,
conflictResolutionProfilePath: values.conflictResolutionProfilePath,
commitProfilePath: values.commitProfilePath,
prDiffSummaryProfilePath: values.prDiffSummaryProfilePath,
codexModel: values.codexModel,
codexReasoningEffort: values.codexReasoningEffort,
profilesDirectory: values.profilesDirectory,
youtrackBaseUrl: values.youtrackBaseUrl,
youtrackProjectShortName: values.youtrackProjectShortName,
youtrackCloseCommand: values.youtrackCloseCommand,
youtrackToken: values.youtrackToken,
})
}, [])
const listYouTrackProjects = useCallback((baseUrl: string, token: string): void => {
setYoutrackProjects({ status: 'loading', projects: [] })
postMessage({ type: 'youtrack/list-projects', baseUrl, token })
}, [])
const importYouTrack = useCallback((): void => {
postMessage({ type: 'youtrack/import' })
}, [])
const dismissSettings = useCallback((): void => {
setSettings(null)
}, [])
const requestEditAuth = useCallback((): void => {
postMessage({ type: 'settings/edit-request' })
}, [])
const createIssue = useCallback((
userRequest: string,
images?: Array<{ mediaType: string, base64: string }>,
brainstormProfilePath?: string,
): void => {
postMessage({ type: 'issue/create', userRequest, images, brainstormProfilePath })
}, [])
const dismissToast = useCallback((id: string): void => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const openUrl = useCallback((url: string): void => {
postMessage({ type: 'toast/open-url', url })
}, [])
// 开 tab 是秒级操作(要读 state JSON、跑 pre-create 钩子),行内 spinner 要
// 真实反映进度:发请求时标 pending,扩展回 session/tab-settled 时清。消息万一
// 丢了就靠 PENDING_TIMEOUT_MS 兜底,spinner 不会永远转下去。
const [pendingSessionTabs, setPendingSessionTabs] = useState<Set<string>>(() => new Set())
const pendingTimersRef = useRef<Map<string, number>>(new Map())
const PENDING_TIMEOUT_MS = 60_000
const clearSessionTabPending = useCallback((sessionId: string): void => {
const timer = pendingTimersRef.current.get(sessionId)
if (timer !== undefined) {
window.clearTimeout(timer)
pendingTimersRef.current.delete(sessionId)
}
setPendingSessionTabs((prev) => {
if (!prev.has(sessionId))
return prev
const next = new Set(prev)
next.delete(sessionId)
return next
})
}, [])
const markSessionTabPending = useCallback((sessionId: string): void => {
const existing = pendingTimersRef.current.get(sessionId)
if (existing !== undefined)
window.clearTimeout(existing)
pendingTimersRef.current.set(
sessionId,
window.setTimeout(() => clearSessionTabPending(sessionId), PENDING_TIMEOUT_MS),
)
setPendingSessionTabs(prev => new Set(prev).add(sessionId))
}, [clearSessionTabPending])
const isSessionTabPending = useCallback(
(sessionId: string): boolean => pendingSessionTabs.has(sessionId),
[pendingSessionTabs],
)
useEffect(() => () => {
for (const timer of pendingTimersRef.current.values())
window.clearTimeout(timer)
pendingTimersRef.current.clear()
}, [])
const resumeSession = useCallback((
sessionId: string,
kind: 'implement' | 'brainstorm',
profilePath?: string,
cwd?: string,
issueNumber?: number,
): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume', sessionId, kind, profilePath, cwd, issueNumber })
}, [markSessionTabPending])
const focusSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/focus', issueNumber })
}, [])
const resumeReviewSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume-review', sessionId, issueNumber, cwd })
}, [markSessionTabPending])
const startReviewSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/start-review', issueNumber })
}, [])
const startTestSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/start-test', issueNumber })
}, [])
const resumeTestSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume-test', sessionId, issueNumber, cwd })
}, [markSessionTabPending])
const startPrototypeSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'session/start-prototype', issueNumber })
}, [])
const resumePrototypeSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => {
markSessionTabPending(sessionId)
postMessage({ type: 'session/resume-prototype', sessionId, issueNumber, cwd })
}, [markSessionTabPending])
const openFile = useCallback((path: string): void => {
postMessage({ type: 'editor/open-file', path })
}, [])
const implement = useCallback((
issueNumber: number,
planFile: string,
profilePath?: string,
sessionId?: string,
): void => {
postMessage({ type: 'issue/implement', issueNumber, planFile, profilePath, sessionId })
}, [])
const generatePrDiffSummary = useCallback((issueNumber: number): void => {
if (prDiffSummaryRunningRef.current.has(issueNumber))
return
prDiffSummaryRunningRef.current.add(issueNumber)
setPrDiffSummaryRunning(new Set(prDiffSummaryRunningRef.current))
postMessage({ type: 'issue/generate-pr-diff-summary', issueNumber })
}, [])
const isPrDiffSummaryRunning = useCallback((issueNumber: number): boolean => {
return prDiffSummaryRunning.has(issueNumber)
}, [prDiffSummaryRunning])
const requestHandoffUsers = useCallback((issueNumber: number): void => {
setHandoffUsers(null)
postMessage({ type: 'handoff/users', issueNumber })
}, [])
const markHandoffRunning = useCallback((issueNumber: number, running: boolean): void => {
if (running)
handoffRunningRef.current.add(issueNumber)
else
handoffRunningRef.current.delete(issueNumber)
setHandoffRunning(new Set(handoffRunningRef.current))
}, [])
const startHandoff = useCallback((issueNumber: number, to: string): void => {
if (handoffRunningRef.current.has(issueNumber))
return
markHandoffRunning(issueNumber, true)
postMessage({ type: 'handoff/start', issueNumber, to })
}, [markHandoffRunning])
const acceptHandoff = useCallback((issueNumber: number): void => {
if (handoffRunningRef.current.has(issueNumber))
return
markHandoffRunning(issueNumber, true)
postMessage({ type: 'handoff/accept', issueNumber })
}, [markHandoffRunning])
const isHandoffRunning = useCallback((issueNumber: number): boolean => handoffRunning.has(issueNumber), [handoffRunning])
const openPr = useCallback((pr: string): void => {
postMessage({ type: 'pr/open', pr })
}, [])
const openWorktree = useCallback((path: string): void => {
postMessage({ type: 'worktree/open', path })
}, [])
const deleteWorktree = useCallback((issueNumber: number, path: string): void => {
postMessage({ type: 'worktree/delete', issueNumber, path })
}, [])
const mergeBranch = useCallback((issueNumber: number, branch: string): void => {
postMessage({ type: 'git/merge-preview', issueNumber, branch })
}, [])
const deleteIssue = useCallback((issueNumber: number): void => {
postMessage({ type: 'issue/delete', issueNumber })
}, [])
const closeIssue = useCallback((issueNumber: number): void => {
postMessage({ type: 'issue/close', issueNumber })
}, [])
const closeSessionTab = useCallback(
(issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' | 'prototype'): void => {
postMessage({ type: 'session/close-tab', issueNumber, kind })
},
[],
)
const continueBrainstorm = useCallback((issueNumber: number): void => {
postMessage({ type: 'brainstorm/continue', issueNumber })
}, [])
const changeColumn = useCallback((issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string): void => {
postMessage({ type: 'column/change', issueNumber, toColumn, source, externalId })
}, [])
const setDependency = useCallback((issueNumber: number, prerequisiteNumber: number): void => {
postMessage({ type: 'dependency/set', issueNumber, prerequisiteNumber })
}, [])
const clearDependency = useCallback((issueNumber: number, prerequisiteNumber: number): void => {
postMessage({ type: 'dependency/clear', issueNumber, prerequisiteNumber })
}, [])
const updateIssueAutoReview = useCallback((issueNumber: number, value: boolean): void => {
// Optimistically update local state so the toggle reflects immediately
// and the issues list / detail panel are not remounted by a full reload.
// On failure, the extension posts `issue/patch` to roll us back.
setState((prev) => {
if (prev.status !== 'ready')
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === issueNumber ? { ...i, autoReview: value } : i,
),
}
})
postMessage({ type: 'issue/update-auto-review', issueNumber, value })
}, [])
const updateIssueProfilePath = useCallback((issueNumber: number, profilePath: string): void => {
// Optimistically update local state so the dropdown reflects immediately
// without remounting the issues list / detail panel via a full reload.
// On failure, the extension posts `issue/patch` to roll us back.
setState((prev) => {
if (prev.status !== 'ready')
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === issueNumber ? { ...i, profilePath } : i,
),
}
})
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 })
}, [])
// 清除头脑风暴会话 id:扩展侧先弹确认框,确认后才落库并推 issue/patch,
// 所以这里不做乐观更新。
const clearBrainstormSession = useCallback((issueNumber: number): void => {
postMessage({ type: 'issue/clear-brainstorm-session', issueNumber })
}, [])
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.
// On failure, the extension posts `issue/patch` to roll us back.
setState((prev) => {
if (prev.status !== 'ready')
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === issueNumber ? { ...i, testProfilePath } : i,
),
}
})
postMessage({ type: 'issue/update-test-profile-path', issueNumber, testProfilePath })
}, [])
const updateIssuePrototypeProfilePath = useCallback((issueNumber: number, prototypeProfilePath: string): void => {
setState((prev) => {
if (prev.status !== 'ready')
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === issueNumber ? { ...i, prototypeProfilePath } : i,
),
}
})
postMessage({ type: 'issue/update-prototype-profile-path', issueNumber, prototypeProfilePath })
}, [])
const fetchLogs = useCallback((): void => {
postMessage({ type: 'logs/fetch' })
}, [])
const clearLogs = useCallback((): void => {
postMessage({ type: 'logs/clear' })
}, [])
const runCommit = useCallback((): void => {
// The extension also guards via its own commitRunning flag, but we
// short-circuit the round-trip here so rapid double-clicks don't even
// hit the wire.
if (commitRunning)
return
postMessage({ type: 'commit/run' })
}, [commitRunning])
const runRelease = useCallback((action: ReleaseAction): void => {
postMessage({ type: `release/${action}` })
}, [])
const dismissCherryPick = useCallback((): void => {
setCherryPick(null)
}, [])
const confirmCherryPick = useCallback((shas: string[]): void => {
setCherryPick(null)
postMessage({ type: 'release/cherry-pick-confirm', shas })
}, [])
const toggleEnvLock = useCallback((): void => {
// Double-click guard — the next `env-lock/status` clears the running flag.
if (envLockRunning)
return
setEnvLockRunning(true)
postMessage({ type: 'env-lock/toggle' })
}, [envLockRunning])
const setIssues = useCallback((issues: Issue[]): void => {
setState(prev => prev.status === 'ready' ? { status: 'ready', issues } : prev)
}, [])
useEffect(() => {
const cleanup = onMessage((msg) => {
switch (msg.type) {
case 'session/tab-settled':
clearSessionTabPending(msg.sessionId)
break
case 'issues/loading':
setRefreshing(true)
setState(prev => prev.status === 'ready' ? prev : { status: 'loading' })
break
case 'issues/loaded':
setRefreshing(false)
break
case 'issues/update':
setState({ status: 'ready', issues: msg.issues })
setScope(msg.scope)
setGlobalAutoReview(msg.globalAutoReview)
setYoutrackConfigured(msg.youtrackConfigured)
setMe(msg.me)
// 头像抓取失败时下发空表,保留上一批已有的头像,避免卡片头像闪掉。
if (msg.avatars && Object.keys(msg.avatars).length > 0)
setAvatars(msg.avatars)
break
case 'issues/error':
setState({ status: 'error', message: msg.message })
break
case 'issue/patch':
setState((prev) => {
if (prev.status !== 'ready')
return prev
return {
status: 'ready',
issues: prev.issues.map(i =>
i.number === msg.issueNumber ? applyIssuePatch(i, msg.patch) : i,
),
}
})
if (msg.patch.prDiffFile)
clearPrDiffSummaryRunning(msg.issueNumber)
break
case 'issue/pr-diff-summary-done':
clearPrDiffSummaryRunning(msg.issueNumber)
break
case 'handoff/users-result':
setHandoffUsers({ issueNumber: msg.issueNumber, users: msg.users })
break
case 'handoff/done':
markHandoffRunning(msg.issueNumber, false)
break
case 'issue/remove':
// 工单被删除或关闭 — 从 open issues 数组移除。如果它是当前选中的,挑下一张
// 同列的卡片(按该列排序取首张近邻:完成列按合并时间,其余按 number 降序);同列没有则交给 App.tsx 的
// 现有 effect(selectedId 失效 → setSelectedId(null))兜底。
setState((prev) => {
if (prev.status !== 'ready')
return prev
const removed = prev.issues.find(i => i.number === msg.issueNumber)
const remaining = prev.issues.filter(i => i.number !== msg.issueNumber)
if (removed) {
const sameCol = remaining
.filter(i => i.column === removed.column)
.sort((a, b) => compareIssuesInColumn(removed.column, a, b))
const next = sameCol[0]
if (next)
setPendingSelectId(next.id)
}
return { status: 'ready', issues: remaining }
})
break
case 'issue/append':
setState((prev) => {
if (prev.status !== 'ready')
return prev
const exists = prev.issues.some(i => i.number === msg.issue.number)
return {
status: 'ready',
issues: exists
? prev.issues.map(i => i.number === msg.issue.number ? msg.issue : i)
: [...prev.issues, msg.issue],
}
})
if (msg.select)
setPendingSelectId(msg.issue.id)
break
case 'issue/select-by-number':
// 反向选中:column 2 切换终端 tab → 在 ready issues 里按 number 找到
// 对应工单的 id,复用 pendingSelectId 机制让 App.tsx 切换 selectedId。
setState((prev) => {
if (prev.status !== 'ready')
return prev
const issue = prev.issues.find(i => i.number === msg.issueNumber)
if (issue)
setPendingSelectId(issue.id)
return prev
})
break
case 'settings/show':
// Open (or re-open) the overlay. Kanban state is preserved
// underneath — when `canCancel === false` and we're still loading,
// App.tsx renders a "请先完成设置" placeholder rather than the
// usual spinner text.
setSettings({
host: msg.host,
errorMessage: msg.errorMessage,
canCancel: msg.canCancel === true,
tokenSaved: msg.tokenSaved,
webhookPort: msg.webhookPort,
webhookSecret: msg.webhookSecret,
brainstormPrompt: msg.brainstormPrompt,
brainstormContinuePrompt: msg.brainstormContinuePrompt,
implementPlanPrompt: msg.implementPlanPrompt,
autoReview: msg.autoReview,
reviewPrompt: msg.reviewPrompt,
testPrompt: msg.testPrompt,
prototypePrompt: msg.prototypePrompt,
devBranch: msg.devBranch,
releaseBranchPrefix: msg.releaseBranchPrefix,
jenkinsJob: msg.jenkinsJob,
jenkinsProdHost: msg.jenkinsProdHost,
worktreeDirectory: msg.worktreeDirectory,
worktreeOpenWith: msg.worktreeOpenWith,
pycharmCommand: msg.pycharmCommand,
worktreePostCreateScript: msg.worktreePostCreateScript,
worktreePreRemoveScript: msg.worktreePreRemoveScript,
implTabPreCreateScript: msg.implTabPreCreateScript,
implTabPostCloseScript: msg.implTabPostCloseScript,
splitNewTerminals: msg.splitNewTerminals,
defaultBrainstormProfilePath: msg.defaultBrainstormProfilePath,
defaultImplementProfilePath: msg.defaultImplementProfilePath,
defaultTestProfilePath: msg.defaultTestProfilePath,
defaultPrototypeProfilePath: msg.defaultPrototypeProfilePath,
conflictResolutionProfilePath: msg.conflictResolutionProfilePath,
commitProfilePath: msg.commitProfilePath,
prDiffSummaryProfilePath: msg.prDiffSummaryProfilePath,
codexModel: msg.codexModel,
codexReasoningEffort: msg.codexReasoningEffort,
profilesDirectory: msg.profilesDirectory,
youtrackBaseUrl: msg.youtrackBaseUrl ?? '',
youtrackProjectShortName: msg.youtrackProjectShortName ?? '',
youtrackCloseCommand: msg.youtrackCloseCommand ?? '',
youtrackTokenSaved: msg.youtrackTokenSaved === true,
})
// Reset the project dropdown each time the modal opens.
setYoutrackProjects({ status: 'idle', projects: [] })
break
case 'youtrack/projects':
setYoutrackProjects(msg.error
? { status: 'error', projects: [], error: msg.error }
: { status: 'ready', projects: msg.projects })
break
case 'toast/show': {
const toast: ToastItem = {
id: msg.id,
level: msg.level,
message: msg.message,
spinner: msg.spinner,
link: msg.link,
dismissOnTimer: msg.dismissOnTimer,
}
setToasts((prev) => {
const existing = prev.findIndex(t => t.id === toast.id)
if (existing >= 0) {
const next = [...prev]
next[existing] = toast
return next
}
return [...prev, toast]
})
break
}
case 'toast/dismiss':
setToasts(prev => prev.filter(t => t.id !== msg.id))
break
case 'profiles/update':
setProfiles(msg.profiles)
break
case 'logs/snapshot':
setLogs(msg.entries)
break
case 'logs/append':
setLogs(prev => [...prev, msg.entry].slice(-500))
break
case 'logs/cleared':
setLogs([])
break
case 'commit/state':
setCommitRunning(msg.running)
break
case 'commit/has-changes':
setHasChanges(msg.value)
break
case 'release/status':
setRelease(msg)
break
case 'release/pick-show':
setCherryPick({ branch: msg.branch, groups: msg.groups, firstAfterReleaseSha: msg.firstAfterReleaseSha })
break
case 'env-lock/status': {
setEnvLocked(msg.locked)
setEnvFileCount(msg.fileCount)
setEnvLockRunning(false)
// Title is fully derived from payload so consumers don't recompute.
if (msg.fileCount === 0) {
setEnvLockTitle('工作区无 .env 文件')
}
else if (msg.locked) {
const suffix = msg.failedCount ? `(${msg.failedCount} 个失败)` : ''
setEnvLockTitle(`已锁定 ${msg.fileCount} 个 .env 文件${suffix},点击解锁`)
}
else {
const suffix = msg.failedCount ? `(${msg.failedCount} 个失败)` : ''
setEnvLockTitle(`点击锁定 ${msg.fileCount} 个 .env 文件${suffix}`)
}
break
}
}
})
postMessage({ type: 'issues/refresh' })
postMessage({ type: 'profiles/list' })
postMessage({ type: 'logs/fetch' })
postMessage({ type: 'release/check' })
postMessage({ type: 'env-lock/check' })
return cleanup
}, [clearPrDiffSummaryRunning, markHandoffRunning])
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearBrainstormSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning }
}