/** * 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): Issue { const next = { ...issue, ...patch } as Issue & Record 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 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 /** * 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/` 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 { return !!raw && typeof raw === 'object' && Object.values(raw).every(v => typeof v === 'string') } function readInjectedSnapshot(): { issues: Issue[] scope: 'mine' | 'all' me?: string avatars?: Record 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 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( injected ? { status: 'ready', issues: injected.issues } : { status: 'loading' }, ) const [settings, setSettings] = useState(null) const [youtrackProjects, setYoutrackProjects] = useState({ status: 'idle', projects: [] }) const [globalAutoReview, setGlobalAutoReview] = useState(injected?.globalAutoReview ?? true) // 默认团队全部,与扩展侧 DEFAULT_KANBAN_SCOPE 一致(两个包不共享源码)。 const [scope, setScope] = useState<'mine' | 'all'>(injected?.scope ?? 'all') const [youtrackConfigured, setYoutrackConfigured] = useState(injected?.youtrackConfigured ?? false) const [refreshing, setRefreshing] = useState(false) const [toasts, setToasts] = useState([]) const [profiles, setProfiles] = useState([]) const [logs, setLogs] = useState([]) const [pendingSelectId, setPendingSelectId] = useState(null) const [commitRunning, setCommitRunning] = useState(false) const [hasChanges, setHasChanges] = useState(false) const [release, setRelease] = useState() const [cherryPick, setCherryPick] = useState<{ branch: string, groups: ReleaseCommitGroup[], firstAfterReleaseSha?: string } | null>(null) const [envLocked, setEnvLocked] = useState(false) const [envFileCount, setEnvFileCount] = useState(0) const [envLockRunning, setEnvLockRunning] = useState(false) const [envLockTitle, setEnvLockTitle] = useState('正在检查 .env 锁定状态…') const prDiffSummaryRunningRef = useRef>(new Set()) const [prDiffSummaryRunning, setPrDiffSummaryRunning] = useState>(() => new Set()) const [me, setMe] = useState(injected?.me) const [avatars, setAvatars] = useState>(injected?.avatars ?? {}) const [handoffUsers, setHandoffUsers] = useState<{ issueNumber: number, users: string[] } | null>(null) const handoffRunningRef = useRef>(new Set()) const [handoffRunning, setHandoffRunning] = useState>(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>(() => new Set()) const pendingTimersRef = useRef>(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 } }