import type { PastedImage } from './components/NewIssueModal' import type { Issue } from './types' import { useEffect, useMemo, useRef, useState } from 'react' import { BottomTabs } from './components/BottomTabs' import { KanbanBoard } from './components/KanbanBoard' import { LogModal } from './components/LogModal' import { NewIssueModal } from './components/NewIssueModal' import { PanelHeader } from './components/PanelHeader' import { SettingsModal } from './components/SettingsModal' import { ToastStack } from './components/ToastStack' import { useIssues } from './hooks/useIssues' import { useManagedSessions } from './hooks/useManagedSessions' import { useProfiles } from './hooks/useProfiles' import { compareIssuesInColumn } from './lib/issueSort' import { COLUMN_ORDER } from './types' export function App() { const { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, scope, toggleScope, setIssues, refresh, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, branchSyncBehind, branchSyncRunning, branchSyncDisabled, branchSyncTitle, runBranchSync, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, } = useIssues() const { profiles: profileData, saveProfiles, openProfileValue } = useProfiles() const { managedSessions, createManagedSession, renameManagedSession, resumeManagedSession, deleteManagedSession, closeManagedSessionTab, listImportableNamedSessions, importNamedSession, importableSessions, importableLoading, } = useManagedSessions() const [showNewIssueModal, setShowNewIssueModal] = useState(false) const [showLogs, setShowLogs] = useState(false) const [selectedId, setSelectedId] = useState(null) // 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦 // effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。 const lastProgrammaticSelectRef = useRef(null) function handleSubmitNewIssue(userRequest: string, images: PastedImage[], brainstormProfilePath?: string): void { const payload = images.map(({ mediaType, base64 }) => ({ mediaType, base64 })) createIssue(userRequest, payload.length > 0 ? payload : undefined, brainstormProfilePath) setShowNewIssueModal(false) } // Drop selectedId if it's no longer present after issues refresh. const readyIssues = state.status === 'ready' ? state.issues : null useEffect(() => { if (!readyIssues) return if (selectedId && !readyIssues.some(i => i.id === selectedId)) setSelectedId(null) }, [readyIssues, selectedId]) // Auto-select issues that arrive via webhook in response to user-initiated // creation (issue/append with select: true). User-initiated, so we // intentionally override any current arrow-key selection. useEffect(() => { if (!pendingSelectId) return if (!readyIssues) return if (!readyIssues.some(i => i.id === pendingSelectId)) return lastProgrammaticSelectRef.current = pendingSelectId setSelectedId(pendingSelectId) clearPendingSelect() }, [pendingSelectId, readyIssues, clearPendingSelect]) // Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation. // 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。 const ordered = useMemo(() => { if (!readyIssues) return [] return COLUMN_ORDER.map(col => readyIssues.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)), ) }, [readyIssues]) // Resolved selected issue (or null) for the detail panel. const selectedIssue = useMemo(() => { if (!readyIssues || !selectedId) return null return readyIssues.find(i => i.id === selectedId) ?? null }, [readyIssues, selectedId]) // Auto-focus the terminal tab whenever the selected card has a session. // 优先 implementSessionId(实施终端),没有再回退到 sessionId(规划/头脑风暴终端); // 依赖里加入 implementSessionId,让"先有规划、后有实施"的场景能正确切换。 useEffect(() => { if (!selectedIssue) return // 这次选中是程序化设置的(反向选中等)→ 不聚焦终端,打断死循环。仅用户主动 // 点卡片/方向键切换才走聚焦。按 id 匹配,避免同一工单反向选中后残留的标记 // 误压制后续的用户选中。 if (selectedIssue.id === lastProgrammaticSelectRef.current) { lastProgrammaticSelectRef.current = null return } if (selectedIssue.implementSessionId || selectedIssue.sessionId) focusSession(selectedIssue.number) }, [selectedIssue?.implementSessionId, selectedIssue?.sessionId, selectedIssue?.number, focusSession]) // Global keyboard navigation. Skipped whenever an overlay (new-issue, logs, // settings) is showing, so its inputs receive arrow/Enter without // interference. const settingsOpen = settings !== null useEffect(() => { if (state.status !== 'ready') return if (showNewIssueModal) return if (showLogs) return if (settingsOpen) return function onKeyDown(e: KeyboardEvent): void { // Skip when typing in form fields. const active = document.activeElement const tag = active?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return // Editable elements (e.g. contenteditable divs). if (active && (active as HTMLElement).isContentEditable) return // 焦点在提交列表(role=listbox)等自管键盘的区域时,让它自己处理方向键。 if (active && (active as HTMLElement).closest?.('[role="listbox"]')) return if (e.key === 'Escape') { if (selectedId !== null) { e.preventDefault() setSelectedId(null) } return } if (e.key === 'Enter') { const sid = selectedIssue?.implementSessionId || selectedIssue?.sessionId if (sid) { e.preventDefault() const isImpl = !!selectedIssue?.implementSessionId const cwd = isImpl ? selectedIssue?.worktreePath : undefined // 实施 resume 用 profilePath;头脑风暴 resume 用 brainstormProfilePath。 const profileForResume = isImpl ? selectedIssue?.profilePath : selectedIssue?.brainstormProfilePath resumeSession(sid, profileForResume, cwd, selectedIssue?.number) } return } if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return // Locate current selection within `ordered`. let colIdx = -1 let rowIdx = -1 if (selectedId) { for (let c = 0; c < ordered.length; c++) { const r = ordered[c].findIndex(i => i.id === selectedId) if (r >= 0) { colIdx = c rowIdx = r break } } } // No current selection: pick the first card in the first non-empty // column (typically todo[0]). if (colIdx < 0) { for (let c = 0; c < ordered.length; c++) { if (ordered[c].length > 0) { e.preventDefault() setSelectedId(ordered[c][0].id) return } } return } if (e.key === 'ArrowUp') { if (rowIdx > 0) { e.preventDefault() setSelectedId(ordered[colIdx][rowIdx - 1].id) } return } if (e.key === 'ArrowDown') { if (rowIdx < ordered[colIdx].length - 1) { e.preventDefault() setSelectedId(ordered[colIdx][rowIdx + 1].id) } return } // Horizontal navigation: keep rowIdx, clamp to target column length - 1. // Skip empty columns entirely. const dir = e.key === 'ArrowLeft' ? -1 : 1 let target = colIdx + dir while (target >= 0 && target < ordered.length && ordered[target].length === 0) target += dir if (target < 0 || target >= ordered.length) return e.preventDefault() const clampedRow = Math.min(rowIdx, ordered[target].length - 1) setSelectedId(ordered[target][clampedRow].id) } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [state.status, showNewIssueModal, showLogs, settingsOpen, ordered, selectedId, selectedIssue, resumeSession]) // When the user is being forced into first-time setup, the kanban behind // the modal has nothing meaningful to show — replace the spinner with a // muted hint so it doesn't look like things are still loading. const blockedBySetup = settings !== null && !settings.canCancel && state.status === 'loading' return (
{state.status === 'loading' && ( <>
{blockedBySetup ? '请先完成设置' : '加载中…'}
)} {state.status === 'ready' && ( <>
setShowNewIssueModal(true)} selectedId={selectedId} onSelectIssue={setSelectedId} onColumnChange={changeColumn} onDependencySet={setDependency} onDependencyClear={clearDependency} />
setShowLogs(true)} profileData={profileData} onProfileSave={saveProfiles} onProfileOpen={openProfileValue} managedSessions={managedSessions} profiles={profiles} onManagedSessionCreate={createManagedSession} onManagedSessionRename={renameManagedSession} onManagedSessionResume={resumeManagedSession} onManagedSessionDelete={deleteManagedSession} onManagedSessionCloseTab={closeManagedSessionTab} importableSessions={importableSessions} importableLoading={importableLoading} onListImportableSessions={listImportableNamedSessions} onImportNamedSession={importNamedSession} />
)} {state.status === 'error' && ( <>
无法加载 issues
{state.message}
)} setShowNewIssueModal(false)} onSubmit={handleSubmitNewIssue} profiles={profiles} /> setShowLogs(false)} onClear={clearLogs} />
) }