照测试会话那条链路补一份 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
1042 lines
43 KiB
TypeScript
1042 lines
43 KiB
TypeScript
/**
|
||
* 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 }
|
||
}
|