From dbf4d4b75d8ed16e8abf28417e81c86331ca7afa Mon Sep 17 00:00:00 2001 From: JJ_EMPTY_STRING Date: Tue, 9 Dec 2025 14:09:40 +0800 Subject: [PATCH 1/5] fix: terminal unmounting between sessions navigation --- src/components/ConsolidatedTerminal.tsx | 88 +++++++++++++++++-------- 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/src/components/ConsolidatedTerminal.tsx b/src/components/ConsolidatedTerminal.tsx index c69baab2..7474a8ed 100644 --- a/src/components/ConsolidatedTerminal.tsx +++ b/src/components/ConsolidatedTerminal.tsx @@ -283,27 +283,6 @@ export const ConsolidatedTerminal = forwardRef< xterm.attachCustomKeyEventHandler(localHandleKeyEvent); xterm.onData(localHandleXtermData); - // Setup resize observers if visible - if (!isHidden && terminalRef.current) { - window.addEventListener("resize", localHandleResize); - resizeObserverRef.current = new ResizeObserver(localHandleResize); - resizeObserverRef.current.observe(terminalRef.current); - } - - // Initial fit if visible - if (!isHidden && terminalRef.current) { - requestAnimationFrame(() => { - if (!terminalRef.current || !xtermRef.current) return; - const rect = terminalRef.current.getBoundingClientRect(); - if (rect.width === 0 || rect.height === 0) return; - try { - fitAddon.fit(); - } catch (error) { - console.warn("Initial fit failed", error); - } - }); - } - let resizeTimeout: ReturnType | null = null; // Setup PTY @@ -343,10 +322,6 @@ export const ConsolidatedTerminal = forwardRef< idleTimeoutRef.current = null; } - window.removeEventListener("resize", localHandleResize); - resizeObserverRef.current?.disconnect(); - resizeObserverRef.current = null; - unlistenRef.current?.(); unlistenRef.current = null; @@ -368,9 +343,70 @@ export const ConsolidatedTerminal = forwardRef< autoCommand, instanceKey, idleTimeoutMs, - isHidden, ]); + // Separate effect to handle resize observer based on visibility + useEffect(() => { + if (!terminalRef.current || !xtermRef.current || !fitAddonRef.current) return; + + const xterm = xtermRef.current; + const fitAddon = fitAddonRef.current; + const terminal = terminalRef.current; + + const handleResize = () => { + if (!terminal || !xterm || !fitAddon) return; + + const rect = terminal.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + if (!("buffer" in xterm)) return; + + try { + fitAddon.fit(); + const { rows, cols } = xterm; + lastValidDimensionsRef.current = { rows, cols }; + + if (isPtyReadyRef.current) { + ptyResize(sessionId, rows, cols).catch((error) => { + console.error("Resize error:", error); + }); + } + } catch (error) { + console.warn("Resize failed", error); + } + }; + + if (isHidden) { + // Clean up resize observers when hidden + window.removeEventListener("resize", handleResize); + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + return; + } + + // Set up resize observers when visible + window.addEventListener("resize", handleResize); + resizeObserverRef.current = new ResizeObserver(handleResize); + resizeObserverRef.current.observe(terminal); + + // Initial fit when becoming visible + requestAnimationFrame(() => { + if (!terminal) return; + const rect = terminal.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + try { + fitAddon.fit(); + } catch (error) { + console.warn("Initial fit failed", error); + } + }); + + return () => { + window.removeEventListener("resize", handleResize); + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + }; + }, [isHidden, sessionId]); + useImperativeHandle(ref, () => ({ findNext: (term: string, options?: ISearchOptions) => { if (!term || !searchAddonRef.current) return false; From 9c6a670c4b7ce455836b064dba738f8c16afc03e Mon Sep 17 00:00:00 2001 From: TzeYiing Date: Fri, 12 Dec 2025 22:10:41 +0800 Subject: [PATCH 2/5] more features --- src/components/ConsolidatedTerminal.tsx | 5 +- src/components/CreateWorkspaceDialog.tsx | 4 +- src/components/Dashboard.tsx | 159 +-- src/components/FileBrowser.tsx | 71 +- src/components/GitChangesSection.tsx | 3 + src/components/GitFileRow.tsx | 25 +- src/components/MergeDialog.tsx | 2 +- src/components/ModelSelector.tsx | 37 +- src/components/PlanDisplay.tsx | 5 +- src/components/PlanHistoryDialog.tsx | 2 +- src/components/RepositorySettingsContent.tsx | 5 +- src/components/ReviewSummaryPanel.tsx | 2 +- src/components/SessionSidebar.tsx | 48 +- src/components/SessionTerminal.tsx | 1020 +--------------- src/components/SettingsPage.tsx | 2 +- src/components/StagingDiffViewer.tsx | 683 +++++++---- src/components/WorkspaceEditSession.tsx | 4 +- src/components/WorkspaceTerminalPane.tsx | 1139 ++++++++++++++++++ src/index.css | 18 + 19 files changed, 1795 insertions(+), 1439 deletions(-) create mode 100644 src/components/WorkspaceTerminalPane.tsx diff --git a/src/components/ConsolidatedTerminal.tsx b/src/components/ConsolidatedTerminal.tsx index 7474a8ed..57d6e943 100644 --- a/src/components/ConsolidatedTerminal.tsx +++ b/src/components/ConsolidatedTerminal.tsx @@ -40,6 +40,8 @@ interface ConsolidatedTerminalProps { terminalPaneClassName?: string; terminalBackgroundClassName?: string; isHidden?: boolean; + /** Skip loading state - useful for split terminals where seamless appearance is preferred */ + skipLoadingState?: boolean; } export interface ConsolidatedTerminalHandle { @@ -75,6 +77,7 @@ export const ConsolidatedTerminal = forwardRef< terminalPaneClassName, terminalBackgroundClassName, isHidden = false, + skipLoadingState = false, }, ref ) => { @@ -448,7 +451,7 @@ export const ConsolidatedTerminal = forwardRef< "[&_.xterm-viewport::-webkit-scrollbar-thumb]:rounded" )} /> - {!isPtyReady && !terminalError && ( + {!isPtyReady && !terminalError && !skipLoadingState && (
Preparing terminal... diff --git a/src/components/CreateWorkspaceDialog.tsx b/src/components/CreateWorkspaceDialog.tsx index a0380bef..373f4735 100644 --- a/src/components/CreateWorkspaceDialog.tsx +++ b/src/components/CreateWorkspaceDialog.tsx @@ -202,7 +202,7 @@ export const CreateWorkspaceDialog: React.FC = ({ Create New Workspace - Create a new jj workspace for parallel development + Create a new workspace for parallel development @@ -213,7 +213,7 @@ export const CreateWorkspaceDialog: React.FC = ({ id="intent" value={intent} onChange={(e) => setIntent(e.target.value)} - placeholder="What do you want to work on? (e.g., Add dark mode toggle to settings)" + placeholder="e.g., Add dark mode to settings" rows={3} className="resize-none" /> diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index ef37d8aa..43b404dd 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -16,12 +16,12 @@ import { ErrorBoundary } from "./ErrorBoundary"; import { GitChangesSection } from "./GitChangesSection"; import { MoveToWorkspaceDialog } from "./MoveToWorkspaceDialog"; import { LineDiffStatsDisplay } from "./LineDiffStatsDisplay"; +import { WorkspaceTerminalPane, type ClaudeSessionData } from "./WorkspaceTerminalPane"; // Lazy imports const SessionTerminal = lazy(() => import("./SessionTerminal").then(m => ({ default: m.SessionTerminal }))); const SettingsPage = lazy(() => import("./SettingsPage").then(m => ({ default: m.SettingsPage }))); const MergeReviewPage = lazy(() => import("./MergeReviewPage").then(m => ({ default: m.MergeReviewPage }))); -const FileBrowser = lazy(() => import("./FileBrowser").then(m => ({ default: m.FileBrowser }))); import { PlanSection } from "../types/planning"; import { parseChangedFiles, @@ -62,6 +62,7 @@ import { Session, createSession, updateSessionAccess, + updateSessionName, getSessions, setSessionModel, gitGetChangedFiles, @@ -103,7 +104,6 @@ type ViewMode = | "workspace-edit" | "workspace-session" | "merge-review" - | "file-browser" | "settings"; type MergeConfirmPayload = { strategy: MergeStrategy; @@ -131,7 +131,6 @@ const WorkspaceListItem: React.FC<{ onPin: (workspaceId: number) => void; onUpdateBranch: (workspace: Workspace) => void; onMerge: (workspace: Workspace) => void; - onBrowseFiles: (workspace: Workspace) => void; onOpenSession: (workspace: Workspace) => void; updateBranchPending: boolean; mergePending: boolean; @@ -144,7 +143,6 @@ const WorkspaceListItem: React.FC<{ onPin, onUpdateBranch, onMerge, - onBrowseFiles, onOpenSession, updateBranchPending, mergePending, @@ -274,17 +272,6 @@ const WorkspaceListItem: React.FC<{ > Merge into {currentBranch || "main"} - - - - Browse files - - - - {mainBranchInfo?.upstream && (
{mainBranchInfo.behind > 0 && ( @@ -1920,7 +1897,7 @@ export const Dashboard: React.FC = () => { {workspaces.length === 0 ? (
- No workspaces yet + No workspaces yet. Create one with ⌘N
) : (
@@ -1939,10 +1916,6 @@ export const Dashboard: React.FC = () => { }} onUpdateBranch={(wt) => updateBranchMutation.mutate(wt)} onMerge={(wt) => openMergeDialogForWorkspace(wt)} - onBrowseFiles={(wt) => { - setFileBrowserWorkspace(wt); - setViewMode("file-browser"); - }} onOpenSession={(wt) => handleOpenSession(wt)} updateBranchPending={updateBranchMutation.isPending} mergePending={mergeMutation.isPending} diff --git a/src/components/FileBrowser.tsx b/src/components/FileBrowser.tsx index 7cd908e2..b79ebb3a 100644 --- a/src/components/FileBrowser.tsx +++ b/src/components/FileBrowser.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; import { Folder, FolderOpen, FileText, Loader2, AlertCircle, Plus } from "lucide-react"; -import { List, type ListImperativeAPI, type RowComponentProps } from "react-window"; +import { List } from "react-window"; import type { Workspace, DirectoryEntry } from "../lib/api"; import { listDirectory, readFile, gitGetChangedFiles, gitGetFileHunks } from "../lib/api"; import { cn } from "../lib/utils"; @@ -74,7 +74,6 @@ export const FileBrowser = memo(function FileBrowser({ workspace, repoPath, bran } | null>(null); const [hoveredLine, setHoveredLine] = useState(null); const { addToast } = useToast(); - const listRef = useRef(null); // Load root directory on mount useEffect(() => { @@ -529,13 +528,11 @@ export const FileBrowser = memo(function FileBrowser({ workspace, repoPath, bran
{ listRef.current = ref; }} + style={{ height: window.innerHeight, width: "100%" }} + className="px-4 pb-4" rowCount={lines.length} rowHeight={getItemHeight} - rowProps={{}} - className="px-4 pb-4" - style={{ height: "100%" }} - rowComponent={({ index, style }: RowComponentProps) => { + rowComponent={({ index, style }: { index: number; style: React.CSSProperties }) => { const lineNum = index + 1; const line = lines[index]; const gitStatus = fileHunks.get(lineNum); @@ -678,6 +675,7 @@ export const FileBrowser = memo(function FileBrowser({ workspace, repoPath, bran
); }} + rowProps={{}} />
@@ -691,63 +689,9 @@ export const FileBrowser = memo(function FileBrowser({ workspace, repoPath, bran const title = workspace ? getWorkspaceTitle(workspace) : "File Browser"; return ( -
- {/* Header */} -
-
-
-

{title}

- {workspace?.is_pinned && ( - - )} - -
-
- - {displayBranch} - {branchInfo && (branchInfo.ahead > 0 || branchInfo.behind > 0) && ( - - {branchInfo.ahead > 0 && {branchInfo.ahead}↑} - {branchInfo.behind > 0 && {branchInfo.behind}↓} - - )} -
- {status && totalChanges > 0 && ( -
- {status.modified > 0 && ( - - {status.modified} modified - - )} - {status.added > 0 && ( - - {status.added} added - - )} - {status.deleted > 0 && ( - - {status.deleted} deleted - - )} - {status.untracked > 0 && ( - - {status.untracked} untracked - - )} -
- )} - {workspace && ( -
-
Created {new Date(workspace.created_at).toLocaleString()}
-
- )} -
-
- - {/* Main Content */} -
+
{/* File Tree */} -
+
{isLoadingDir && rootEntries.length === 0 ? (
@@ -771,7 +715,6 @@ export const FileBrowser = memo(function FileBrowser({ workspace, repoPath, bran
{renderFileContent()}
-
); }); diff --git a/src/components/GitChangesSection.tsx b/src/components/GitChangesSection.tsx index 039c98c6..c6bf58c7 100644 --- a/src/components/GitChangesSection.tsx +++ b/src/components/GitChangesSection.tsx @@ -12,6 +12,7 @@ export interface GitChangesSectionProps { onToggleCollapse: () => void; fileActionTarget?: string | null; readOnly?: boolean; + activeFilePath?: string | null; selectedFiles?: Set; onFileSelect?: (path: string, event: React.MouseEvent) => void; onMoveToWorkspace?: () => void; @@ -30,6 +31,7 @@ export const GitChangesSection = memo(({ onToggleCollapse, fileActionTarget, readOnly = false, + activeFilePath, selectedFiles, onFileSelect, onMoveToWorkspace, @@ -141,6 +143,7 @@ export const GitChangesSection = memo(({ file={file} isStaged={isStaged} isSelected={!isStaged && selectedFiles?.has(file.path) || false} + isActive={activeFilePath === file.path} isBusy={fileActionTarget === file.path} readOnly={readOnly} onFileClick={onFileSelect} diff --git a/src/components/GitFileRow.tsx b/src/components/GitFileRow.tsx index 78f66533..f2cacaf7 100644 --- a/src/components/GitFileRow.tsx +++ b/src/components/GitFileRow.tsx @@ -7,6 +7,7 @@ export interface GitFileRowProps { file: ParsedFileChange; isStaged: boolean; isSelected?: boolean; + isActive?: boolean; isBusy?: boolean; readOnly?: boolean; onFileClick?: (path: string, event: React.MouseEvent) => void; @@ -18,6 +19,7 @@ export const GitFileRow = memo(({ file, isStaged, isSelected = false, + isActive = false, isBusy = false, readOnly = false, onFileClick, @@ -33,22 +35,25 @@ export const GitFileRow = memo(({ return (
{ + // Don't trigger if clicking on action buttons + if ((e.target as HTMLElement).closest('button[title="Stage file"], button[title="Unstage file"]')) { + return; + } + onFileClick?.(file.path, e); + }} + title={file.path} >
- +
{showActionButton && (
diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index 42548b43..b57ca938 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useCallback } from "react"; import { Sparkles } from "lucide-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -24,9 +24,6 @@ const AVAILABLE_MODELS = [ ]; export function ModelSelector({ currentModel, onModelChange, disabled }: ModelSelectorProps) { - const [isHovered, setIsHovered] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const getCurrentModelLabel = useCallback(() => { if (currentModel) { const model = AVAILABLE_MODELS.find(m => m.value === currentModel); @@ -36,12 +33,16 @@ export function ModelSelector({ currentModel, onModelChange, disabled }: ModelSe }, [currentModel]); const handleModelSelect = useCallback(async (modelValue: string) => { - setIsOpen(false); await onModelChange(modelValue); }, [onModelChange]); + const isSelected = (modelValue: string) => { + if (currentModel === null && modelValue === "default") return true; + return currentModel === modelValue; + }; + return ( - + @@ -49,28 +50,14 @@ export function ModelSelector({ currentModel, onModelChange, disabled }: ModelSe - Change Model + {getCurrentModelLabel()} @@ -78,7 +65,7 @@ export function ModelSelector({ currentModel, onModelChange, disabled }: ModelSe handleModelSelect(model.value)} - className={currentModel === model.value ? "bg-accent" : ""} + className={isSelected(model.value) ? "bg-primary/15 text-primary font-medium" : ""} > {model.label} diff --git a/src/components/PlanDisplay.tsx b/src/components/PlanDisplay.tsx index c4ba2690..70141fb9 100644 --- a/src/components/PlanDisplay.tsx +++ b/src/components/PlanDisplay.tsx @@ -213,10 +213,9 @@ export const PlanDisplay: React.FC = memo(({
-

No plans detected yet

+

No plans yet

- Start planning in the terminal. Plans will appear here automatically - when detected in the output. + Plans appear here when detected

diff --git a/src/components/PlanHistoryDialog.tsx b/src/components/PlanHistoryDialog.tsx index 9f705e3a..6cb8a6ef 100644 --- a/src/components/PlanHistoryDialog.tsx +++ b/src/components/PlanHistoryDialog.tsx @@ -98,7 +98,7 @@ export const PlanHistoryDialog: React.FC = ({ open, onOp Plan History {workspace ? `- ${workspace.branch_name}` : ""} - Review the plans executed for this workspace. Entries are ordered by execution time. + Past plans for this workspace diff --git a/src/components/RepositorySettingsContent.tsx b/src/components/RepositorySettingsContent.tsx index 42581429..93a4eef2 100644 --- a/src/components/RepositorySettingsContent.tsx +++ b/src/components/RepositorySettingsContent.tsx @@ -119,7 +119,7 @@ export const RepositorySettingsContent: React.FC className="mt-2" />

- Pattern for auto-generating branch names. Use {"{name}"} as placeholder for the sanitized intent/title. Example: "treq/{"{name}"}" → "treq/add-dark-mode" + treq/{"{name}"} → treq/add-dark-mode

@@ -153,8 +153,7 @@ export const RepositorySettingsContent: React.FC className="font-mono text-sm mt-2" />

- Glob patterns for .gitignored files/directories to copy to new workspaces (opt-in). - One pattern per line. Supports ** for recursive matching. Leave empty to copy nothing. + Patterns to copy (e.g., node_modules, .env*)

{availableFiles.length > 0 && (
diff --git a/src/components/ReviewSummaryPanel.tsx b/src/components/ReviewSummaryPanel.tsx index b9ec856f..f74f60c5 100644 --- a/src/components/ReviewSummaryPanel.tsx +++ b/src/components/ReviewSummaryPanel.tsx @@ -109,7 +109,7 @@ export const ReviewSummaryPanel: React.FC = ({

- Finish review by merging directly or sending the feedback to the execution terminal. + Merge or send feedback to terminal

- + {currentBranch || "main"} {repoPath && } -
+
{onCreateSession && ( @@ -480,7 +474,7 @@ export const SessionSidebar: React.FC = memo(({ - @@ -499,17 +493,6 @@ export const SessionSidebar: React.FC = memo(({ New Session )} - {onBrowseFiles && ( - { - event.preventDefault(); - onBrowseFiles(null); - }} - > - - Browse files - - )} { event.preventDefault(); @@ -549,7 +532,7 @@ export const SessionSidebar: React.FC = memo(({
{workspaces.map((workspace) => { const sessionsForWorkspace = sessionsByWorkspace.get(workspace.id) || []; - const isBrowsingThisWorkspace = browsingWorkspaceId === workspace.id; return (
@@ -583,16 +565,13 @@ export const SessionSidebar: React.FC = memo(({ )} onBrowseFiles?.(workspace)} > {getWorkspaceTitle(workspace.id)} -
+
{onCreateSession && ( @@ -612,7 +591,7 @@ export const SessionSidebar: React.FC = memo(({ - @@ -631,17 +610,6 @@ export const SessionSidebar: React.FC = memo(({ New Session )} - {onBrowseFiles && ( - { - event.preventDefault(); - onBrowseFiles(workspace); - }} - > - - Browse files - - )} { event.preventDefault(); diff --git a/src/components/SessionTerminal.tsx b/src/components/SessionTerminal.tsx index d23d3fa9..13a19cbb 100644 --- a/src/components/SessionTerminal.tsx +++ b/src/components/SessionTerminal.tsx @@ -1,12 +1,10 @@ import { - type KeyboardEvent as ReactKeyboardEvent, memo, useCallback, useEffect, useRef, useState, } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { Workspace, Session, @@ -16,10 +14,6 @@ import { LineDiffStats, savePlanToFile, savePlanToRepo, - loadPlanFromRepo, - ptyWrite, - ptyClose, - updateSessionName, gitPush, gitPushForce, gitMerge, @@ -29,21 +23,15 @@ import { gitGetLineDiffStats, preloadWorkspaceGitData, gitGetCurrentBranch, - getSessionModel, - setSessionModel, } from "../lib/api"; import { PlanSection } from "../types/planning"; -import { createDebouncedParser } from "../lib/planParser"; -import { - ConsolidatedTerminal, - type ConsolidatedTerminalHandle, -} from "./ConsolidatedTerminal"; import { StagingDiffViewer, type StagingDiffViewerHandle, } from "./StagingDiffViewer"; +import { FileBrowser } from "./FileBrowser"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; import { Button } from "./ui/button"; -import { Input } from "./ui/input"; import { useToast } from "./ui/toast"; import { DropdownMenu, @@ -62,37 +50,19 @@ import { import { PlanHistoryDialog } from "./PlanHistoryDialog"; import { Loader2, - RotateCw, - X, GitBranch, - Search, - ChevronDown, - ChevronUp, - Pencil, - Check, MoreVertical, GitMerge, Upload, AlertTriangle, - FileText, ArrowDownToLine, - PanelLeftOpen, } from "lucide-react"; import { PlanDisplayModal } from "./PlanDisplayModal"; -import { ModelSelector } from "./ModelSelector"; import { LineDiffStatsDisplay } from "./LineDiffStatsDisplay"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./ui/tooltip"; import { cn } from "../lib/utils"; import { useKeyboardShortcut } from "../hooks/useKeyboard"; import { getWorkspaceTitle as getWorkspaceTitleFromUtils } from "../lib/workspace-utils"; -type SessionPanel = "planning" | "execution" | null; - interface SessionTerminalProps { repositoryPath?: string; workspace?: Workspace; @@ -122,52 +92,38 @@ export const SessionTerminal = memo( session, sessionId, mainRepoBranch, - onClose, + onClose: _onClose, onExecutePlan, onExecutePlanInWorkspace, - initialPlanContent, - initialPlanTitle, - initialPrompt, + initialPlanContent: _initialPlanContent, + initialPlanTitle: _initialPlanTitle, + initialPrompt: _initialPrompt, initialPromptLabel, initialSelectedFile, isHidden = false, }) { const workingDirectory = workspace?.workspace_path || repositoryPath || ""; const effectiveRepoPath = workspace?.repo_path || repositoryPath || ""; + // Use a stable session ID - only generate UUID once if sessionId is null + const stableSessionIdRef = useRef(null); + if (stableSessionIdRef.current === null) { + stableSessionIdRef.current = sessionId + ? `session-${sessionId}` + : `session-${crypto.randomUUID()}`; + } const ptySessionId = sessionId ? `session-${sessionId}` - : `session-${crypto.randomUUID()}`; - const queryClient = useQueryClient(); + : stableSessionIdRef.current; const { addToast } = useToast(); const [planSections, setPlanSections] = useState([]); - const [terminalOutput, setTerminalOutput] = useState(""); - const [activePanel, setActivePanel] = useState(null); const [historyDialogOpen, setHistoryDialogOpen] = useState(false); const [planModalOpen, setPlanModalOpen] = useState(false); - const [isResetting, setIsResetting] = useState(false); - const debouncedParserRef = useRef(createDebouncedParser(1000)); const [refreshSignal, setRefreshSignal] = useState(0); - const [lastPromptLabel, setLastPromptLabel] = useState(null); - const [terminalInstanceKey, setTerminalInstanceKey] = useState(0); + const [lastPromptLabel] = useState(null); - const queuedMessagesRef = useRef([]); - const consolidatedTerminalRef = useRef( - null - ); const stagingDiffViewerRef = useRef(null); - const searchInputRef = useRef(null); - const sessionNameInputRef = useRef(null); - const [searchVisible, setSearchVisible] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [terminalMinimized, setTerminalMinimized] = useState(false); - const [isEditingSessionName, setIsEditingSessionName] = useState(false); - const [editedSessionName, setEditedSessionName] = useState(""); - const [sessionDisplayName, setSessionDisplayName] = useState( - session?.name ?? null - ); - const [isRenamingSession, setIsRenamingSession] = useState(false); const [actionPending, setActionPending] = useState< "push" | "merge" | "forcePush" | null >(null); @@ -183,30 +139,10 @@ export const SessionTerminal = memo( const [maintreeDivergence, setMaintreeDivergence] = useState(null); const [lineStats, setLineStats] = useState(null); - const [showSwitchOverlay, setShowSwitchOverlay] = useState(false); - const prevIsHiddenRef = useRef(isHidden); - const [sessionModel, setSessionModelState] = useState(null); - const [isChangingModel, setIsChangingModel] = useState(false); - const [pendingModelReset, setPendingModelReset] = useState(false); - const [isModelLoaded, setIsModelLoaded] = useState(false); - const [isDragging, setIsDragging] = useState(false); - - const hasImplementationPlan = planSections.some( - (section) => section.type === "implementation_plan" - ); + const [activeTab, setActiveTab] = useState("changes"); useEffect(() => { - setTerminalOutput(""); setPlanSections([]); - setActivePanel(null); - queuedMessagesRef.current = []; - setSearchVisible(false); - setSearchQuery(""); - consolidatedTerminalRef.current?.clearSearch(); - }, [ptySessionId, terminalInstanceKey]); - - useEffect(() => { - setTerminalInstanceKey(0); }, [ptySessionId]); useEffect(() => { @@ -225,245 +161,6 @@ export const SessionTerminal = memo( }); }, [workingDirectory, isHidden]); - useEffect(() => { - if (isEditingSessionName) { - return; - } - setSessionDisplayName(session?.name ?? null); - }, [session?.id, session?.name, isEditingSessionName]); - - useEffect(() => { - if (isEditingSessionName) { - requestAnimationFrame(() => { - sessionNameInputRef.current?.focus(); - sessionNameInputRef.current?.select(); - }); - } - }, [isEditingSessionName]); - - const detectPanelFromOutput = useCallback((output: string) => { - if (!output) { - return null; - } - const lines = output.split(/\r?\n/); - const tail = lines.slice(-5).join("\n").toLowerCase(); - if (tail.includes("plan mode on")) { - return "planning" as const; - } - if (tail.includes("edits on")) { - return "execution" as const; - } - return null; - }, []); - - useEffect(() => { - setActivePanel(detectPanelFromOutput("")); - }, [detectPanelFromOutput]); - - // Reset right panel when session becomes hidden to unmount PlanDisplay/StagingDiffViewer - useEffect(() => { - if (isHidden) { - setActivePanel(null); - } - }, [isHidden]); - - // Show loading overlay when switching from hidden to visible (canvas redraw) - useEffect(() => { - const wasHidden = prevIsHiddenRef.current; - prevIsHiddenRef.current = isHidden; - - // Transition from hidden to visible - show overlay for 300ms - if (wasHidden && !isHidden) { - setShowSwitchOverlay(true); - const timer = setTimeout(() => { - setShowSwitchOverlay(false); - }, 300); - return () => clearTimeout(timer); - } - }, [isHidden]); - - const openSearchPanel = useCallback(() => { - setSearchVisible(true); - requestAnimationFrame(() => { - searchInputRef.current?.focus(); - searchInputRef.current?.select(); - }); - }, []); - - const closeSearchPanel = useCallback(() => { - setSearchVisible(false); - setSearchQuery(""); - consolidatedTerminalRef.current?.clearSearch(); - consolidatedTerminalRef.current?.focus(); - }, []); - - const runSearch = useCallback( - (direction: "next" | "previous") => { - const term = searchQuery.trim(); - if (!term) { - return; - } - if (direction === "next") { - consolidatedTerminalRef.current?.findNext(term); - } else { - consolidatedTerminalRef.current?.findPrevious(term); - } - }, - [searchQuery] - ); - - const handleReviewSubmitted = useCallback(() => { - if (terminalMinimized) { - setTerminalMinimized(false); - // Focus terminal after maximizing - requestAnimationFrame(() => { - consolidatedTerminalRef.current?.focus(); - }); - } - }, [terminalMinimized]); - - useEffect(() => { - if (!searchVisible) { - return; - } - const term = searchQuery.trim(); - if (!term) { - consolidatedTerminalRef.current?.clearSearch(); - return; - } - consolidatedTerminalRef.current?.findNext(term); - }, [searchVisible, searchQuery]); - - const handleSearchKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - runSearch(event.shiftKey ? "previous" : "next"); - } else if (event.key === "Escape") { - event.preventDefault(); - closeSearchPanel(); - } - }, - [runSearch, closeSearchPanel] - ); - - const handleStartEditingSessionName = useCallback(() => { - if (!session) { - return; - } - setEditedSessionName(sessionDisplayName ?? session.name ?? ""); - setIsEditingSessionName(true); - }, [session, sessionDisplayName]); - - const handleCancelSessionRename = useCallback(() => { - setIsEditingSessionName(false); - setEditedSessionName(""); - }, []); - - const handleSaveSessionName = useCallback(async () => { - if (!session) { - return; - } - const trimmed = editedSessionName.trim(); - if (!trimmed) { - addToast({ - title: "Name required", - description: "Enter a session name before saving.", - type: "error", - }); - return; - } - if (trimmed === (sessionDisplayName ?? session.name)) { - handleCancelSessionRename(); - return; - } - - try { - setIsRenamingSession(true); - const repoPath = workspace?.repo_path || repositoryPath || ""; - await updateSessionName(repoPath, session.id, trimmed); - setSessionDisplayName(trimmed); - setIsEditingSessionName(false); - setEditedSessionName(""); - await queryClient.invalidateQueries({ queryKey: ["sessions"] }); - } catch (error) { - addToast({ - title: "Rename failed", - description: error instanceof Error ? error.message : String(error), - type: "error", - }); - } finally { - setIsRenamingSession(false); - } - }, [ - session, - editedSessionName, - sessionDisplayName, - addToast, - handleCancelSessionRename, - queryClient, - ]); - - const handleSessionNameKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - handleSaveSessionName(); - } else if (event.key === "Escape") { - event.preventDefault(); - handleCancelSessionRename(); - } - }, - [handleSaveSessionName, handleCancelSessionRename] - ); - - useEffect(() => { - const isWithinTerminal = (element: HTMLElement | null): boolean => { - if (!element) return false; - return element.closest(".xterm, [data-terminal]") !== null; - }; - - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const activeElement = document.activeElement as HTMLElement | null; - - // Don't intercept events when terminal is focused - if (isWithinTerminal(target) || isWithinTerminal(activeElement)) { - return; - } - - const isModifierPressed = event.metaKey || event.ctrlKey; - if (isModifierPressed && event.key.toLowerCase() === "f") { - const isTextInput = - target?.tagName === "INPUT" || - target?.tagName === "TEXTAREA" || - target?.getAttribute("contenteditable") === "true"; - if (!isTextInput) { - event.preventDefault(); - openSearchPanel(); - } - return; - } - if (event.key === "Escape" && searchVisible) { - event.stopPropagation(); - closeSearchPanel(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [openSearchPanel, closeSearchPanel, searchVisible]); - - // Cmd+E: Edit session name - useKeyboardShortcut( - "e", - true, - () => { - handleStartEditingSessionName(); - }, - [session, sessionDisplayName] - ); - // Cmd+/: Focus commit message useKeyboardShortcut( "/", @@ -474,16 +171,6 @@ export const SessionTerminal = memo( [] ); - // Cmd+J: Toggle terminal minimize - useKeyboardShortcut( - "j", - true, - () => { - setTerminalMinimized((prev) => !prev); - }, - [] - ); - const handlePlanEdit = useCallback( async (planId: string, newContent: string) => { if (!effectiveRepoPath) return; @@ -574,141 +261,6 @@ export const SessionTerminal = memo( [workspace, effectiveRepoPath, onExecutePlanInWorkspace, addToast] ); - useEffect(() => { - if (!terminalOutput) { - setPlanSections([]); - return; - } - - debouncedParserRef.current(terminalOutput, async (sections) => { - if (!effectiveRepoPath) { - setPlanSections(sections); - return; - } - - const sectionsWithEdits = await Promise.all( - sections.map(async (section) => { - try { - const savedPlan = await loadPlanFromRepo( - effectiveRepoPath, - section.id, - ptySessionId - ); - if (savedPlan) { - const hasExplicitEdit = - savedPlan.editedAt && - new Date(savedPlan.editedAt) > section.timestamp; - if (hasExplicitEdit) { - return { - ...section, - editedContent: savedPlan.content, - isEdited: true, - editedAt: new Date(savedPlan.editedAt), - }; - } - } - } catch (error) { - console.error(`Failed to load plan ${section.id}:`, error); - } - return section; - }) - ); - setPlanSections(sectionsWithEdits); - }); - }, [terminalOutput, effectiveRepoPath, ptySessionId]); - - const handleReset = useCallback(async () => { - setIsResetting(true); - try { - // Close the existing PTY session to force a fresh Claude instance - await ptyClose(ptySessionId).catch(console.error); - - setPlanSections([]); - setTerminalOutput(""); - setActivePanel(null); - queuedMessagesRef.current = []; - - // Increment instance key to remount ConsolidatedTerminal with a fresh PTY - setTerminalInstanceKey((prev) => prev + 1); - - addToast({ - title: "Terminal Reset", - description: "Starting new Claude session", - type: "info", - }); - } catch (error) { - addToast({ - title: "Reset Failed", - description: error instanceof Error ? error.message : String(error), - type: "error", - }); - } finally { - setIsResetting(false); - } - }, [effectiveRepoPath, ptySessionId, addToast]); - - const handleModelChange = useCallback( - async (newModel: string) => { - if (!sessionId || !effectiveRepoPath) return; - - setIsChangingModel(true); - try { - // Convert "default" selection to null (no explicit model) - const modelToSave = newModel === "default" ? null : newModel; - - // Save the new model to the database - await setSessionModel(effectiveRepoPath, sessionId, modelToSave); - - // Update the state - setSessionModelState(modelToSave); - - // Set flag to trigger reset after state updates - setPendingModelReset(true); - - // Success toast will be shown by the reset effect - } catch (error) { - addToast({ - title: "Failed to change model", - description: error instanceof Error ? error.message : String(error), - type: "error", - }); - setIsChangingModel(false); - } - }, - [sessionId, effectiveRepoPath, addToast] - ); - - // Reset terminal when model changes (after state has updated) - useEffect(() => { - if (!pendingModelReset) return; - - const performReset = async () => { - await handleReset(); - - addToast({ - title: "Model Changed", - description: `Switched to ${sessionModel || "default"}`, - type: "success", - }); - - setPendingModelReset(false); - setIsChangingModel(false); - }; - - performReset(); - }, [pendingModelReset, handleReset, sessionModel, addToast]); - - const handleSessionError = useCallback( - (message: string) => { - addToast({ - title: "PTY Error", - description: message, - type: "error", - }); - }, - [addToast] - ); - const handleStagedFilesChange = useCallback((_files: string[]) => { // No-op: staged files tracking not currently used }, []); @@ -943,116 +495,39 @@ export const SessionTerminal = memo( } }, [workingDirectory, addToast, triggerSidebarRefresh]); - const buildPlanPrompt = useCallback(() => { - if (!initialPlanContent) return null; - const title = initialPlanTitle?.trim() || "Implementation Plan"; - return `Please implement the following plan:\n\n# ${title}\n\n${initialPlanContent}\n`; - }, [initialPlanContent, initialPlanTitle]); - - const flushQueuedMessages = useCallback(() => { - if (!queuedMessagesRef.current.length) { - return; - } - const next = queuedMessagesRef.current.shift(); - if (!next) { - return; - } - - ptyWrite(ptySessionId, next) - .then(() => { - if (queuedMessagesRef.current.length > 0) { - setTimeout(() => { - flushQueuedMessages(); - }, 400); - } else { - // All messages sent - focus terminal - consolidatedTerminalRef.current?.focus(); - } - }) - .catch((error) => { - console.error("Failed to send automated prompt:", error); - addToast({ - title: "Prompt Error", - description: - "Unable to deliver the automated instructions to Claude.", - type: "error", - }); - }); - }, [ptySessionId, addToast]); - - const prevInitialPromptRef = useRef(undefined); - - // Load session model on mount - useEffect(() => { - const loadSessionModel = async () => { - if (!sessionId || !effectiveRepoPath) { - setIsModelLoaded(true); - return; - } - - const model = await getSessionModel(effectiveRepoPath, sessionId); - setSessionModelState(model); - setIsModelLoaded(true); - }; - - loadSessionModel(); - }, [sessionId, effectiveRepoPath]); - - useEffect(() => { - const queue: string[] = []; - const planPrompt = buildPlanPrompt(); - if (planPrompt) { - queue.push(`${planPrompt}\n`); - } - if (initialPrompt && initialPrompt.trim()) { - queue.push(`${initialPrompt}\n`); - setLastPromptLabel(initialPromptLabel || null); - } else { - setLastPromptLabel(null); - } - queuedMessagesRef.current = queue; - - // If initialPrompt changed while session is already ready, flush immediately - const promptChanged = prevInitialPromptRef.current !== initialPrompt; - prevInitialPromptRef.current = initialPrompt; - - if (promptChanged && initialPrompt && initialPrompt.trim()) { - setTimeout(() => { - flushQueuedMessages(); - }, 500); - } - }, [ - ptySessionId, - buildPlanPrompt, - initialPrompt, - initialPromptLabel, - flushQueuedMessages, - ]); - - useEffect(() => { - if (!ptySessionId) { - return; - } - - const timeout = setTimeout(() => { - flushQueuedMessages(); - }, 1500); - - return () => clearTimeout(timeout); - }, [ptySessionId, flushQueuedMessages]); - const executionPanel = workingDirectory ? (
- +
+ + + Changes + Files + + +
+
+ {activeTab === "changes" ? ( + + ) : ( + + )} +
) : (
@@ -1060,181 +535,23 @@ export const SessionTerminal = memo(
); - const handleDragOver = useCallback((event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = "copy"; - }, []); - - const handleDragEnter = useCallback((event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - // Only hide if leaving the terminal container itself - if (event.currentTarget === event.target) { - setIsDragging(false); - } - }, []); - - const handleDrop = useCallback( - async (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - setIsDragging(false); - - try { - const { files, items } = event.dataTransfer; - - // Check if there are files dropped - if (files.length > 0) { - const file = files[0]; - - // Try to get the file path (works in Tauri/Electron environments) - // @ts-ignore - path property is not standard but available in Tauri - const filePath = file.path; - - if (filePath) { - // Insert file path into terminal - const escapedPath = filePath.includes(" ") - ? `"${filePath}"` - : filePath; - await ptyWrite(ptySessionId, escapedPath); - consolidatedTerminalRef.current?.focus(); - return; - } - } - - // Check if there's an image in the clipboard - if (items.length > 0) { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - // Check if it's an image - if (item.type.startsWith("image/")) { - // Trigger paste operation in the terminal - // Read the clipboard and write it to the terminal - try { - const text = await navigator.clipboard.readText(); - if (text) { - await ptyWrite(ptySessionId, text); - consolidatedTerminalRef.current?.focus(); - return; - } - } catch (clipboardError) { - console.warn("Clipboard read failed:", clipboardError); - } - - // If clipboard text read fails, try to paste the image data - const blob = await new Promise((resolve) => { - item.getAsString(() => resolve(null)); - const file = item.getAsFile(); - resolve(file); - }); - - if (blob) { - addToast({ - title: "Image dropped", - description: - "Image files cannot be pasted directly. Please save and drag the file instead.", - type: "info", - }); - } - return; - } - } - } - - addToast({ - title: "Drop not supported", - description: - "Please drop a file or copy an image to clipboard first.", - type: "info", - }); - } catch (error) { - addToast({ - title: "Drop failed", - description: error instanceof Error ? error.message : String(error), - type: "error", - }); - } - }, - [ptySessionId, addToast] - ); - const getWorkspaceTitle = (): string => { if (!workspace) return "Main"; return getWorkspaceTitleFromUtils(workspace); }; const sessionTitle = - sessionDisplayName && sessionDisplayName.trim().length > 0 - ? sessionDisplayName.trim() - : session?.name && session.name.trim().length > 0 + session?.name && session.name.trim().length > 0 ? session.name.trim() : "Session Terminal"; return ( -
-
+
+
{/* Row 1: Session name */}
-
- {isEditingSessionName ? ( -
- - setEditedSessionName(event.target.value) - } - onKeyDown={handleSessionNameKeyDown} - className="h-8 w-44 text-sm" - placeholder="Session name" - disabled={isRenamingSession} - /> - - -
- ) : ( -
-

{sessionTitle}

- {session && ( - - )} -
- )} +
+

{sessionTitle}

@@ -1297,49 +614,6 @@ export const SessionTerminal = memo( {/* Row 2: Workspace info */}
- {!terminalMinimized && ( - - - - - - Minimize terminal (⌘+J) - - - )} - {terminalMinimized && ( - - - - - - Maximize terminal (⌘+J) - - - - // - )} {workspace && ( {getWorkspaceTitle()} @@ -1420,194 +694,8 @@ export const SessionTerminal = memo(
-
-
- {/* Drag overlay */} - {isDragging && ( -
-
-

- Drop file here -

-
-
- )} - - {/* Floating refresh and search buttons */} - {!searchVisible && ( -
- {sessionId && ( - - )} - - - - - - Reset - - - - - - - - Search (⌘+F) - - - {activePanel === "planning" && ( - - - - - - - {hasImplementationPlan - ? "View Plan" - : "No plan detected"} - - - - )} -
- )} - - {/* Compact search overlay */} - {searchVisible && ( -
- setSearchQuery(event.target.value)} - placeholder="Find" - onKeyDown={handleSearchKeyDown} - className="h-6 w-48 text-sm !outline-none !ring-0" - /> - - - - - - Previous (Shift+Enter) - - - - - - - - Next (Enter) - - - - - - - - Close (Esc) - - -
- )} - - {!terminalMinimized && isModelLoaded && ( - - )} - - {!terminalMinimized && !isModelLoaded && ( -
- Loading session configuration... -
- )} -
- -
- -
+
+
{executionPanel}
diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index de376a64..b63bef78 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -335,7 +335,7 @@ export const SettingsPage: React.FC = ({
) : (
- Please configure a repository path in the Application tab first. + Set repository in Application tab
)} diff --git a/src/components/StagingDiffViewer.tsx b/src/components/StagingDiffViewer.tsx index 0accb5fd..3dffa748 100644 --- a/src/components/StagingDiffViewer.tsx +++ b/src/components/StagingDiffViewer.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, Fragment, forw import { type KeyboardEvent as ReactKeyboardEvent } from "react"; import { openPath } from "@tauri-apps/plugin-opener"; import { v4 as uuidv4 } from "uuid"; -import { List, type ListImperativeAPI, type RowComponentProps } from "react-window"; +import { List } from "react-window"; import { gitGetChangedFiles, gitGetFileHunks, @@ -45,6 +45,7 @@ import { DropdownMenuSeparator, } from "./ui/dropdown-menu"; import { + AlertTriangle, FileText, Loader2, Minus, @@ -76,17 +77,6 @@ import { GitChangesSection } from "./GitChangesSection"; import { MoveToWorkspaceDialog } from "./MoveToWorkspaceDialog"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "./ui/tooltip"; -// Helper to compute hash of hunk content using native Web Crypto API -const computeHunkHash = async (hunks: any[]): Promise => { - const content = hunks.map(h => h.lines.join('\n')).join('\n'); - const encoder = new TextEncoder(); - const data = encoder.encode(content); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; -}; - interface StagingDiffViewerProps { workspacePath: string; readOnly?: boolean; @@ -251,15 +241,6 @@ const computeHunksHash = (hunks: GitDiffHunk[]): string => { return hash.toString(16); }; -// Virtualization height constants -const FILE_HEADER_HEIGHT = 48; -const HUNK_HEADER_HEIGHT = 28; -const LINE_HEIGHT = 24; -const LOADING_HEIGHT = 60; -const COMMENT_INPUT_HEIGHT = 140; -const COMMENT_DISPLAY_HEIGHT = 120; -const ROW_PADDING_BOTTOM = 24; // pb-6 class = 1.5rem = 24px - // Isolated comment input component to prevent parent re-renders during typing interface CommentInputProps { onSubmit: (text: string) => void; @@ -267,7 +248,6 @@ interface CommentInputProps { filePath?: string; startLine?: number; endLine?: number; - lineContents?: string[]; } const CommentInput: React.FC = memo(({ @@ -276,7 +256,6 @@ const CommentInput: React.FC = memo(({ filePath, startLine, endLine, - lineContents, }) => { const [text, setText] = useState(""); @@ -308,24 +287,17 @@ const CommentInput: React.FC = memo(({ : null; return ( -
- {(lineLabel || lineContents) && ( +
+ {filePath && lineLabel && (
- {filePath && lineLabel && ( - {filePath}:{lineLabel} - )} - {lineContents && lineContents.length > 0 && ( -
-
{lineContents.join('\n')}
-
- )} + {filePath}:{lineLabel}
)}