"use client"; import React, { useCallback, useEffect } from "react"; import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos"; import { useSessionChangesCount } from "@/hooks/domains/session/use-session-changes-count"; import type { ReviewSource } from "@/hooks/domains/session/use-review-sources"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import { useFileEditors } from "@/hooks/use-file-editors"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { AppState } from "@/lib/state/store"; import { BrowserPanel } from "./browser-panel"; import type { OpenDiffOptions } from "./changes-diff-target"; import { ChangesPanel } from "./changes-panel"; import { CommitDetailPanel } from "./commit-detail-panel"; import { FileEditorPanel } from "./file-editor-panel"; import { FilesPanel } from "./files-panel"; import { PanelBody, PanelRoot } from "./panel-primitives"; import { PassthroughToolbar } from "./passthrough-toolbar"; import { TaskChangesPanel } from "./task-changes-panel"; import { TaskChatPanel } from "./task-chat-panel"; import { TaskPlanPanel } from "./task-plan-panel"; import { TerminalPanel } from "./terminal-panel"; import { VscodePanel } from "./vscode-panel"; const debugChangesVisibility = createDebugLogger("changes:visibility"); export const CHAT_PANEL_FALLBACK_LABEL = "Agent"; export function resolveChatPanelTitle(agentLabel: string | null | undefined): string { return agentLabel || CHAT_PANEL_FALLBACK_LABEL; } function useChatSessionTitle(panelId: string, sessionId: string | null) { const agentLabel = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; if (!session?.agent_profile_id) return null; const profile = state.agentProfiles.items.find( (p: { id: string }) => p.id === session.agent_profile_id, ); if (!profile) return null; const parts = profile.label.split(" \u2022 "); return parts[1] || parts[0] || profile.label; }); useEffect(() => { setPanelTitle(panelId, resolveChatPanelTitle(agentLabel)); }, [panelId, agentLabel]); } function ChatContent({ panelId, params }: { panelId: string; params: Record }) { const paramSessionId = params?.sessionId as string | undefined; const storeSessionId = useAppStore((state) => state.tasks.activeSessionId); const sessionId = paramSessionId ?? storeSessionId; const taskId = useAppStore((state) => state.tasks.activeTaskId); const { openFile } = useFileEditors(); const isPassthrough = useAppStore((state) => sessionId ? state.taskSessions.items[sessionId]?.is_passthrough === true : false, ); useChatSessionTitle(panelId, sessionId); if (isPassthrough) { return ( ); } return ( ); } function DiffViewerContent({ panelId, params, }: { panelId: string; params: Record; }) { const selectedDiff = useDockviewStore((s) => s.selectedDiff); const setSelectedDiff = useDockviewStore((s) => s.setSelectedDiff); const { openFile } = useFileEditors(); const panelKind = (params?.kind as string) ?? "all"; const selectedPath = panelKind === "file" ? (params?.path as string) : undefined; const selectedRepositoryName = panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined; const sourceFilter = ((params?.source as string) || "all") as "all" | ReviewSource; const panelSelectedDiff = panelKind === "all" ? selectedDiff : null; const handleClosePanel = useCallback(() => { const dockApi = useDockviewStore.getState().api; const panel = dockApi?.getPanel(panelId); if (dockApi && panel) dockApi.removePanel(panel); }, [panelId]); return ( setSelectedDiff(null)} onOpenFile={openFile} onBecameEmpty={handleClosePanel} /> ); } function describeTaskRepositoriesForDebug(state: AppState, taskId: string | null) { if (!taskId) return { source: "none", repositoryId: "-", repoCount: -1, repoIds: "-" }; const task = state.kanban.tasks.find((item) => item.id === taskId); if (task) { return { source: "kanban", repositoryId: task.repositoryId ?? "-", repoCount: task.repositories?.length ?? -1, repoIds: task.repositories?.map((repo) => repo.repository_id).join(",") || "-", }; } for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { const snapshotTask = snapshot.tasks.find((item) => item.id === taskId); if (!snapshotTask) continue; return { source: `kanbanMulti:${workflowId}`, repositoryId: snapshotTask.repositoryId ?? "-", repoCount: snapshotTask.repositories?.length ?? -1, repoIds: snapshotTask.repositories?.map((repo) => repo.repository_id).join(",") || "-", }; } return { source: "missing", repositoryId: "-", repoCount: -1, repoIds: "-" }; } function ChangesContent({ panelId }: { panelId: string }) { const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel); const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); const { openFile } = useFileEditors(); const appStore = useAppStoreApi(); // Dynamic title with file count - use environment-stable sessionId so the // tab title doesn't re-fetch on same-environment session tab switches. const activeSessionId = useEnvironmentSessionId(); const totalCount = useSessionChangesCount(activeSessionId); // Repo-less tasks have no git changes ever - auto-close the panel so users // don't see a permanently empty Changes tab. Gate on a confirmed `false`: // `null` means the task hasn't loaded yet, and removing the panel during // that window is unrecoverable in the same session. const taskHasRepos = useActiveTaskHasRepos(); useEffect(() => { const dockApi = useDockviewStore.getState().api; const panel = dockApi?.getPanel(panelId); if (isDebug()) { const state = appStore.getState(); const activeTaskId = state.tasks.activeTaskId; const repoDebug = describeTaskRepositoriesForDebug(state, activeTaskId); let action = "keep"; if (taskHasRepos === false) { action = panel ? "remove" : "remove-missing-panel"; } debugChangesVisibility("auto-close decision", { panelId, taskHasRepos: taskHasRepos === null ? "unknown" : String(taskHasRepos), action, activeTaskId: activeTaskId ?? "-", sessionId: state.tasks.activeSessionId ?? "-", taskSource: repoDebug.source, repositoryId: repoDebug.repositoryId, repoCount: repoDebug.repoCount, repoIds: repoDebug.repoIds, livePanelIds: dockApi?.panels.map((p) => p.id).join(",") ?? "-", }); } if (taskHasRepos !== false) return; if (dockApi && panel) dockApi.removePanel(panel); }, [taskHasRepos, panelId, appStore]); useEffect(() => { const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; setPanelTitle(panelId, title); }, [totalCount, panelId]); const handleEditFile = useCallback( (path: string, repo?: string) => openFile(path, repo), [openFile], ); const handleOpenDiffFile = useCallback( (path: string, options?: OpenDiffOptions) => addFileDiffPanel(path, { source: options?.source, repositoryName: options?.repositoryName }), [addFileDiffPanel], ); const handleOpenCommitDetail = useCallback( (sha: string, repo?: string) => addCommitDetailPanel(sha, { repo }), [addCommitDetailPanel], ); const handleOpenDiffAll = useCallback(() => addDiffViewerPanel(), [addDiffViewerPanel]); const handleOpenReview = useCallback(() => { window.dispatchEvent(new CustomEvent("open-review-dialog")); }, []); return ( ); } function FilesContent() { const { openFile } = useFileEditors(); const handleOpenFile = useCallback( (file: { path: string; name: string; content: string }) => openFile(file.path), [openFile], ); return ; } function PlanContent() { const taskId = useAppStore((state) => state.tasks.activeTaskId); return ; } const COMPONENT_ALIASES: Record = { "diff-files": "changes", "all-files": "files", }; function resolveComponent(component: string): string { return COMPONENT_ALIASES[component] ?? component; } export function renderPanel( panelId: string, component: string, params: Record, ): React.ReactNode { const resolved = resolveComponent(component); switch (resolved) { case "sidebar": return null; case "chat": return ; case "diff-viewer": return ; case "file-editor": return ; case "commit-detail": return ; case "changes": return ; case "files": return ; case "terminal": return ; case "browser": return ; case "vscode": return ; case "plan": return ; case "pr-detail": return ; default: return
Unknown panel: {component}
; } }