/** * Subscribes to the extension host's issue stream. * * State machine (`UseIssuesState`): * - loading : initial mount + any time the host pushes `issues/loading` * - ready : host pushed `issues/update`; carries the current list * - 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 { useCallback, useEffect, useRef, useState } from 'react' import type { Issue, IssueColumn } from '../types' import type { ToastItem } from '../components/ToastStack' import type { LogEntry } from '../lib/messages' import { compareIssuesInColumn } from '../lib/issueSort' import { onMessage, postMessage } from '../lib/vscode' export interface SettingsValues { host: string token: string webhookPort: number brainstormPrompt: string implementPlanPrompt: string autoReview: boolean reviewPrompt: string devBranch: string autoBuildBranch: string worktreeDirectory: string worktreeOpenWith: 'vscode' | 'pycharm' worktreePostCreateScript: string worktreePreRemoveScript: string implTabPreCreateScript: string implTabPostCloseScript: string youtrackBaseUrl: string youtrackProjectShortName: string youtrackCloseCommand: string youtrackToken: string } export interface SettingsOverlayState { host: string errorMessage?: string canCancel: boolean tokenSaved: boolean webhookPort: number brainstormPrompt: string implementPlanPrompt: string autoReview: boolean reviewPrompt: string devBranch: string autoBuildBranch: string worktreeDirectory: string worktreeOpenWith: 'vscode' | 'pycharm' worktreePostCreateScript: string worktreePreRemoveScript: string implTabPreCreateScript: string implTabPostCloseScript: 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 interface UseIssuesResult { state: UseIssuesState settings: SettingsOverlayState | null /** 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 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 }>, profilePath?: string) => void dismissToast: (id: string) => void openUrl: (url: string) => void resumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void resumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void startTestSession: (issueNumber: number) => void resumeTestSession: (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') => void /** Spawn a fresh "规划" cc tab for an existing issue whose sessionId is * still empty. The extension picks up profilePath from the issue's state * JSON, watches `~/.claude/projects/` for the new * session jsonl, then writes the id back as `sessionId`. */ startBrainstormSession: (issueNumber: number) => void changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void 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` 回滚。 */ updateIssueTestProfilePath: (issueNumber: number, testProfilePath: 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 /** Commits the remote auto-build branch is behind the remote dev branch * (per latest `branch-sync/status`). 0 ⇒ in sync ⇒ disable button. */ branchSyncBehind: number /** True while a `branch-sync/run` request is in flight. The extension * doesn't echo a "running" state itself — we set this on click and clear * it when the next `branch-sync/status` arrives. */ branchSyncRunning: boolean /** True when sync is structurally unavailable (same branch, missing * remote, fetch error, …). Disables the button regardless of behind. */ branchSyncDisabled: boolean /** Tooltip text for the sync button (already includes branch names / * behind count / unavailable reason). */ branchSyncTitle: string /** Trigger a fast-forward push from remote dev to remote auto-build. */ runBranchSync: () => 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 } export function useIssues(): UseIssuesResult { const [state, setState] = useState({ status: 'loading' }) const [settings, setSettings] = useState(null) const [youtrackProjects, setYoutrackProjects] = useState({ status: 'idle', projects: [] }) const [globalAutoReview, setGlobalAutoReview] = useState(true) const [youtrackConfigured, setYoutrackConfigured] = 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 [branchSyncBehind, setBranchSyncBehind] = useState(0) const [branchSyncRunning, setBranchSyncRunning] = useState(false) const [branchSyncDisabled, setBranchSyncDisabled] = useState(true) const [branchSyncTitle, setBranchSyncTitle] = useState('正在检查分支同步状态…') 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 clearPrDiffSummaryRunning = useCallback((issueNumber: number): void => { if (!prDiffSummaryRunningRef.current.delete(issueNumber)) return setPrDiffSummaryRunning(new Set(prDiffSummaryRunningRef.current)) }, []) const clearPendingSelect = useCallback((): void => { setPendingSelectId(null) }, []) const refresh = useCallback((): void => { setState({ status: 'loading' }) postMessage({ type: 'issues/refresh' }) // Re-poll branch-sync state on user refresh — branches may have moved // on the remote since the last check. postMessage({ type: 'branch-sync/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, brainstormPrompt: values.brainstormPrompt, implementPlanPrompt: values.implementPlanPrompt, autoReview: values.autoReview, reviewPrompt: values.reviewPrompt, devBranch: values.devBranch, autoBuildBranch: values.autoBuildBranch, worktreeDirectory: values.worktreeDirectory, worktreeOpenWith: values.worktreeOpenWith, worktreePostCreateScript: values.worktreePostCreateScript, worktreePreRemoveScript: values.worktreePreRemoveScript, implTabPreCreateScript: values.implTabPreCreateScript, implTabPostCloseScript: values.implTabPostCloseScript, 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 }>, profilePath?: string, ): void => { postMessage({ type: 'issue/create', userRequest, images, profilePath }) }, []) 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 }) }, []) const resumeSession = useCallback(( sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number, ): void => { postMessage({ type: 'session/resume', sessionId, profilePath, cwd, issueNumber }) }, []) const focusSession = useCallback((issueNumber: number): void => { postMessage({ type: 'session/focus', issueNumber }) }, []) const resumeReviewSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => { postMessage({ type: 'session/resume-review', sessionId, issueNumber, cwd }) }, []) const startTestSession = useCallback((issueNumber: number): void => { postMessage({ type: 'session/start-test', issueNumber }) }, []) const resumeTestSession = useCallback((sessionId: string, issueNumber: number, cwd?: string): void => { postMessage({ type: 'session/resume-test', sessionId, issueNumber, cwd }) }, []) 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 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'): void => { postMessage({ type: 'session/close-tab', issueNumber, kind }) }, [], ) const startBrainstormSession = useCallback((issueNumber: number): void => { postMessage({ type: 'brainstorm/start', 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 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 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 runBranchSync = useCallback((): void => { // Double-click guard. The next `branch-sync/status` message clears the // running flag — both on success and on failure. if (branchSyncRunning) return setBranchSyncRunning(true) postMessage({ type: 'branch-sync/run' }) }, [branchSyncRunning]) 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 'issues/loading': setState({ status: 'loading' }) break case 'issues/update': setState({ status: 'ready', issues: msg.issues }) setGlobalAutoReview(msg.globalAutoReview) setYoutrackConfigured(msg.youtrackConfigured) 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 ? { ...i, ...msg.patch } : i, ), } }) if (msg.patch.prDiffFile) clearPrDiffSummaryRunning(msg.issueNumber) break case 'issue/pr-diff-summary-done': clearPrDiffSummaryRunning(msg.issueNumber) 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, brainstormPrompt: msg.brainstormPrompt, implementPlanPrompt: msg.implementPlanPrompt, autoReview: msg.autoReview, reviewPrompt: msg.reviewPrompt, devBranch: msg.devBranch, autoBuildBranch: msg.autoBuildBranch, worktreeDirectory: msg.worktreeDirectory, worktreeOpenWith: msg.worktreeOpenWith, worktreePostCreateScript: msg.worktreePostCreateScript, worktreePreRemoveScript: msg.worktreePreRemoveScript, implTabPreCreateScript: msg.implTabPreCreateScript, implTabPostCloseScript: msg.implTabPostCloseScript, 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 'branch-sync/status': { setBranchSyncBehind(msg.behind) setBranchSyncRunning(false) // Title + disabled derive purely from the status payload, so we // resolve them here once instead of recomputing in each render. if (msg.unavailable) { setBranchSyncDisabled(true) setBranchSyncTitle(msg.reason ?? '分支同步不可用') } else if (msg.behind <= 0) { setBranchSyncDisabled(true) setBranchSyncTitle(`${msg.devBranch} → ${msg.autoBuildBranch} 已同步`) } else { setBranchSyncDisabled(false) setBranchSyncTitle( `同步 ${msg.devBranch} → ${msg.autoBuildBranch}(落后 ${msg.behind} 个提交)`, ) } 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: 'branch-sync/check' }) postMessage({ type: 'env-lock/check' }) return cleanup }, [clearPrDiffSummaryRunning]) return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, branchSyncBehind, branchSyncRunning, branchSyncDisabled, branchSyncTitle, runBranchSync, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock } }