diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index a07b6aa1..1605d62f 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -149,6 +149,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::workspace_summary::WorkspaceSummaryRequest::decl(), server::routes::task_attempts::workspace_summary::WorkspaceSummary::decl(), server::routes::task_attempts::workspace_summary::WorkspaceSummaryResponse::decl(), + server::routes::task_attempts::workspace_summary::DiffStats::decl(), services::services::filesystem::DirectoryEntry::decl(), services::services::filesystem::DirectoryListResponse::decl(), services::services::file_search::SearchMode::decl(), diff --git a/crates/server/src/routes/task_attempts/workspace_summary.rs b/crates/server/src/routes/task_attempts/workspace_summary.rs index 18e1e87d..43e5661f 100644 --- a/crates/server/src/routes/task_attempts/workspace_summary.rs +++ b/crates/server/src/routes/task_attempts/workspace_summary.rs @@ -56,11 +56,11 @@ pub struct WorkspaceSummaryResponse { pub summaries: Vec, } -#[derive(Debug, Clone, Default)] -struct DiffStats { - files_changed: usize, - lines_added: usize, - lines_removed: usize, +#[derive(Debug, Clone, Default, Serialize, TS)] +pub struct DiffStats { + pub files_changed: usize, + pub lines_added: usize, + pub lines_removed: usize, } /// Fetch summary information for workspaces filtered by archived status. diff --git a/frontend/src/components/ui-new/NewDisplayConversationEntry.tsx b/frontend/src/components/ui-new/NewDisplayConversationEntry.tsx index d2445caf..2af4044c 100644 --- a/frontend/src/components/ui-new/NewDisplayConversationEntry.tsx +++ b/frontend/src/components/ui-new/NewDisplayConversationEntry.tsx @@ -17,8 +17,8 @@ import { } from '@/stores/useUiPreferencesStore'; import DisplayConversationEntry from '@/components/NormalizedConversation/DisplayConversationEntry'; import { useMessageEditContext } from '@/contexts/MessageEditContext'; -import { useFileNavigation } from '@/contexts/FileNavigationContext'; -import { useLogNavigation } from '@/contexts/LogNavigationContext'; +import { useChangesView } from '@/contexts/ChangesViewContext'; +import { useLogsPanel } from '@/contexts/LogsPanelContext'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { cn } from '@/lib/utils'; import { @@ -354,7 +354,7 @@ function FileEditEntry({ expansionKey as PersistKey, pendingApproval ); - const { viewFileInChanges, diffPaths } = useFileNavigation(); + const { viewFileInChanges, diffPaths } = useChangesView(); // Calculate diff stats for edit changes const { additions, deletions } = useMemo(() => { @@ -561,7 +561,7 @@ function ToolSummaryEntry({ `tool:${expansionKey}`, false ); - const { viewToolContentInPanel } = useLogNavigation(); + const { viewToolContentInPanel } = useLogsPanel(); const textRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); diff --git a/frontend/src/components/ui-new/actions/index.ts b/frontend/src/components/ui-new/actions/index.ts index 1dfb8acd..1749d17f 100644 --- a/frontend/src/components/ui-new/actions/index.ts +++ b/frontend/src/components/ui-new/actions/index.ts @@ -38,8 +38,11 @@ import { QuestionIcon, } from '@phosphor-icons/react'; import { useDiffViewStore } from '@/stores/useDiffViewStore'; -import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; -import { useLayoutStore } from '@/stores/useLayoutStore'; +import { + useUiPreferencesStore, + RIGHT_MAIN_PANEL_MODES, +} from '@/stores/useUiPreferencesStore'; + import { attemptsApi, tasksApi, repoApi } from '@/lib/api'; import { attemptKeys } from '@/hooks/useAttempt'; import { taskKeys } from '@/hooks/useTask'; @@ -95,12 +98,12 @@ export interface ActionExecutorContext { // Context for evaluating action visibility and state conditions export interface ActionVisibilityContext { // Layout state - isChangesMode: boolean; - isLogsMode: boolean; - isPreviewMode: boolean; - isSidebarVisible: boolean; - isMainPanelVisible: boolean; - isGitPanelVisible: boolean; + rightMainPanelMode: + | (typeof RIGHT_MAIN_PANEL_MODES)[keyof typeof RIGHT_MAIN_PANEL_MODES] + | null; + isLeftSidebarVisible: boolean; + isLeftMainPanelVisible: boolean; + isRightSidebarVisible: boolean; isCreateMode: boolean; // Workspace state @@ -415,7 +418,8 @@ export const Actions = { : 'Switch to Inline View', icon: ColumnsIcon, requiresTarget: false, - isVisible: (ctx) => ctx.isChangesMode, + isVisible: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES, isActive: (ctx) => ctx.diffViewMode === 'split', getIcon: (ctx) => (ctx.diffViewMode === 'split' ? ColumnsIcon : RowsIcon), getTooltip: (ctx) => @@ -433,7 +437,8 @@ export const Actions = { : 'Ignore Whitespace Changes', icon: EyeSlashIcon, requiresTarget: false, - isVisible: (ctx) => ctx.isChangesMode, + isVisible: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES, execute: () => { const store = useDiffViewStore.getState(); store.setIgnoreWhitespace(!store.ignoreWhitespace); @@ -448,7 +453,8 @@ export const Actions = { : 'Enable Line Wrapping', icon: TextAlignLeftIcon, requiresTarget: false, - isVisible: (ctx) => ctx.isChangesMode, + isVisible: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES, execute: () => { const store = useDiffViewStore.getState(); store.setWrapText(!store.wrapText); @@ -456,94 +462,106 @@ export const Actions = { }, // === Layout Panel Actions === - ToggleSidebar: { - id: 'toggle-sidebar', + ToggleLeftSidebar: { + id: 'toggle-left-sidebar', label: () => - useLayoutStore.getState().isSidebarVisible - ? 'Hide Sidebar' - : 'Show Sidebar', + useUiPreferencesStore.getState().isLeftSidebarVisible + ? 'Hide Left Sidebar' + : 'Show Left Sidebar', icon: SidebarSimpleIcon, requiresTarget: false, - isActive: (ctx) => ctx.isSidebarVisible, + isActive: (ctx) => ctx.isLeftSidebarVisible, execute: () => { - useLayoutStore.getState().toggleSidebar(); + useUiPreferencesStore.getState().toggleLeftSidebar(); }, }, - ToggleMainPanel: { - id: 'toggle-main-panel', + ToggleLeftMainPanel: { + id: 'toggle-left-main-panel', label: () => - useLayoutStore.getState().isMainPanelVisible + useUiPreferencesStore.getState().isLeftMainPanelVisible ? 'Hide Chat Panel' : 'Show Chat Panel', icon: ChatsTeardropIcon, requiresTarget: false, - isActive: (ctx) => ctx.isMainPanelVisible, - isEnabled: (ctx) => !(ctx.isMainPanelVisible && !ctx.isChangesMode), + isActive: (ctx) => ctx.isLeftMainPanelVisible, + isEnabled: (ctx) => + !(ctx.isLeftMainPanelVisible && ctx.rightMainPanelMode === null), execute: () => { - useLayoutStore.getState().toggleMainPanel(); + useUiPreferencesStore.getState().toggleLeftMainPanel(); }, }, - ToggleGitPanel: { - id: 'toggle-git-panel', + ToggleRightSidebar: { + id: 'toggle-right-sidebar', label: () => - useLayoutStore.getState().isGitPanelVisible - ? 'Hide Git Panel' - : 'Show Git Panel', + useUiPreferencesStore.getState().isRightSidebarVisible + ? 'Hide Right Sidebar' + : 'Show Right Sidebar', icon: RightSidebarIcon, requiresTarget: false, - isActive: (ctx) => ctx.isGitPanelVisible, + isActive: (ctx) => ctx.isRightSidebarVisible, execute: () => { - useLayoutStore.getState().toggleGitPanel(); + useUiPreferencesStore.getState().toggleRightSidebar(); }, }, ToggleChangesMode: { id: 'toggle-changes-mode', label: () => - useLayoutStore.getState().isChangesMode + useUiPreferencesStore.getState().rightMainPanelMode === + RIGHT_MAIN_PANEL_MODES.CHANGES ? 'Hide Changes Panel' : 'Show Changes Panel', icon: GitDiffIcon, requiresTarget: false, isVisible: (ctx) => !ctx.isCreateMode, - isActive: (ctx) => ctx.isChangesMode, + isActive: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES, isEnabled: (ctx) => !ctx.isCreateMode, execute: () => { - useLayoutStore.getState().toggleChangesMode(); + useUiPreferencesStore + .getState() + .toggleRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.CHANGES); }, }, ToggleLogsMode: { id: 'toggle-logs-mode', label: () => - useLayoutStore.getState().isLogsMode + useUiPreferencesStore.getState().rightMainPanelMode === + RIGHT_MAIN_PANEL_MODES.LOGS ? 'Hide Logs Panel' : 'Show Logs Panel', icon: TerminalIcon, requiresTarget: false, isVisible: (ctx) => !ctx.isCreateMode, - isActive: (ctx) => ctx.isLogsMode, + isActive: (ctx) => ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS, isEnabled: (ctx) => !ctx.isCreateMode, execute: () => { - useLayoutStore.getState().toggleLogsMode(); + useUiPreferencesStore + .getState() + .toggleRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS); }, }, TogglePreviewMode: { id: 'toggle-preview-mode', label: () => - useLayoutStore.getState().isPreviewMode + useUiPreferencesStore.getState().rightMainPanelMode === + RIGHT_MAIN_PANEL_MODES.PREVIEW ? 'Hide Preview Panel' : 'Show Preview Panel', icon: DesktopIcon, requiresTarget: false, isVisible: (ctx) => !ctx.isCreateMode, - isActive: (ctx) => ctx.isPreviewMode, + isActive: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW, isEnabled: (ctx) => !ctx.isCreateMode, execute: () => { - useLayoutStore.getState().togglePreviewMode(); + useUiPreferencesStore + .getState() + .toggleRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.PREVIEW); }, }, @@ -592,7 +610,8 @@ export const Actions = { }, icon: CaretDoubleUpIcon, requiresTarget: false, - isVisible: (ctx) => ctx.isChangesMode, + isVisible: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES, getIcon: (ctx) => ctx.isAllDiffsExpanded ? CaretDoubleUpIcon : CaretDoubleDownIcon, getTooltip: (ctx) => @@ -686,7 +705,9 @@ export const Actions = { } else { ctx.startDevServer(); // Auto-open preview mode when starting dev server - useLayoutStore.getState().setPreviewMode(true); + useUiPreferencesStore + .getState() + .setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.PREVIEW); } }, }, @@ -949,12 +970,12 @@ export const NavbarActionGroups = { Actions.ToggleDiffViewMode, Actions.ToggleAllDiffs, NavbarDivider, - Actions.ToggleSidebar, - Actions.ToggleMainPanel, + Actions.ToggleLeftSidebar, + Actions.ToggleLeftMainPanel, Actions.ToggleChangesMode, Actions.ToggleLogsMode, Actions.TogglePreviewMode, - Actions.ToggleGitPanel, + Actions.ToggleRightSidebar, NavbarDivider, Actions.OpenCommandBar, Actions.Feedback, diff --git a/frontend/src/components/ui-new/actions/pages.ts b/frontend/src/components/ui-new/actions/pages.ts index f89f8bf0..711a72f8 100644 --- a/frontend/src/components/ui-new/actions/pages.ts +++ b/frontend/src/components/ui-new/actions/pages.ts @@ -1,6 +1,7 @@ import type { Icon } from '@phosphor-icons/react'; import { type ActionDefinition, type ActionVisibilityContext } from './index'; import { Actions } from './index'; +import { RIGHT_MAIN_PANEL_MODES } from '@/stores/useUiPreferencesStore'; // Define page IDs first to avoid circular reference export type PageId = @@ -130,7 +131,8 @@ export const Pages: Record = { id: 'diff-options', title: 'Diff Options', parent: 'root', - isVisible: (ctx) => ctx.isChangesMode, + isVisible: (ctx) => + ctx.rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES, items: [ { type: 'group', @@ -155,9 +157,9 @@ export const Pages: Record = { type: 'group', label: 'Panels', items: [ - { type: 'action', action: Actions.ToggleSidebar }, - { type: 'action', action: Actions.ToggleMainPanel }, - { type: 'action', action: Actions.ToggleGitPanel }, + { type: 'action', action: Actions.ToggleLeftSidebar }, + { type: 'action', action: Actions.ToggleLeftMainPanel }, + { type: 'action', action: Actions.ToggleRightSidebar }, { type: 'action', action: Actions.ToggleChangesMode }, { type: 'action', action: Actions.ToggleLogsMode }, { type: 'action', action: Actions.TogglePreviewMode }, diff --git a/frontend/src/components/ui-new/actions/useActionVisibility.ts b/frontend/src/components/ui-new/actions/useActionVisibility.ts index a8e8e03f..63935b49 100644 --- a/frontend/src/components/ui-new/actions/useActionVisibility.ts +++ b/frontend/src/components/ui-new/actions/useActionVisibility.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; -import { useLayoutStore } from '@/stores/useLayoutStore'; -import { useDiffViewStore, useDiffViewMode } from '@/stores/useDiffViewStore'; import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; +import { useDiffViewStore, useDiffViewMode } from '@/stores/useDiffViewStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { useUserSystem } from '@/components/ConfigProvider'; import { useDevServer } from '@/hooks/useDevServer'; @@ -23,7 +22,7 @@ import type { CommandBarPage } from './pages'; * action visibility and state conditions. */ export function useActionVisibilityContext(): ActionVisibilityContext { - const layout = useLayoutStore(); + const layout = useUiPreferencesStore(); const { workspace, workspaceId, isCreateMode, repos } = useWorkspaceContext(); const diffPaths = useDiffViewStore((s) => s.diffPaths); const diffViewMode = useDiffViewMode(); @@ -62,12 +61,10 @@ export function useActionVisibilityContext(): ActionVisibilityContext { false; return { - isChangesMode: layout.isChangesMode, - isLogsMode: layout.isLogsMode, - isPreviewMode: layout.isPreviewMode, - isSidebarVisible: layout.isSidebarVisible, - isMainPanelVisible: layout.isMainPanelVisible, - isGitPanelVisible: layout.isGitPanelVisible, + rightMainPanelMode: layout.rightMainPanelMode, + isLeftSidebarVisible: layout.isLeftSidebarVisible, + isLeftMainPanelVisible: layout.isLeftMainPanelVisible, + isRightSidebarVisible: layout.isRightSidebarVisible, isCreateMode, hasWorkspace: !!workspace, workspaceArchived: workspace?.archived ?? false, @@ -84,12 +81,10 @@ export function useActionVisibilityContext(): ActionVisibilityContext { isAttemptRunning: isAttemptRunningVisible, }; }, [ - layout.isChangesMode, - layout.isLogsMode, - layout.isPreviewMode, - layout.isSidebarVisible, - layout.isMainPanelVisible, - layout.isGitPanelVisible, + layout.rightMainPanelMode, + layout.isLeftSidebarVisible, + layout.isLeftMainPanelVisible, + layout.isRightSidebarVisible, isCreateMode, workspace, repos, diff --git a/frontend/src/components/ui-new/containers/ChangesPanelContainer.tsx b/frontend/src/components/ui-new/containers/ChangesPanelContainer.tsx index 3afa100f..7ab43c13 100644 --- a/frontend/src/components/ui-new/containers/ChangesPanelContainer.tsx +++ b/frontend/src/components/ui-new/containers/ChangesPanelContainer.tsx @@ -8,6 +8,9 @@ import { } from 'react'; import { ChangesPanel } from '../views/ChangesPanel'; import { sortDiffs } from '@/utils/fileTreeUtils'; +import { useChangesView } from '@/contexts/ChangesViewContext'; +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useTask } from '@/hooks/useTask'; import type { Diff, DiffChangeKind } from 'shared/types'; // Auto-collapse defaults based on change type (matches DiffsPanel behavior) @@ -128,24 +131,20 @@ function useInViewObserver( } interface ChangesPanelContainerProps { - diffs: Diff[]; - selectedFilePath?: string | null; - onFileInViewChange?: (path: string) => void; className?: string; - /** Project ID for @ mentions in comments */ - projectId?: string; /** Attempt ID for opening files in IDE */ attemptId?: string; } export function ChangesPanelContainer({ - diffs, - selectedFilePath, - onFileInViewChange, className, - projectId, attemptId, }: ChangesPanelContainerProps) { + const { diffs, workspace } = useWorkspaceContext(); + const { data: task } = useTask(workspace?.task_id, { + enabled: !!workspace?.task_id, + }); + const { selectedFilePath, setFileInView } = useChangesView(); const diffRefs = useRef>(new Map()); const containerRef = useRef(null); // Track which diffs we've processed for auto-collapse @@ -155,7 +154,7 @@ export function ChangesPanelContainer({ const observeElement = useInViewObserver( diffRefs, containerRef, - onFileInViewChange + setFileInView ); useEffect(() => { @@ -208,7 +207,7 @@ export function ChangesPanelContainer({ className={className} diffItems={diffItems} onDiffRef={handleDiffRef} - projectId={projectId} + projectId={task?.project_id} attemptId={attemptId} /> ); diff --git a/frontend/src/components/ui-new/containers/FileTreeContainer.tsx b/frontend/src/components/ui-new/containers/FileTreeContainer.tsx index 211d1db9..4a0678ad 100644 --- a/frontend/src/components/ui-new/containers/FileTreeContainer.tsx +++ b/frontend/src/components/ui-new/containers/FileTreeContainer.tsx @@ -8,12 +8,12 @@ import { } from '@/utils/fileTreeUtils'; import { usePersistedCollapsedPaths } from '@/stores/useUiPreferencesStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useChangesView } from '@/contexts/ChangesViewContext'; import type { Diff } from 'shared/types'; interface FileTreeContainerProps { workspaceId?: string; diffs: Diff[]; - selectedFilePath?: string | null; onSelectFile?: (path: string, diff: Diff) => void; className?: string; } @@ -21,10 +21,10 @@ interface FileTreeContainerProps { export function FileTreeContainer({ workspaceId, diffs, - selectedFilePath, onSelectFile, className, }: FileTreeContainerProps) { + const { fileInView } = useChangesView(); const [searchQuery, setSearchQuery] = useState(''); const [collapsedPaths, setCollapsedPaths] = usePersistedCollapsedPaths(workspaceId); @@ -39,19 +39,19 @@ export function FileTreeContainer({ isGitHubCommentsLoading, } = useWorkspaceContext(); - // Sync selectedPath with external selectedFilePath prop and scroll into view + // Sync selectedPath with fileInView from context and scroll into view useEffect(() => { - if (selectedFilePath !== undefined) { - setSelectedPath(selectedFilePath); + if (fileInView !== undefined) { + setSelectedPath(fileInView); // Scroll the selected node into view if needed - if (selectedFilePath) { - const el = nodeRefs.current.get(selectedFilePath); + if (fileInView) { + const el = nodeRefs.current.get(fileInView); if (el) { el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } } - }, [selectedFilePath]); + }, [fileInView]); const handleNodeRef = useCallback( (path: string, el: HTMLDivElement | null) => { diff --git a/frontend/src/components/ui-new/containers/GitPanelContainer.tsx b/frontend/src/components/ui-new/containers/GitPanelContainer.tsx new file mode 100644 index 00000000..a75160d0 --- /dev/null +++ b/frontend/src/components/ui-new/containers/GitPanelContainer.tsx @@ -0,0 +1,288 @@ +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useActions } from '@/contexts/ActionsContext'; +import { usePush } from '@/hooks/usePush'; +import { useRenameBranch } from '@/hooks/useRenameBranch'; +import { useBranchStatus } from '@/hooks/useBranchStatus'; +import { repoApi } from '@/lib/api'; +import { ConfirmDialog } from '@/components/ui-new/dialogs/ConfirmDialog'; +import { ForcePushDialog } from '@/components/dialogs/git/ForcePushDialog'; +import { GitPanel, type RepoInfo } from '@/components/ui-new/views/GitPanel'; +import { Actions } from '@/components/ui-new/actions'; +import type { RepoAction } from '@/components/ui-new/primitives/RepoCard'; +import type { + Workspace, + RepoWithTargetBranch, + Diff, + Merge, +} from 'shared/types'; + +export interface GitPanelContainerProps { + selectedWorkspace: Workspace | undefined; + repos: RepoWithTargetBranch[]; + diffs: Diff[]; +} + +type PushState = 'idle' | 'pending' | 'success' | 'error'; + +export function GitPanelContainer({ + selectedWorkspace, + repos, + diffs, +}: GitPanelContainerProps) { + const { executeAction } = useActions(); + const navigate = useNavigate(); + + // Hooks for branch management (moved from WorkspacesLayout) + const renameBranch = useRenameBranch(selectedWorkspace?.id); + const { data: branchStatus } = useBranchStatus(selectedWorkspace?.id); + + const handleBranchNameChange = useCallback( + (newName: string) => { + renameBranch.mutate(newName); + }, + [renameBranch] + ); + + // Transform repos to RepoInfo format (moved from WorkspacesLayout) + const repoInfos: RepoInfo[] = useMemo( + () => + repos.map((repo) => { + const repoStatus = branchStatus?.find((s) => s.repo_id === repo.id); + + let prNumber: number | undefined; + let prUrl: string | undefined; + let prStatus: 'open' | 'merged' | 'closed' | 'unknown' | undefined; + + if (repoStatus?.merges) { + const openPR = repoStatus.merges.find( + (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open' + ); + const mergedPR = repoStatus.merges.find( + (m: Merge) => m.type === 'pr' && m.pr_info.status === 'merged' + ); + + const relevantPR = openPR || mergedPR; + if (relevantPR && relevantPR.type === 'pr') { + prNumber = Number(relevantPR.pr_info.number); + prUrl = relevantPR.pr_info.url; + prStatus = relevantPR.pr_info.status; + } + } + + const repoDiffs = diffs.filter((d) => d.repoId === repo.id); + const filesChanged = repoDiffs.length; + const linesAdded = repoDiffs.reduce( + (sum, d) => sum + (d.additions ?? 0), + 0 + ); + const linesRemoved = repoDiffs.reduce( + (sum, d) => sum + (d.deletions ?? 0), + 0 + ); + + return { + id: repo.id, + name: repo.display_name || repo.name, + targetBranch: repo.target_branch || 'main', + commitsAhead: repoStatus?.commits_ahead ?? 0, + remoteCommitsAhead: repoStatus?.remote_commits_ahead ?? 0, + filesChanged, + linesAdded, + linesRemoved, + prNumber, + prUrl, + prStatus, + }; + }), + [repos, diffs, branchStatus] + ); + + // Track push state per repo: idle, pending, success, or error + const [pushStates, setPushStates] = useState>({}); + const pushStatesRef = useRef>({}); + pushStatesRef.current = pushStates; + const successTimeoutRef = useRef | null>(null); + const currentPushRepoRef = useRef(null); + + // Reset push-related state when the selected workspace changes to avoid + // leaking push state across workspaces with repos that share the same ID. + useEffect(() => { + setPushStates({}); + pushStatesRef.current = {}; + currentPushRepoRef.current = null; + + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + }, [selectedWorkspace?.id]); + // Use push hook for direct API access with proper error handling + const pushMutation = usePush( + selectedWorkspace?.id, + // onSuccess + () => { + const repoId = currentPushRepoRef.current; + if (!repoId) return; + setPushStates((prev) => ({ ...prev, [repoId]: 'success' })); + // Clear success state after 2 seconds + successTimeoutRef.current = setTimeout(() => { + setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); + }, 2000); + }, + // onError + async (err, errorData) => { + const repoId = currentPushRepoRef.current; + if (!repoId) return; + + // Handle force push required - show confirmation dialog + if (errorData?.type === 'force_push_required' && selectedWorkspace?.id) { + setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); + await ForcePushDialog.show({ + attemptId: selectedWorkspace.id, + repoId, + }); + return; + } + + // Show error state and dialog for other errors + setPushStates((prev) => ({ ...prev, [repoId]: 'error' })); + const message = + err instanceof Error ? err.message : 'Failed to push changes'; + ConfirmDialog.show({ + title: 'Error', + message, + confirmText: 'OK', + showCancelButton: false, + variant: 'destructive', + }); + // Clear error state after 3 seconds + successTimeoutRef.current = setTimeout(() => { + setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); + }, 3000); + } + ); + + // Clean up timeout on unmount + useEffect(() => { + return () => { + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + // Compute repoInfos with push button state + const repoInfosWithPushButton = useMemo( + () => + repoInfos.map((repo) => { + const state = pushStates[repo.id] ?? 'idle'; + const hasUnpushedCommits = + repo.prStatus === 'open' && (repo.remoteCommitsAhead ?? 0) > 0; + // Show push button if there are unpushed commits OR if we're in a push flow + // (pending/success/error states keep the button visible for feedback) + const isInPushFlow = state !== 'idle'; + return { + ...repo, + showPushButton: hasUnpushedCommits && !isInPushFlow, + isPushPending: state === 'pending', + isPushSuccess: state === 'success', + isPushError: state === 'error', + }; + }), + [repoInfos, pushStates] + ); + + // Handle copying repo path to clipboard + const handleCopyPath = useCallback( + (repoId: string) => { + const repo = repos.find((r) => r.id === repoId); + if (repo?.path) { + navigator.clipboard.writeText(repo.path); + } + }, + [repos] + ); + + // Handle opening repo in editor + const handleOpenInEditor = useCallback(async (repoId: string) => { + try { + const response = await repoApi.openEditor(repoId, { + editor_type: null, + file_path: null, + }); + + // If a URL is returned (remote mode), open it in a new tab + if (response.url) { + window.open(response.url, '_blank'); + } + } catch (err) { + console.error('Failed to open repo in editor:', err); + } + }, []); + + // Handle GitPanel actions using the action system + const handleActionsClick = useCallback( + async (repoId: string, action: RepoAction) => { + if (!selectedWorkspace?.id) return; + + // Map RepoAction to Action definitions + const actionMap = { + 'pull-request': Actions.GitCreatePR, + merge: Actions.GitMerge, + rebase: Actions.GitRebase, + 'change-target': Actions.GitChangeTarget, + push: Actions.GitPush, + }; + + const actionDef = actionMap[action]; + if (!actionDef) return; + + // Execute git action with workspaceId and repoId + await executeAction(actionDef, selectedWorkspace.id, repoId); + }, + [selectedWorkspace, executeAction] + ); + + // Handle push button click - use mutation for proper state tracking + const handlePushClick = useCallback( + (repoId: string) => { + // Use ref to check current state to avoid stale closure + if (pushStatesRef.current[repoId] === 'pending') return; + + // Clear any existing timeout + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + + // Track which repo we're pushing + currentPushRepoRef.current = repoId; + setPushStates((prev) => ({ ...prev, [repoId]: 'pending' })); + pushMutation.mutate({ repo_id: repoId }); + }, + [pushMutation] + ); + + // Handle opening repository settings + const handleOpenSettings = useCallback( + (repoId: string) => { + navigate(`/settings/repos?repoId=${repoId}`); + }, + [navigate] + ); + + return ( + console.log('Add repo clicked')} + /> + ); +} diff --git a/frontend/src/components/ui-new/containers/LogsContentContainer.tsx b/frontend/src/components/ui-new/containers/LogsContentContainer.tsx index 4d9f4e1a..b18878ed 100644 --- a/frontend/src/components/ui-new/containers/LogsContentContainer.tsx +++ b/frontend/src/components/ui-new/containers/LogsContentContainer.tsx @@ -6,26 +6,23 @@ import { type LogEntry, } from '../VirtualizedProcessLogs'; import { useLogStream } from '@/hooks/useLogStream'; +import { useLogsPanel } from '@/contexts/LogsPanelContext'; export type LogsPanelContent = | { type: 'process'; processId: string } | { type: 'tool'; toolName: string; content: string; command?: string }; interface LogsContentContainerProps { - content: LogsPanelContent | null; className?: string; - searchQuery?: string; - currentMatchIndex?: number; - onMatchIndicesChange?: (indices: number[]) => void; } -export function LogsContentContainer({ - content, - className, - searchQuery = '', - currentMatchIndex = 0, - onMatchIndicesChange, -}: LogsContentContainerProps) { +export function LogsContentContainer({ className }: LogsContentContainerProps) { + const { + logsPanelContent: content, + logSearchQuery: searchQuery, + logCurrentMatchIdx: currentMatchIndex, + setLogMatchIndices: onMatchIndicesChange, + } = useLogsPanel(); const { t } = useTranslation('common'); // Get logs for process content (only when type is 'process') const processId = content?.type === 'process' ? content.processId : ''; diff --git a/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx b/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx index 428fbb32..14c6ff52 100644 --- a/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx +++ b/frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx @@ -7,7 +7,7 @@ import { type ScreenSize, } from '@/hooks/usePreviewSettings'; import { useLogStream } from '@/hooks/useLogStream'; -import { useLayoutStore } from '@/stores/useLayoutStore'; +import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; import { useNavigate } from 'react-router-dom'; import { ScriptFixerDialog } from '@/components/dialogs/scripts/ScriptFixerDialog'; @@ -25,8 +25,10 @@ export function PreviewBrowserContainer({ className, }: PreviewBrowserContainerProps) { const navigate = useNavigate(); - const previewRefreshKey = useLayoutStore((s) => s.previewRefreshKey); - const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh); + const previewRefreshKey = useUiPreferencesStore((s) => s.previewRefreshKey); + const triggerPreviewRefresh = useUiPreferencesStore( + (s) => s.triggerPreviewRefresh + ); const { repos, workspaceId } = useWorkspaceContext(); const { diff --git a/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx b/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx index 302f546b..5551634f 100644 --- a/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx +++ b/frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx @@ -2,7 +2,10 @@ import { useCallback, useState, useEffect } from 'react'; import { PreviewControls } from '../views/PreviewControls'; import { usePreviewDevServer } from '../hooks/usePreviewDevServer'; import { useLogStream } from '@/hooks/useLogStream'; -import { useLayoutStore } from '@/stores/useLayoutStore'; +import { + useUiPreferencesStore, + RIGHT_MAIN_PANEL_MODES, +} from '@/stores/useUiPreferencesStore'; import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; interface PreviewControlsContainerProps { @@ -17,7 +20,9 @@ export function PreviewControlsContainer({ className, }: PreviewControlsContainerProps) { const { repos } = useWorkspaceContext(); - const setLogsMode = useLayoutStore((s) => s.setLogsMode); + const setRightMainPanelMode = useUiPreferencesStore( + (s) => s.setRightMainPanelMode + ); const { isStarting, runningDevServers, devServerProcesses } = usePreviewDevServer(attemptId); @@ -42,10 +47,10 @@ export function PreviewControlsContainer({ if (targetId && onViewProcessInPanel) { onViewProcessInPanel(targetId); } else { - setLogsMode(true); + setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS); } }, - [activeProcess?.id, onViewProcessInPanel, setLogsMode] + [activeProcess?.id, onViewProcessInPanel, setRightMainPanelMode] ); const handleTabChange = useCallback((processId: string) => { diff --git a/frontend/src/components/ui-new/containers/ProcessListContainer.tsx b/frontend/src/components/ui-new/containers/ProcessListContainer.tsx index dd07bec6..135401f6 100644 --- a/frontend/src/components/ui-new/containers/ProcessListContainer.tsx +++ b/frontend/src/components/ui-new/containers/ProcessListContainer.tsx @@ -1,36 +1,29 @@ import { useEffect, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useExecutionProcessesContext } from '@/contexts/ExecutionProcessesContext'; +import { useLogsPanel } from '@/contexts/LogsPanelContext'; import { ProcessListItem } from '../primitives/ProcessListItem'; import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader'; import { InputField } from '../primitives/InputField'; import { CaretUpIcon, CaretDownIcon } from '@phosphor-icons/react'; import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore'; -interface ProcessListContainerProps { - selectedProcessId: string | null; - onSelectProcess: (processId: string) => void; - disableAutoSelect?: boolean; - // Search props - searchQuery?: string; - onSearchQueryChange?: (query: string) => void; - matchCount?: number; - currentMatchIdx?: number; - onPrevMatch?: () => void; - onNextMatch?: () => void; -} +export function ProcessListContainer() { + const { + logsPanelContent, + logSearchQuery: searchQuery, + logMatchIndices, + logCurrentMatchIdx: currentMatchIdx, + setLogSearchQuery: onSearchQueryChange, + handleLogPrevMatch: onPrevMatch, + handleLogNextMatch: onNextMatch, + viewProcessInPanel: onSelectProcess, + } = useLogsPanel(); -export function ProcessListContainer({ - selectedProcessId, - onSelectProcess, - disableAutoSelect, - searchQuery = '', - onSearchQueryChange, - matchCount = 0, - currentMatchIdx = 0, - onPrevMatch, - onNextMatch, -}: ProcessListContainerProps) { + const selectedProcessId = + logsPanelContent?.type === 'process' ? logsPanelContent.processId : null; + const disableAutoSelect = logsPanelContent?.type === 'tool'; + const matchCount = logMatchIndices.length; const { t } = useTranslation('common'); const { executionProcessesVisible } = useExecutionProcessesContext(); diff --git a/frontend/src/components/ui-new/containers/RightSidebar.tsx b/frontend/src/components/ui-new/containers/RightSidebar.tsx new file mode 100644 index 00000000..4850b213 --- /dev/null +++ b/frontend/src/components/ui-new/containers/RightSidebar.tsx @@ -0,0 +1,107 @@ +import { GitPanelCreateContainer } from '@/components/ui-new/containers/GitPanelCreateContainer'; +import { FileTreeContainer } from '@/components/ui-new/containers/FileTreeContainer'; +import { ProcessListContainer } from '@/components/ui-new/containers/ProcessListContainer'; +import { PreviewControlsContainer } from '@/components/ui-new/containers/PreviewControlsContainer'; +import { GitPanelContainer } from '@/components/ui-new/containers/GitPanelContainer'; +import { useChangesView } from '@/contexts/ChangesViewContext'; +import { useLogsPanel } from '@/contexts/LogsPanelContext'; +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import type { Workspace, RepoWithTargetBranch } from 'shared/types'; +import { + RIGHT_MAIN_PANEL_MODES, + type RightMainPanelMode, + useExpandedAll, +} from '@/stores/useUiPreferencesStore'; + +export interface RightSidebarProps { + isCreateMode: boolean; + rightMainPanelMode: RightMainPanelMode | null; + selectedWorkspace: Workspace | undefined; + repos: RepoWithTargetBranch[]; +} + +export function RightSidebar({ + isCreateMode, + rightMainPanelMode, + selectedWorkspace, + repos, +}: RightSidebarProps) { + const { selectFile } = useChangesView(); + const { viewProcessInPanel } = useLogsPanel(); + const { diffs } = useWorkspaceContext(); + const { setExpanded } = useExpandedAll(); + + if (isCreateMode) { + return ; + } + + if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES) { + return ( +
+
+ { + selectFile(path); + setExpanded(`diff:${path}`, true); + }} + /> +
+
+ +
+
+ ); + } + + if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS) { + return ( +
+
+ +
+
+ +
+
+ ); + } + + if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW) { + return ( +
+
+ +
+
+ +
+
+ ); + } + + return ( + + ); +} diff --git a/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx b/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx index 2f7da5e8..f05f59f0 100644 --- a/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx +++ b/frontend/src/components/ui-new/containers/SessionChatBoxContainer.tsx @@ -28,6 +28,10 @@ import { SessionChatBox, type ExecutionStatus, } from '../primitives/SessionChatBox'; +import { + useUiPreferencesStore, + RIGHT_MAIN_PANEL_MODES, +} from '@/stores/useUiPreferencesStore'; import { Actions, type ActionDefinition } from '../actions'; import { isActionVisible, @@ -65,8 +69,6 @@ interface SessionChatBoxContainerProps { linesAdded?: number; /** Number of lines removed */ linesRemoved?: number; - /** Callback to view code changes (toggle ChangesPanel) */ - onViewCode?: () => void; /** Available sessions for this workspace */ sessions?: Session[]; /** Called when a session is selected */ @@ -87,7 +89,6 @@ export function SessionChatBoxContainer({ filesChanged, linesAdded, linesRemoved, - onViewCode, sessions = [], onSelectSession, projectId, @@ -101,6 +102,15 @@ export function SessionChatBoxContainer({ const { executeAction } = useActions(); const actionCtx = useActionVisibilityContext(); + const { rightMainPanelMode, setRightMainPanelMode } = useUiPreferencesStore(); + + const handleViewCode = useCallback(() => { + setRightMainPanelMode( + rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES + ? null + : RIGHT_MAIN_PANEL_MODES.CHANGES + ); + }, [rightMainPanelMode, setRightMainPanelMode]); // Get entries early to extract pending approval for scratch key const { entries } = useEntries(); @@ -578,8 +588,8 @@ export function SessionChatBoxContainer({ filesChanged: 0, linesAdded: 0, linesRemoved: 0, - onViewCode: undefined, }} + onViewCode={handleViewCode} /> ); } @@ -587,6 +597,7 @@ export function SessionChatBoxContainer({ return ( void; +const WORKSPACES_GUIDE_ID = 'workspaces-guide'; + +interface ModeProviderProps { + isCreateMode: boolean; + executionProps: { + key: string; + attemptId?: string; + sessionId?: string; + }; + children: ReactNode; } -type PushState = 'idle' | 'pending' | 'success' | 'error'; - -function GitPanelContainer({ - selectedWorkspace, - repos, - repoInfos, - onBranchNameChange, -}: GitPanelContainerProps) { - const { executeAction } = useActions(); - const navigate = useNavigate(); - - // Track push state per repo: idle, pending, success, or error - const [pushStates, setPushStates] = useState>({}); - const pushStatesRef = useRef>({}); - pushStatesRef.current = pushStates; - const successTimeoutRef = useRef | null>(null); - const currentPushRepoRef = useRef(null); - - // Reset push-related state when the selected workspace changes to avoid - // leaking push state across workspaces with repos that share the same ID. - useEffect(() => { - setPushStates({}); - pushStatesRef.current = {}; - currentPushRepoRef.current = null; - - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - successTimeoutRef.current = null; - } - }, [selectedWorkspace?.id]); - // Use push hook for direct API access with proper error handling - const pushMutation = usePush( - selectedWorkspace?.id, - // onSuccess - () => { - const repoId = currentPushRepoRef.current; - if (!repoId) return; - setPushStates((prev) => ({ ...prev, [repoId]: 'success' })); - // Clear success state after 2 seconds - successTimeoutRef.current = setTimeout(() => { - setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); - }, 2000); - }, - // onError - async (err, errorData) => { - const repoId = currentPushRepoRef.current; - if (!repoId) return; - - // Handle force push required - show confirmation dialog - if (errorData?.type === 'force_push_required' && selectedWorkspace?.id) { - setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); - await ForcePushDialog.show({ - attemptId: selectedWorkspace.id, - repoId, - }); - return; - } - - // Show error state and dialog for other errors - setPushStates((prev) => ({ ...prev, [repoId]: 'error' })); - const message = - err instanceof Error ? err.message : 'Failed to push changes'; - ConfirmDialog.show({ - title: 'Error', - message, - confirmText: 'OK', - showCancelButton: false, - variant: 'destructive', - }); - // Clear error state after 3 seconds - successTimeoutRef.current = setTimeout(() => { - setPushStates((prev) => ({ ...prev, [repoId]: 'idle' })); - }, 3000); - } - ); - - // Clean up timeout on unmount - useEffect(() => { - return () => { - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - } - }; - }, []); - - // Compute repoInfos with push button state - const repoInfosWithPushButton = useMemo( - () => - repoInfos.map((repo) => { - const state = pushStates[repo.id] ?? 'idle'; - const hasUnpushedCommits = - repo.prStatus === 'open' && (repo.remoteCommitsAhead ?? 0) > 0; - // Show push button if there are unpushed commits OR if we're in a push flow - // (pending/success/error states keep the button visible for feedback) - const isInPushFlow = state !== 'idle'; - return { - ...repo, - showPushButton: hasUnpushedCommits && !isInPushFlow, - isPushPending: state === 'pending', - isPushSuccess: state === 'success', - isPushError: state === 'error', - }; - }), - [repoInfos, pushStates] - ); - - // Handle copying repo path to clipboard - const handleCopyPath = useCallback( - (repoId: string) => { - const repo = repos.find((r) => r.id === repoId); - if (repo?.path) { - navigator.clipboard.writeText(repo.path); - } - }, - [repos] - ); - - // Handle opening repo in editor - const handleOpenInEditor = useCallback(async (repoId: string) => { - try { - const response = await repoApi.openEditor(repoId, { - editor_type: null, - file_path: null, - }); - - // If a URL is returned (remote mode), open it in a new tab - if (response.url) { - window.open(response.url, '_blank'); - } - } catch (err) { - console.error('Failed to open repo in editor:', err); - } - }, []); - - // Handle GitPanel actions using the action system - const handleActionsClick = useCallback( - async (repoId: string, action: RepoAction) => { - if (!selectedWorkspace?.id) return; - - // Map RepoAction to Action definitions - const actionMap = { - 'pull-request': Actions.GitCreatePR, - merge: Actions.GitMerge, - rebase: Actions.GitRebase, - 'change-target': Actions.GitChangeTarget, - push: Actions.GitPush, - }; - - const actionDef = actionMap[action]; - if (!actionDef) return; - - // Execute git action with workspaceId and repoId - await executeAction(actionDef, selectedWorkspace.id, repoId); - }, - [selectedWorkspace, executeAction] - ); - - // Handle push button click - use mutation for proper state tracking - const handlePushClick = useCallback( - (repoId: string) => { - // Use ref to check current state to avoid stale closure - if (pushStatesRef.current[repoId] === 'pending') return; - - // Clear any existing timeout - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - successTimeoutRef.current = null; - } - - // Track which repo we're pushing - currentPushRepoRef.current = repoId; - setPushStates((prev) => ({ ...prev, [repoId]: 'pending' })); - pushMutation.mutate({ repo_id: repoId }); - }, - [pushMutation] - ); - - // Handle opening repository settings - const handleOpenSettings = useCallback( - (repoId: string) => { - navigate(`/settings/repos?repoId=${repoId}`); - }, - [navigate] - ); - +function ModeProvider({ + isCreateMode, + executionProps, + children, +}: ModeProviderProps) { + if (isCreateMode) { + return {children}; + } return ( - console.log('Add repo clicked')} - /> + + {children} + ); } -// Fixed UUID for the universal workspace draft (same as in useCreateModeState.ts) -const DRAFT_WORKSPACE_ID = '00000000-0000-0000-0000-000000000001'; - export function WorkspacesLayout() { const { workspace: selectedWorkspace, - workspaceId: selectedWorkspaceId, - activeWorkspaces, - archivedWorkspaces, isLoading, isCreateMode, - selectWorkspace, - navigateToCreate, selectedSession, selectedSessionId, sessions, @@ -283,621 +71,168 @@ export function WorkspacesLayout() { isNewSessionMode, startNewSession, } = useWorkspaceContext(); - const [searchQuery, setSearchQuery] = useState(''); - // Layout state from store const { - isSidebarVisible, - isMainPanelVisible, - isGitPanelVisible, - isChangesMode, - isLogsMode, - isPreviewMode, - setChangesMode, - setLogsMode, - resetForCreateMode, - setSidebarVisible, - setMainPanelVisible, - } = useLayoutStore(); + isLeftSidebarVisible, + isLeftMainPanelVisible, + isRightSidebarVisible, + rightMainPanelMode, + setLeftSidebarVisible, + setLeftMainPanelVisible, + } = useUiPreferencesStore(); - const [rightMainPanelSize, setRightMainPanelSize] = usePaneSize( - PERSIST_KEYS.rightMainPanel, - 50 - ); - const isRightMainPanelVisible = useIsRightMainPanelVisible(); - const [showArchive, setShowArchive] = usePersistedExpanded( - PERSIST_KEYS.workspacesSidebarArchived, - false - ); - - const defaultLayout = (): Layout => { - let layout = { 'left-main': 50, 'right-main': 50 }; - if (typeof rightMainPanelSize === 'number') { - layout = { - 'left-main': 100 - rightMainPanelSize, - 'right-main': rightMainPanelSize, - }; - } - return layout; - }; - - const onLayoutChange = (layout: Layout) => { - if (isRightMainPanelVisible) { - setRightMainPanelSize(layout['right-main']); - } - }; - - // === Auto-show Workspaces Guide on first visit === - const WORKSPACES_GUIDE_ID = 'workspaces-guide'; const { config, updateAndSaveConfig, loading: configLoading, } = useUserSystem(); - const seenFeatures = useMemo( - () => config?.showcases?.seen_features ?? [], - [config?.showcases?.seen_features] - ); - - const hasSeenGuide = - !configLoading && seenFeatures.includes(WORKSPACES_GUIDE_ID); + useCommandBarShortcut(() => CommandBarDialog.show()); + // Auto-show Workspaces Guide on first visit useEffect(() => { - if (configLoading || hasSeenGuide) return; + const seenFeatures = config?.showcases?.seen_features ?? []; + if (configLoading || seenFeatures.includes(WORKSPACES_GUIDE_ID)) return; - // Mark as seen immediately before showing, so page reload doesn't re-trigger void updateAndSaveConfig({ showcases: { seen_features: [...seenFeatures, WORKSPACES_GUIDE_ID] }, }); + WorkspacesGuideDialog.show().finally(() => WorkspacesGuideDialog.hide()); + }, [configLoading, config?.showcases?.seen_features, updateAndSaveConfig]); - WorkspacesGuideDialog.show().finally(() => { - WorkspacesGuideDialog.hide(); - }); - }, [configLoading, hasSeenGuide, seenFeatures, updateAndSaveConfig]); + // Ensure left panels visible when right main panel hidden + useEffect(() => { + if (rightMainPanelMode === null) { + setLeftSidebarVisible(true); + if (!isLeftMainPanelVisible) setLeftMainPanelVisible(true); + } + }, [ + isLeftMainPanelVisible, + rightMainPanelMode, + setLeftSidebarVisible, + setLeftMainPanelVisible, + ]); - // Read persisted draft for sidebar placeholder (works outside of CreateModeProvider) - const { scratch: draftScratch } = useScratch( - ScratchType.DRAFT_WORKSPACE, - DRAFT_WORKSPACE_ID + const [rightMainPanelSize, setRightMainPanelSize] = usePaneSize( + PERSIST_KEYS.rightMainPanel, + 50 ); - // Extract draft title from persisted scratch - const persistedDraftTitle = useMemo(() => { - const scratchData: DraftWorkspaceData | undefined = - draftScratch?.payload?.type === 'DRAFT_WORKSPACE' - ? draftScratch.payload.data - : undefined; - - if (!scratchData?.message?.trim()) return undefined; - const { title } = splitMessageToTitleDescription( - scratchData.message.trim() - ); - return title || 'New Workspace'; - }, [draftScratch]); - - // Command bar keyboard shortcut (CMD+K) - defined later after isChangesMode - // See useCommandBarShortcut call below - - // Selected file path for scroll-to in changes mode (user clicked in FileTree) - const [selectedFilePath, setSelectedFilePath] = useState(null); - // File currently in view from scrolling (for FileTree highlighting) - const [fileInView, setFileInView] = useState(null); - - // Fetch task for current workspace (used for old UI navigation) - const { data: selectedWorkspaceTask } = useTask(selectedWorkspace?.task_id, { - enabled: !!selectedWorkspace?.task_id, - }); - - // Stream real diffs for the selected workspace - const { diffs: realDiffs } = useDiffStream( - selectedWorkspace?.id ?? null, - !isCreateMode && !!selectedWorkspace?.id - ); - - // Hook to rename branch via API - const renameBranch = useRenameBranch(selectedWorkspace?.id); - - // Fetch branch status (including PR/merge info) - const { data: branchStatus } = useBranchStatus(selectedWorkspace?.id); - - const handleBranchNameChange = useCallback( - (newName: string) => { - renameBranch.mutate(newName); - }, - [renameBranch] - ); - - // Compute aggregate diff stats from real diffs (for WorkspacesMainContainer) - const diffStats = useMemo( - () => ({ - filesChanged: realDiffs.length, - linesAdded: realDiffs.reduce((sum, d) => sum + (d.additions ?? 0), 0), - linesRemoved: realDiffs.reduce((sum, d) => sum + (d.deletions ?? 0), 0), - }), - [realDiffs] - ); - - // Transform repos to RepoInfo format for GitPanel - const repoInfos: RepoInfo[] = useMemo( - () => - repos.map((repo) => { - // Find branch status for this repo to get PR info - const repoStatus = branchStatus?.find((s) => s.repo_id === repo.id); - - // Find the most relevant PR (prioritize open, then merged) - let prNumber: number | undefined; - let prUrl: string | undefined; - let prStatus: 'open' | 'merged' | 'closed' | 'unknown' | undefined; - - if (repoStatus?.merges) { - const openPR = repoStatus.merges.find( - (m: Merge) => m.type === 'pr' && m.pr_info.status === 'open' - ); - const mergedPR = repoStatus.merges.find( - (m: Merge) => m.type === 'pr' && m.pr_info.status === 'merged' - ); - - const relevantPR = openPR || mergedPR; - if (relevantPR && relevantPR.type === 'pr') { - prNumber = Number(relevantPR.pr_info.number); - prUrl = relevantPR.pr_info.url; - prStatus = relevantPR.pr_info.status; - } + const defaultLayout: Layout = + typeof rightMainPanelSize === 'number' + ? { + 'left-main': 100 - rightMainPanelSize, + 'right-main': rightMainPanelSize, } + : { 'left-main': 50, 'right-main': 50 }; - // Compute per-repo diff stats - const repoDiffs = realDiffs.filter((d) => d.repoId === repo.id); - const filesChanged = repoDiffs.length; - const linesAdded = repoDiffs.reduce( - (sum, d) => sum + (d.additions ?? 0), - 0 - ); - const linesRemoved = repoDiffs.reduce( - (sum, d) => sum + (d.deletions ?? 0), - 0 - ); - - return { - id: repo.id, - name: repo.display_name || repo.name, - targetBranch: repo.target_branch || 'main', - commitsAhead: repoStatus?.commits_ahead ?? 0, - remoteCommitsAhead: repoStatus?.remote_commits_ahead ?? 0, - filesChanged, - linesAdded, - linesRemoved, - prNumber, - prUrl, - prStatus, - }; - }), - [repos, realDiffs, branchStatus] - ); - - // Content for logs panel (either process logs or tool content) - const [logsPanelContent, setLogsPanelContent] = - useState(null); - - // Log search state (lifted from LogsContentContainer) - const [logSearchQuery, setLogSearchQuery] = useState(''); - const [logMatchIndices, setLogMatchIndices] = useState([]); - const [logCurrentMatchIdx, setLogCurrentMatchIdx] = useState(0); - - // Reset search when content changes - const logContentId = - logsPanelContent?.type === 'process' - ? logsPanelContent.processId - : logsPanelContent?.type === 'tool' - ? logsPanelContent.toolName - : null; - - useEffect(() => { - setLogSearchQuery(''); - setLogCurrentMatchIdx(0); - }, [logContentId]); - - // Reset current match index when search query changes - useEffect(() => { - setLogCurrentMatchIdx(0); - }, [logSearchQuery]); - - // Navigation handlers for log search - const handleLogPrevMatch = useCallback(() => { - if (logMatchIndices.length === 0) return; - setLogCurrentMatchIdx((prev) => - prev > 0 ? prev - 1 : logMatchIndices.length - 1 - ); - }, [logMatchIndices.length]); - - const handleLogNextMatch = useCallback(() => { - if (logMatchIndices.length === 0) return; - setLogCurrentMatchIdx((prev) => - prev < logMatchIndices.length - 1 ? prev + 1 : 0 - ); - }, [logMatchIndices.length]); - - // Reset changes and logs mode when entering create mode - useEffect(() => { - if (isCreateMode) { - resetForCreateMode(); - } - }, [isCreateMode, resetForCreateMode]); - - // Show sidebar when right main panel is hidden - useEffect(() => { - if (!isRightMainPanelVisible) { - setSidebarVisible(true); - } - }, [isRightMainPanelVisible, setSidebarVisible]); - - // Ensure left main panel (chat) is visible when right main panel is hidden - // This prevents invalid state where only sidebars are visible after page reload - useEffect(() => { - if (!isMainPanelVisible && !isRightMainPanelVisible) { - setMainPanelVisible(true); - } - }, [isMainPanelVisible, isRightMainPanelVisible, setMainPanelVisible]); - - // Command bar keyboard shortcut (CMD+K) - const handleOpenCommandBar = useCallback(() => { - CommandBarDialog.show(); - }, []); - useCommandBarShortcut(handleOpenCommandBar); - - // Expanded state for file tree selection - const { setExpanded } = useExpandedAll(); - - // Navigate to logs panel and select a specific process - const handleViewProcessInPanel = useCallback( - (processId: string) => { - if (!isLogsMode) { - setLogsMode(true); - } - setLogsPanelContent({ type: 'process', processId }); - }, - [isLogsMode, setLogsMode] - ); - - // Navigate to logs panel and display static tool content - const handleViewToolContentInPanel = useCallback( - (toolName: string, content: string, command?: string) => { - if (!isLogsMode) { - setLogsMode(true); - } - setLogsPanelContent({ type: 'tool', toolName, content, command }); - }, - [isLogsMode, setLogsMode] - ); - - // Navigate to changes panel and scroll to a specific file - const handleViewFileInChanges = useCallback( - (filePath: string) => { - setChangesMode(true); - setSelectedFilePath(filePath); - }, - [setChangesMode] - ); - - // Toggle changes mode for "View Code" button in main panel - const handleToggleChangesMode = useCallback(() => { - setChangesMode(!isChangesMode); - }, [isChangesMode, setChangesMode]); - - // Compute diffPaths for FileNavigationContext - const diffPaths = useMemo(() => { - return new Set( - realDiffs.map((d) => d.newPath || d.oldPath || '').filter(Boolean) - ); - }, [realDiffs]); - - // Sync diffPaths to store for actions (ToggleAllDiffs, ExpandAllDiffs, etc.) - useEffect(() => { - useDiffViewStore.getState().setDiffPaths(Array.from(diffPaths)); - return () => useDiffViewStore.getState().setDiffPaths([]); - }, [diffPaths]); - - // Get the most recent workspace to auto-select its project and repos in create mode - // Fall back to archived workspaces if no active workspaces exist - const mostRecentWorkspace = activeWorkspaces[0] ?? archivedWorkspaces[0]; - - const { data: lastWorkspaceTask } = useTask(mostRecentWorkspace?.taskId, { - enabled: isCreateMode && !!mostRecentWorkspace?.taskId, - }); - - // Fetch repos from the most recent workspace to auto-select in create mode - const { repos: lastWorkspaceRepos } = useAttemptRepo( - mostRecentWorkspace?.id, - { - enabled: isCreateMode && !!mostRecentWorkspace?.id, - } - ); - - // Render right panel content based on current mode - const renderRightPanelContent = () => { - if (isCreateMode) { - return ; - } - - if (isChangesMode) { - // In changes mode, split git panel vertically: file tree on top, git on bottom - return ( -
-
- { - setSelectedFilePath(path); - setFileInView(path); - // Expand the diff if it was collapsed - setExpanded(`diff:${path}`, true); - }} - /> -
-
- -
-
- ); - } - - if (isLogsMode) { - // In logs mode, split git panel vertically: process list on top, git on bottom - // Derive selectedProcessId from logsPanelContent if it's a process - const selectedProcessId = - logsPanelContent?.type === 'process' - ? logsPanelContent.processId - : null; - return ( -
-
- -
-
- -
-
- ); - } - - if (isPreviewMode) { - // In preview mode, split git panel vertically: preview controls on top, git on bottom - return ( -
-
- -
-
- -
-
- ); - } - - return ( - - ); - }; - - // Action handlers for sidebar workspace actions - const { executeAction } = useActions(); - - const handleArchiveWorkspace = useCallback( - (workspaceId: string) => { - executeAction(Actions.ArchiveWorkspace, workspaceId); - }, - [executeAction] - ); - - const handlePinWorkspace = useCallback( - (workspaceId: string) => { - executeAction(Actions.PinWorkspace, workspaceId); - }, - [executeAction] - ); - - // Render sidebar with persisted draft title - const renderSidebar = () => ( - - ); - - // Render layout content (create mode or workspace mode) - const renderContent = () => { - // Main panel content - const mainPanelContent = isCreateMode ? ( - - ) : ( - - - - - - ); - - // Right main panel content (Changes/Logs/Preview) - const rightMainPanelContent = ( - <> - {isChangesMode && ( - - )} - {isLogsMode && ( - - )} - {isPreviewMode && ( - - )} - - ); - - // Inner layout with main, changes/logs, git panel - const innerLayout = ( -
- {/* Resizable area for main + right panels */} - - {/* Main panel (chat area) */} - {isMainPanelVisible && ( - - {mainPanelContent} - - )} - - {/* Resize handle between main and right panels */} - {isMainPanelVisible && isRightMainPanelVisible && ( - - )} - - {/* Right main panel (Changes/Logs/Preview) */} - {isRightMainPanelVisible && ( - - {rightMainPanelContent} - - )} - - - {/* Git panel (right sidebar) - fixed width, not resizable */} - {isGitPanelVisible && ( -
- {renderRightPanelContent()} -
- )} -
- ); - - // Wrap inner layout with providers - const wrappedInnerContent = isCreateMode ? ( - - - {innerLayout} - - - ) : ( - - - {innerLayout} - - - ); - - return ( -
- {/* Sidebar - OUTSIDE providers, won't remount on workspace switch */} - {isSidebarVisible && ( -
- {renderSidebar()} -
- )} - - {/* Container for provider-wrapped inner content */} -
{wrappedInnerContent}
-
- ); + const onLayoutChange = (layout: Layout) => { + if (rightMainPanelMode !== null) + setRightMainPanelSize(layout['right-main']); }; return (
- {renderContent()} +
+ {isLeftSidebarVisible && ( +
+ +
+ )} + +
+ + + + +
+ + {isLeftMainPanelVisible && ( + + {isCreateMode ? ( + + ) : ( + + )} + + )} + + {isLeftMainPanelVisible && + rightMainPanelMode !== null && ( + + )} + + {rightMainPanelMode !== null && ( + + {rightMainPanelMode === + RIGHT_MAIN_PANEL_MODES.CHANGES && ( + + )} + {rightMainPanelMode === + RIGHT_MAIN_PANEL_MODES.LOGS && ( + + )} + {rightMainPanelMode === + RIGHT_MAIN_PANEL_MODES.PREVIEW && ( + + )} + + )} + + + {isRightSidebarVisible && ( +
+ +
+ )} +
+
+
+
+
+
+
); } diff --git a/frontend/src/components/ui-new/containers/WorkspacesMainContainer.tsx b/frontend/src/components/ui-new/containers/WorkspacesMainContainer.tsx index 5e24d3ef..cfcd8988 100644 --- a/frontend/src/components/ui-new/containers/WorkspacesMainContainer.tsx +++ b/frontend/src/components/ui-new/containers/WorkspacesMainContainer.tsx @@ -3,12 +3,7 @@ import type { Workspace, Session } from 'shared/types'; import { createWorkspaceWithSession } from '@/types/attempt'; import { WorkspacesMain } from '@/components/ui-new/views/WorkspacesMain'; import { useTask } from '@/hooks/useTask'; - -interface DiffStats { - filesChanged: number; - linesAdded: number; - linesRemoved: number; -} +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; interface WorkspacesMainContainerProps { selectedWorkspace: Workspace | null; @@ -20,10 +15,6 @@ interface WorkspacesMainContainerProps { isNewSessionMode?: boolean; /** Callback to start new session mode */ onStartNewSession?: () => void; - /** Callback to toggle changes panel */ - onViewCode?: () => void; - /** Diff statistics from the workspace */ - diffStats?: DiffStats; } export function WorkspacesMainContainer({ @@ -34,9 +25,8 @@ export function WorkspacesMainContainer({ isLoading, isNewSessionMode, onStartNewSession, - onViewCode, - diffStats, }: WorkspacesMainContainerProps) { + const { diffStats } = useWorkspaceContext(); const containerRef = useRef(null); // Fetch task to get project_id for file search @@ -58,10 +48,13 @@ export function WorkspacesMainContainer({ isLoading={isLoading} containerRef={containerRef} projectId={task?.project_id} - onViewCode={onViewCode} isNewSessionMode={isNewSessionMode} onStartNewSession={onStartNewSession} - diffStats={diffStats} + diffStats={{ + filesChanged: diffStats.files_changed, + linesAdded: diffStats.lines_added, + linesRemoved: diffStats.lines_removed, + }} /> ); } diff --git a/frontend/src/components/ui-new/containers/WorkspacesSidebarContainer.tsx b/frontend/src/components/ui-new/containers/WorkspacesSidebarContainer.tsx new file mode 100644 index 00000000..6fa19aa0 --- /dev/null +++ b/frontend/src/components/ui-new/containers/WorkspacesSidebarContainer.tsx @@ -0,0 +1,88 @@ +import { useState, useCallback, useMemo } from 'react'; +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; +import { useActions } from '@/contexts/ActionsContext'; +import { useScratch } from '@/hooks/useScratch'; +import { ScratchType, type DraftWorkspaceData } from 'shared/types'; +import { splitMessageToTitleDescription } from '@/utils/string'; +import { + PERSIST_KEYS, + usePersistedExpanded, +} from '@/stores/useUiPreferencesStore'; +import { WorkspacesSidebar } from '@/components/ui-new/views/WorkspacesSidebar'; +import { Actions } from '@/components/ui-new/actions'; + +// Fixed UUID for the universal workspace draft (same as in useCreateModeState.ts) +const DRAFT_WORKSPACE_ID = '00000000-0000-0000-0000-000000000001'; + +export function WorkspacesSidebarContainer() { + const { + workspaceId: selectedWorkspaceId, + activeWorkspaces, + archivedWorkspaces, + isCreateMode, + selectWorkspace, + navigateToCreate, + } = useWorkspaceContext(); + + const [searchQuery, setSearchQuery] = useState(''); + const [showArchive, setShowArchive] = usePersistedExpanded( + PERSIST_KEYS.workspacesSidebarArchived, + false + ); + + // Read persisted draft for sidebar placeholder + const { scratch: draftScratch } = useScratch( + ScratchType.DRAFT_WORKSPACE, + DRAFT_WORKSPACE_ID + ); + + // Extract draft title from persisted scratch + const persistedDraftTitle = useMemo(() => { + const scratchData: DraftWorkspaceData | undefined = + draftScratch?.payload?.type === 'DRAFT_WORKSPACE' + ? draftScratch.payload.data + : undefined; + + if (!scratchData?.message?.trim()) return undefined; + const { title } = splitMessageToTitleDescription( + scratchData.message.trim() + ); + return title || 'New Workspace'; + }, [draftScratch]); + + // Action handlers for sidebar workspace actions + const { executeAction } = useActions(); + + const handleArchiveWorkspace = useCallback( + (workspaceId: string) => { + executeAction(Actions.ArchiveWorkspace, workspaceId); + }, + [executeAction] + ); + + const handlePinWorkspace = useCallback( + (workspaceId: string) => { + executeAction(Actions.PinWorkspace, workspaceId); + }, + [executeAction] + ); + + return ( + + ); +} diff --git a/frontend/src/components/ui-new/primitives/SessionChatBox.tsx b/frontend/src/components/ui-new/primitives/SessionChatBox.tsx index f87a5ffb..cace648d 100644 --- a/frontend/src/components/ui-new/primitives/SessionChatBox.tsx +++ b/frontend/src/components/ui-new/primitives/SessionChatBox.tsx @@ -77,7 +77,6 @@ interface StatsProps { filesChanged?: number; linesAdded?: number; linesRemoved?: number; - onViewCode?: () => void; hasConflicts?: boolean; conflictedFilesCount?: number; } @@ -135,6 +134,7 @@ interface SessionChatBoxProps { executor?: ExecutorProps; inProgressTodo?: TodoItem | null; localImages?: LocalImageMetadata[]; + onViewCode?: () => void; } /** @@ -160,6 +160,7 @@ export function SessionChatBox({ executor, inProgressTodo, localImages, + onViewCode, }: SessionChatBoxProps) { const { t } = useTranslation('tasks'); const fileInputRef = useRef(null); @@ -554,7 +555,7 @@ export function SessionChatBox({ )} - + {t('diff.filesChanged', { count: filesChanged })} diff --git a/frontend/src/components/ui-new/primitives/conversation/ChatMarkdown.tsx b/frontend/src/components/ui-new/primitives/conversation/ChatMarkdown.tsx index 88cff0c9..e115b0b6 100644 --- a/frontend/src/components/ui-new/primitives/conversation/ChatMarkdown.tsx +++ b/frontend/src/components/ui-new/primitives/conversation/ChatMarkdown.tsx @@ -1,6 +1,6 @@ import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { cn } from '@/lib/utils'; -import { useFileNavigation } from '@/contexts/FileNavigationContext'; +import { useChangesView } from '@/contexts/ChangesViewContext'; interface ChatMarkdownProps { content: string; @@ -15,7 +15,7 @@ export function ChatMarkdown({ className, workspaceId, }: ChatMarkdownProps) { - const { viewFileInChanges, findMatchingDiffPath } = useFileNavigation(); + const { viewFileInChanges, findMatchingDiffPath } = useChangesView(); return (
diff --git a/frontend/src/components/ui-new/primitives/conversation/ChatScriptEntry.tsx b/frontend/src/components/ui-new/primitives/conversation/ChatScriptEntry.tsx index 4bfb0488..59c50cf9 100644 --- a/frontend/src/components/ui-new/primitives/conversation/ChatScriptEntry.tsx +++ b/frontend/src/components/ui-new/primitives/conversation/ChatScriptEntry.tsx @@ -3,7 +3,7 @@ import { TerminalIcon, WrenchIcon } from '@phosphor-icons/react'; import { cn } from '@/lib/utils'; import { ToolStatus } from 'shared/types'; import { ToolStatusDot } from './ToolStatusDot'; -import { useLogNavigation } from '@/contexts/LogNavigationContext'; +import { useLogsPanel } from '@/contexts/LogsPanelContext'; interface ChatScriptEntryProps { title: string; @@ -23,7 +23,7 @@ export function ChatScriptEntry({ onFix, }: ChatScriptEntryProps) { const { t } = useTranslation('tasks'); - const { viewProcessInPanel } = useLogNavigation(); + const { viewProcessInPanel } = useLogsPanel(); const isRunning = status.status === 'created'; const isSuccess = status.status === 'success'; const isFailed = status.status === 'failed'; diff --git a/frontend/src/components/ui-new/views/WorkspacesMain.tsx b/frontend/src/components/ui-new/views/WorkspacesMain.tsx index d63c0e90..c1dac0c5 100644 --- a/frontend/src/components/ui-new/views/WorkspacesMain.tsx +++ b/frontend/src/components/ui-new/views/WorkspacesMain.tsx @@ -23,7 +23,6 @@ interface WorkspacesMainProps { isLoading: boolean; containerRef: RefObject; projectId?: string; - onViewCode?: () => void; /** Whether user is creating a new session */ isNewSessionMode?: boolean; /** Callback to start new session mode */ @@ -39,7 +38,6 @@ export function WorkspacesMain({ isLoading, containerRef, projectId, - onViewCode, isNewSessionMode, onStartNewSession, diffStats, @@ -91,7 +89,6 @@ export function WorkspacesMain({ filesChanged={diffStats?.filesChanged} linesAdded={diffStats?.linesAdded} linesRemoved={diffStats?.linesRemoved} - onViewCode={onViewCode} projectId={projectId} isNewSessionMode={isNewSessionMode} onStartNewSession={onStartNewSession} diff --git a/frontend/src/components/ui-new/views/WorkspacesSidebar.tsx b/frontend/src/components/ui-new/views/WorkspacesSidebar.tsx index 57f79eab..3dcb11c8 100644 --- a/frontend/src/components/ui-new/views/WorkspacesSidebar.tsx +++ b/frontend/src/components/ui-new/views/WorkspacesSidebar.tsx @@ -56,8 +56,6 @@ export function WorkspacesSidebar({ .filter((workspace) => workspace.name.toLowerCase().includes(searchLower)) .slice(0, isSearching ? undefined : DISPLAY_LIMIT); - const hasArchivedWorkspaces = archivedWorkspaces.length > 0; - return (
{/* Header + Search */} @@ -156,29 +154,27 @@ export function WorkspacesSidebar({
{/* Fixed footer toggle - only show if there are archived workspaces */} - {hasArchivedWorkspaces && ( -
- -
- )} +
+ +
); } diff --git a/frontend/src/contexts/ChangesViewContext.tsx b/frontend/src/contexts/ChangesViewContext.tsx new file mode 100644 index 00000000..5056a6a5 --- /dev/null +++ b/frontend/src/contexts/ChangesViewContext.tsx @@ -0,0 +1,110 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, +} from 'react'; +import { + useUiPreferencesStore, + RIGHT_MAIN_PANEL_MODES, +} from '@/stores/useUiPreferencesStore'; +import { useWorkspaceContext } from '@/contexts/WorkspaceContext'; + +interface ChangesViewContextValue { + /** File path selected by user (triggers scroll-to in ChangesPanelContainer) */ + selectedFilePath: string | null; + /** File currently in view from scrolling (for FileTree highlighting) */ + fileInView: string | null; + /** Select a file and update fileInView */ + selectFile: (path: string) => void; + /** Update the file currently in view (from scroll observer) */ + setFileInView: (path: string | null) => void; + /** Navigate to changes mode and scroll to a specific file */ + viewFileInChanges: (filePath: string) => void; + /** Set of file paths currently in the diffs (for checking if inline code should be clickable) */ + diffPaths: Set; + /** Find a diff path matching the given text (supports partial/right-hand match) */ + findMatchingDiffPath: (text: string) => string | null; +} + +const EMPTY_SET = new Set(); + +const defaultValue: ChangesViewContextValue = { + selectedFilePath: null, + fileInView: null, + selectFile: () => {}, + setFileInView: () => {}, + viewFileInChanges: () => {}, + diffPaths: EMPTY_SET, + findMatchingDiffPath: () => null, +}; + +const ChangesViewContext = createContext(defaultValue); + +interface ChangesViewProviderProps { + children: React.ReactNode; +} + +export function ChangesViewProvider({ children }: ChangesViewProviderProps) { + const { diffPaths } = useWorkspaceContext(); + const [selectedFilePath, setSelectedFilePath] = useState(null); + const [fileInView, setFileInView] = useState(null); + const { setRightMainPanelMode } = useUiPreferencesStore(); + + const selectFile = useCallback((path: string) => { + setSelectedFilePath(path); + setFileInView(path); + }, []); + + const viewFileInChanges = useCallback( + (filePath: string) => { + setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.CHANGES); + setSelectedFilePath(filePath); + }, + [setRightMainPanelMode] + ); + + const findMatchingDiffPath = useCallback( + (text: string): string | null => { + if (diffPaths.has(text)) return text; + for (const fullPath of diffPaths) { + if (fullPath.endsWith('/' + text)) { + return fullPath; + } + } + return null; + }, + [diffPaths] + ); + + const value = useMemo( + () => ({ + selectedFilePath, + fileInView, + selectFile, + setFileInView, + viewFileInChanges, + diffPaths, + findMatchingDiffPath, + }), + [ + selectedFilePath, + fileInView, + selectFile, + viewFileInChanges, + diffPaths, + findMatchingDiffPath, + ] + ); + + return ( + + {children} + + ); +} + +export function useChangesView(): ChangesViewContextValue { + return useContext(ChangesViewContext); +} diff --git a/frontend/src/contexts/CreateModeContext.tsx b/frontend/src/contexts/CreateModeContext.tsx index ac578298..1e6ffdb0 100644 --- a/frontend/src/contexts/CreateModeContext.tsx +++ b/frontend/src/contexts/CreateModeContext.tsx @@ -1,10 +1,9 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; -import type { - Repo, - ExecutorProfileId, - RepoWithTargetBranch, -} from 'shared/types'; +import type { Repo, ExecutorProfileId } from 'shared/types'; import { useCreateModeState } from '@/hooks/useCreateModeState'; +import { useWorkspaces } from '@/components/ui-new/hooks/useWorkspaces'; +import { useTask } from '@/hooks/useTask'; +import { useAttemptRepo } from '@/hooks/useAttemptRepo'; interface CreateModeContextValue { selectedProjectId: string | null; @@ -28,16 +27,28 @@ const CreateModeContext = createContext(null); interface CreateModeProviderProps { children: ReactNode; - initialProjectId?: string; - initialRepos?: RepoWithTargetBranch[]; } -export function CreateModeProvider({ - children, - initialProjectId, - initialRepos, -}: CreateModeProviderProps) { - const state = useCreateModeState({ initialProjectId, initialRepos }); +export function CreateModeProvider({ children }: CreateModeProviderProps) { + // Fetch most recent workspace to use as initial values + const { workspaces: activeWorkspaces, archivedWorkspaces } = useWorkspaces(); + const mostRecentWorkspace = activeWorkspaces[0] ?? archivedWorkspaces[0]; + + const { data: lastWorkspaceTask } = useTask(mostRecentWorkspace?.taskId, { + enabled: !!mostRecentWorkspace?.taskId, + }); + + const { repos: lastWorkspaceRepos } = useAttemptRepo( + mostRecentWorkspace?.id, + { + enabled: !!mostRecentWorkspace?.id, + } + ); + + const state = useCreateModeState({ + initialProjectId: lastWorkspaceTask?.project_id, + initialRepos: lastWorkspaceRepos, + }); const value = useMemo( () => ({ diff --git a/frontend/src/contexts/FileNavigationContext.tsx b/frontend/src/contexts/FileNavigationContext.tsx deleted file mode 100644 index 7d562b13..00000000 --- a/frontend/src/contexts/FileNavigationContext.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { createContext, useContext, useMemo, useCallback } from 'react'; - -interface FileNavigationContextValue { - /** Navigate to changes panel and scroll to the specified file */ - viewFileInChanges: (path: string) => void; - /** Set of file paths currently in the diffs (for checking if inline code should be clickable) */ - diffPaths: Set; - /** Find a diff path matching the given text (supports partial/right-hand match) */ - findMatchingDiffPath: (text: string) => string | null; -} - -const EMPTY_SET = new Set(); - -const defaultValue: FileNavigationContextValue = { - viewFileInChanges: () => {}, - diffPaths: EMPTY_SET, - findMatchingDiffPath: () => null, -}; - -const FileNavigationContext = - createContext(defaultValue); - -interface FileNavigationProviderProps { - children: React.ReactNode; - viewFileInChanges: (path: string) => void; - diffPaths: Set; -} - -export function FileNavigationProvider({ - children, - viewFileInChanges, - diffPaths, -}: FileNavigationProviderProps) { - // Find a diff path that matches the given text (exact or right-hand match) - const findMatchingDiffPath = useCallback( - (text: string): string | null => { - // Exact match first - if (diffPaths.has(text)) return text; - - // Right-hand match: check if any diff path ends with the text - // Must match at a path boundary (after / or at start) - for (const fullPath of diffPaths) { - if (fullPath.endsWith('/' + text)) { - return fullPath; - } - } - return null; - }, - [diffPaths] - ); - - const value = useMemo( - () => ({ viewFileInChanges, diffPaths, findMatchingDiffPath }), - [viewFileInChanges, diffPaths, findMatchingDiffPath] - ); - - return ( - - {children} - - ); -} - -export function useFileNavigation(): FileNavigationContextValue { - return useContext(FileNavigationContext); -} diff --git a/frontend/src/contexts/LogNavigationContext.tsx b/frontend/src/contexts/LogNavigationContext.tsx deleted file mode 100644 index 36ad9175..00000000 --- a/frontend/src/contexts/LogNavigationContext.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { createContext, useContext, useMemo } from 'react'; - -interface LogNavigationContextValue { - /** Navigate to logs panel and select the specified process */ - viewProcessInPanel: (processId: string) => void; - /** Navigate to logs panel and display static tool content */ - viewToolContentInPanel: ( - toolName: string, - content: string, - command?: string - ) => void; -} - -const defaultValue: LogNavigationContextValue = { - viewProcessInPanel: () => {}, - viewToolContentInPanel: () => {}, -}; - -const LogNavigationContext = - createContext(defaultValue); - -interface LogNavigationProviderProps { - children: React.ReactNode; - viewProcessInPanel: (processId: string) => void; - viewToolContentInPanel: ( - toolName: string, - content: string, - command?: string - ) => void; -} - -export function LogNavigationProvider({ - children, - viewProcessInPanel, - viewToolContentInPanel, -}: LogNavigationProviderProps) { - const value = useMemo( - () => ({ viewProcessInPanel, viewToolContentInPanel }), - [viewProcessInPanel, viewToolContentInPanel] - ); - - return ( - - {children} - - ); -} - -export function useLogNavigation(): LogNavigationContextValue { - return useContext(LogNavigationContext); -} diff --git a/frontend/src/contexts/LogsPanelContext.tsx b/frontend/src/contexts/LogsPanelContext.tsx new file mode 100644 index 00000000..6af70dc7 --- /dev/null +++ b/frontend/src/contexts/LogsPanelContext.tsx @@ -0,0 +1,145 @@ +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useMemo, + type ReactNode, +} from 'react'; +import type { LogsPanelContent } from '@/components/ui-new/containers/LogsContentContainer'; +import { + useUiPreferencesStore, + RIGHT_MAIN_PANEL_MODES, +} from '@/stores/useUiPreferencesStore'; + +interface LogsPanelContextValue { + logsPanelContent: LogsPanelContent | null; + logSearchQuery: string; + logMatchIndices: number[]; + logCurrentMatchIdx: number; + setLogSearchQuery: (query: string) => void; + setLogMatchIndices: (indices: number[]) => void; + handleLogPrevMatch: () => void; + handleLogNextMatch: () => void; + viewProcessInPanel: (processId: string) => void; + viewToolContentInPanel: ( + toolName: string, + content: string, + command?: string + ) => void; +} + +const defaultValue: LogsPanelContextValue = { + logsPanelContent: null, + logSearchQuery: '', + logMatchIndices: [], + logCurrentMatchIdx: 0, + setLogSearchQuery: () => {}, + setLogMatchIndices: () => {}, + handleLogPrevMatch: () => {}, + handleLogNextMatch: () => {}, + viewProcessInPanel: () => {}, + viewToolContentInPanel: () => {}, +}; + +const LogsPanelContext = createContext(defaultValue); + +interface LogsPanelProviderProps { + children: ReactNode; +} + +export function LogsPanelProvider({ children }: LogsPanelProviderProps) { + const { rightMainPanelMode, setRightMainPanelMode } = useUiPreferencesStore(); + + const [logsPanelContent, setLogsPanelContent] = + useState(null); + const [logSearchQuery, setLogSearchQuery] = useState(''); + const [logMatchIndices, setLogMatchIndices] = useState([]); + const [logCurrentMatchIdx, setLogCurrentMatchIdx] = useState(0); + + const logContentId = + logsPanelContent?.type === 'process' + ? logsPanelContent.processId + : logsPanelContent?.type === 'tool' + ? logsPanelContent.toolName + : null; + + useEffect(() => { + setLogSearchQuery(''); + setLogCurrentMatchIdx(0); + }, [logContentId]); + + useEffect(() => { + setLogCurrentMatchIdx(0); + }, [logSearchQuery]); + + const handleLogPrevMatch = useCallback(() => { + if (logMatchIndices.length === 0) return; + setLogCurrentMatchIdx((prev) => + prev > 0 ? prev - 1 : logMatchIndices.length - 1 + ); + }, [logMatchIndices.length]); + + const handleLogNextMatch = useCallback(() => { + if (logMatchIndices.length === 0) return; + setLogCurrentMatchIdx((prev) => + prev < logMatchIndices.length - 1 ? prev + 1 : 0 + ); + }, [logMatchIndices.length]); + + const viewProcessInPanel = useCallback( + (processId: string) => { + if (rightMainPanelMode !== RIGHT_MAIN_PANEL_MODES.LOGS) { + setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS); + } + setLogsPanelContent({ type: 'process', processId }); + }, + [rightMainPanelMode, setRightMainPanelMode] + ); + + const viewToolContentInPanel = useCallback( + (toolName: string, content: string, command?: string) => { + if (rightMainPanelMode !== RIGHT_MAIN_PANEL_MODES.LOGS) { + setRightMainPanelMode(RIGHT_MAIN_PANEL_MODES.LOGS); + } + setLogsPanelContent({ type: 'tool', toolName, content, command }); + }, + [rightMainPanelMode, setRightMainPanelMode] + ); + + const value = useMemo( + () => ({ + logsPanelContent, + logSearchQuery, + logMatchIndices, + logCurrentMatchIdx, + setLogSearchQuery, + setLogMatchIndices, + handleLogPrevMatch, + handleLogNextMatch, + viewProcessInPanel, + viewToolContentInPanel, + }), + [ + logsPanelContent, + logSearchQuery, + logMatchIndices, + logCurrentMatchIdx, + handleLogPrevMatch, + handleLogNextMatch, + viewProcessInPanel, + viewToolContentInPanel, + ] + ); + + return ( + + {children} + + ); +} + +export function useLogsPanel(): LogsPanelContextValue { + return useContext(LogsPanelContext); +} diff --git a/frontend/src/contexts/WorkspaceContext.tsx b/frontend/src/contexts/WorkspaceContext.tsx index 5117e140..40a12fb2 100644 --- a/frontend/src/contexts/WorkspaceContext.tsx +++ b/frontend/src/contexts/WorkspaceContext.tsx @@ -4,6 +4,7 @@ import { ReactNode, useMemo, useCallback, + useEffect, } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; @@ -19,12 +20,17 @@ import { useGitHubComments, type NormalizedGitHubComment, } from '@/hooks/useGitHubComments'; +import { useDiffStream } from '@/hooks/useDiffStream'; import { attemptsApi } from '@/lib/api'; +import { useUiPreferencesStore } from '@/stores/useUiPreferencesStore'; +import { useDiffViewStore } from '@/stores/useDiffViewStore'; import type { Workspace as ApiWorkspace, Session, RepoWithTargetBranch, UnifiedPrComment, + Diff, + DiffStats, } from 'shared/types'; export type { NormalizedGitHubComment } from '@/hooks/useGitHubComments'; @@ -62,6 +68,12 @@ interface WorkspaceContextValue { setShowGitHubComments: (show: boolean) => void; getGitHubCommentsForFile: (filePath: string) => NormalizedGitHubComment[]; getGitHubCommentCountForFile: (filePath: string) => number; + /** Diffs for the current workspace */ + diffs: Diff[]; + /** Set of file paths in the diffs */ + diffPaths: Set; + /** Aggregate diff statistics */ + diffStats: DiffStats; } const WorkspaceContext = createContext(null); @@ -79,6 +91,13 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { // Derive isCreateMode from URL path instead of prop to allow provider to persist across route changes const isCreateMode = location.pathname === '/workspaces/create'; + // Reset UI state when entering create mode + useEffect(() => { + if (isCreateMode) { + useUiPreferencesStore.getState().resetForCreateMode(); + } + }, [isCreateMode]); + // Fetch workspaces for sidebar display const { workspaces: activeWorkspaces, @@ -127,6 +146,30 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { enabled: !isCreateMode, }); + // Stream diffs for the current workspace + const { diffs } = useDiffStream(workspaceId ?? null, !isCreateMode); + + const diffPaths = useMemo( + () => + new Set(diffs.map((d) => d.newPath || d.oldPath || '').filter(Boolean)), + [diffs] + ); + + // Sync diffPaths to store for expand/collapse all functionality + useEffect(() => { + useDiffViewStore.getState().setDiffPaths(Array.from(diffPaths)); + return () => useDiffViewStore.getState().setDiffPaths([]); + }, [diffPaths]); + + const diffStats: DiffStats = useMemo( + () => ({ + files_changed: diffs.length, + lines_added: diffs.reduce((sum, d) => sum + (d.additions ?? 0), 0), + lines_removed: diffs.reduce((sum, d) => sum + (d.deletions ?? 0), 0), + }), + [diffs] + ); + const isLoading = isLoadingList || isLoadingWorkspace; const selectWorkspace = useCallback( @@ -180,6 +223,9 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { setShowGitHubComments, getGitHubCommentsForFile, getGitHubCommentCountForFile, + diffs, + diffPaths, + diffStats, }), [ workspaceId, @@ -206,6 +252,9 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { setShowGitHubComments, getGitHubCommentsForFile, getGitHubCommentCountForFile, + diffs, + diffPaths, + diffStats, ] ); diff --git a/frontend/src/stores/useLayoutStore.ts b/frontend/src/stores/useLayoutStore.ts deleted file mode 100644 index c095d93f..00000000 --- a/frontend/src/stores/useLayoutStore.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -type LayoutState = { - // Panel visibility - isSidebarVisible: boolean; - isMainPanelVisible: boolean; - isGitPanelVisible: boolean; - isChangesMode: boolean; - isLogsMode: boolean; - isPreviewMode: boolean; - - // Preview refresh coordination - previewRefreshKey: number; - - // Toggle functions - toggleSidebar: () => void; - toggleMainPanel: () => void; - toggleGitPanel: () => void; - toggleChangesMode: () => void; - toggleLogsMode: () => void; - togglePreviewMode: () => void; - - // Setters for direct state updates - setChangesMode: (value: boolean) => void; - setLogsMode: (value: boolean) => void; - setPreviewMode: (value: boolean) => void; - setSidebarVisible: (value: boolean) => void; - setMainPanelVisible: (value: boolean) => void; - - // Preview actions - triggerPreviewRefresh: () => void; - - // Reset for create mode - resetForCreateMode: () => void; -}; - -// Check if screen is wide enough to keep sidebar visible -const isWideScreen = () => window.innerWidth > 2048; - -export const useLayoutStore = create()( - persist( - (set, get) => ({ - isSidebarVisible: true, - isMainPanelVisible: true, - isGitPanelVisible: true, - isChangesMode: false, - isLogsMode: false, - isPreviewMode: false, - previewRefreshKey: 0, - - toggleSidebar: () => - set((s) => ({ isSidebarVisible: !s.isSidebarVisible })), - - toggleMainPanel: () => { - const { isMainPanelVisible, isChangesMode } = get(); - // At least one of Main or Changes must be visible - if (isMainPanelVisible && !isChangesMode) return; - set({ isMainPanelVisible: !isMainPanelVisible }); - }, - - toggleGitPanel: () => - set((s) => ({ isGitPanelVisible: !s.isGitPanelVisible })), - - toggleChangesMode: () => { - const { isChangesMode } = get(); - const newChangesMode = !isChangesMode; - - if (newChangesMode) { - // Changes, logs, and preview are mutually exclusive - // Auto-hide sidebar when entering changes mode (unless screen is wide enough) - set({ - isChangesMode: true, - isLogsMode: false, - isPreviewMode: false, - isSidebarVisible: isWideScreen() ? get().isSidebarVisible : false, - }); - } else { - // Auto-show sidebar when exiting changes mode - set({ - isChangesMode: false, - isSidebarVisible: true, - }); - } - }, - - toggleLogsMode: () => { - const { isLogsMode } = get(); - const newLogsMode = !isLogsMode; - - if (newLogsMode) { - // Logs, changes, and preview are mutually exclusive - // Auto-hide sidebar when entering logs mode (unless screen is wide enough) - set({ - isLogsMode: true, - isChangesMode: false, - isPreviewMode: false, - isSidebarVisible: isWideScreen() ? get().isSidebarVisible : false, - }); - } else { - // Auto-show sidebar when exiting logs mode - set({ - isLogsMode: false, - isSidebarVisible: true, - }); - } - }, - - togglePreviewMode: () => { - const { isPreviewMode } = get(); - const newPreviewMode = !isPreviewMode; - - if (newPreviewMode) { - // Preview, changes, and logs are mutually exclusive - // Auto-hide sidebar when entering preview mode (unless screen is wide enough) - set({ - isPreviewMode: true, - isChangesMode: false, - isLogsMode: false, - isSidebarVisible: isWideScreen() ? get().isSidebarVisible : false, - }); - } else { - // Auto-show sidebar when exiting preview mode - set({ - isPreviewMode: false, - isSidebarVisible: true, - }); - } - }, - - setChangesMode: (value) => { - if (value) { - set({ - isChangesMode: true, - isLogsMode: false, - isPreviewMode: false, - isSidebarVisible: isWideScreen() ? get().isSidebarVisible : false, - }); - } else { - set({ isChangesMode: false }); - } - }, - - setLogsMode: (value) => { - if (value) { - set({ - isLogsMode: true, - isChangesMode: false, - isPreviewMode: false, - isSidebarVisible: isWideScreen() ? get().isSidebarVisible : false, - }); - } else { - set({ isLogsMode: false }); - } - }, - - setPreviewMode: (value) => { - if (value) { - set({ - isPreviewMode: true, - isChangesMode: false, - isLogsMode: false, - isSidebarVisible: isWideScreen() ? get().isSidebarVisible : false, - }); - } else { - set({ isPreviewMode: false }); - } - }, - - setSidebarVisible: (value) => set({ isSidebarVisible: value }), - - setMainPanelVisible: (value) => set({ isMainPanelVisible: value }), - - triggerPreviewRefresh: () => - set((s) => ({ previewRefreshKey: s.previewRefreshKey + 1 })), - - resetForCreateMode: () => - set({ - isChangesMode: false, - isLogsMode: false, - isPreviewMode: false, - }), - }), - { - name: 'layout-preferences', - // Only persist panel visibility preferences, not mode states - partialize: (state) => ({ - isSidebarVisible: state.isSidebarVisible, - isMainPanelVisible: state.isMainPanelVisible, - isGitPanelVisible: state.isGitPanelVisible, - }), - } - ) -); - -// Convenience hooks for individual state values -export const useIsSidebarVisible = () => - useLayoutStore((s) => s.isSidebarVisible); -export const useIsMainPanelVisible = () => - useLayoutStore((s) => s.isMainPanelVisible); -export const useIsGitPanelVisible = () => - useLayoutStore((s) => s.isGitPanelVisible); -export const useIsChangesMode = () => useLayoutStore((s) => s.isChangesMode); -export const useIsLogsMode = () => useLayoutStore((s) => s.isLogsMode); -export const useIsPreviewMode = () => useLayoutStore((s) => s.isPreviewMode); - -// Derived selector: true when right main panel content is visible (Changes/Logs/Preview) -export const useIsRightMainPanelVisible = () => - useLayoutStore((s) => s.isChangesMode || s.isLogsMode || s.isPreviewMode); diff --git a/frontend/src/stores/useUiPreferencesStore.ts b/frontend/src/stores/useUiPreferencesStore.ts index 7882474c..d78e0ad7 100644 --- a/frontend/src/stores/useUiPreferencesStore.ts +++ b/frontend/src/stores/useUiPreferencesStore.ts @@ -3,6 +3,15 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { RepoAction } from '@/components/ui-new/primitives/RepoCard'; +export const RIGHT_MAIN_PANEL_MODES = { + CHANGES: 'changes', + LOGS: 'logs', + PREVIEW: 'preview', +} as const; + +export type RightMainPanelMode = + (typeof RIGHT_MAIN_PANEL_MODES)[keyof typeof RIGHT_MAIN_PANEL_MODES]; + export type ContextBarPosition = | 'top-left' | 'top-right' @@ -14,11 +23,9 @@ export type ContextBarPosition = // Centralized persist keys for type safety export const PERSIST_KEYS = { // Sidebar sections - workspacesSidebarActive: 'workspaces-sidebar-active', workspacesSidebarArchived: 'workspaces-sidebar-archived', // Git panel sections gitAdvancedSettings: 'git-advanced-settings', - gitPanelCreateAddRepo: 'git-panel-create-add-repo', gitPanelRepositories: 'git-panel-repositories', gitPanelProject: 'git-panel-project', gitPanelAddRepositories: 'git-panel-add-repositories', @@ -28,8 +35,6 @@ export const PERSIST_KEYS = { changesSection: 'changes-section', // Preview panel sections devServerSection: 'dev-server-section', - // Context bar - contextBarPosition: 'context-bar-position', // GitHub comments toggle showGitHubComments: 'show-github-comments', // Panel sizes @@ -38,11 +43,12 @@ export const PERSIST_KEYS = { repoCard: (repoId: string) => `repo-card-${repoId}` as const, } as const; +// Check if screen is wide enough to keep sidebar visible +const isWideScreen = () => window.innerWidth > 2048; + export type PersistKey = - | typeof PERSIST_KEYS.workspacesSidebarActive | typeof PERSIST_KEYS.workspacesSidebarArchived | typeof PERSIST_KEYS.gitAdvancedSettings - | typeof PERSIST_KEYS.gitPanelCreateAddRepo | typeof PERSIST_KEYS.gitPanelRepositories | typeof PERSIST_KEYS.gitPanelProject | typeof PERSIST_KEYS.gitPanelAddRepositories @@ -63,11 +69,21 @@ export type PersistKey = | `entry:${string}`; type State = { + // UI preferences repoActions: Record; expanded: Record; contextBarPosition: ContextBarPosition; paneSizes: Record; collapsedPaths: Record; + + // Layout state + isLeftSidebarVisible: boolean; + isLeftMainPanelVisible: boolean; + isRightSidebarVisible: boolean; + rightMainPanelMode: RightMainPanelMode | null; + previewRefreshKey: number; + + // UI preferences actions setRepoAction: (repoId: string, action: RepoAction) => void; setExpanded: (key: string, value: boolean) => void; toggleExpanded: (key: string, defaultValue?: boolean) => void; @@ -75,16 +91,37 @@ type State = { setContextBarPosition: (position: ContextBarPosition) => void; setPaneSize: (key: string, size: number | string) => void; setCollapsedPaths: (key: string, paths: string[]) => void; + + // Layout actions + toggleLeftSidebar: () => void; + toggleLeftMainPanel: () => void; + toggleRightSidebar: () => void; + toggleRightMainPanelMode: (mode: RightMainPanelMode) => void; + setRightMainPanelMode: (mode: RightMainPanelMode | null) => void; + setLeftSidebarVisible: (value: boolean) => void; + setLeftMainPanelVisible: (value: boolean) => void; + triggerPreviewRefresh: () => void; + resetForCreateMode: () => void; }; export const useUiPreferencesStore = create()( persist( - (set) => ({ + (set, get) => ({ + // UI preferences state repoActions: {}, expanded: {}, contextBarPosition: 'middle-right', paneSizes: {}, collapsedPaths: {}, + + // Layout state + isLeftSidebarVisible: true, + isLeftMainPanelVisible: true, + isRightSidebarVisible: true, + rightMainPanelMode: null, + previewRefreshKey: 0, + + // UI preferences actions setRepoAction: (repoId, action) => set((s) => ({ repoActions: { ...s.repoActions, [repoId]: action } })), setExpanded: (key, value) => @@ -109,8 +146,80 @@ export const useUiPreferencesStore = create()( set((s) => ({ paneSizes: { ...s.paneSizes, [key]: size } })), setCollapsedPaths: (key, paths) => set((s) => ({ collapsedPaths: { ...s.collapsedPaths, [key]: paths } })), + + // Layout actions + toggleLeftSidebar: () => + set((s) => ({ isLeftSidebarVisible: !s.isLeftSidebarVisible })), + + toggleLeftMainPanel: () => { + const { isLeftMainPanelVisible, rightMainPanelMode } = get(); + if (isLeftMainPanelVisible && rightMainPanelMode === null) return; + set({ isLeftMainPanelVisible: !isLeftMainPanelVisible }); + }, + + toggleRightSidebar: () => + set((s) => ({ isRightSidebarVisible: !s.isRightSidebarVisible })), + + toggleRightMainPanelMode: (mode) => { + const { rightMainPanelMode } = get(); + const isCurrentlyActive = rightMainPanelMode === mode; + + if (isCurrentlyActive) { + set({ + rightMainPanelMode: null, + isLeftSidebarVisible: true, + }); + } else { + set({ + rightMainPanelMode: mode, + isLeftSidebarVisible: isWideScreen() + ? get().isLeftSidebarVisible + : false, + }); + } + }, + + setRightMainPanelMode: (mode) => { + if (mode !== null) { + set({ + rightMainPanelMode: mode, + isLeftSidebarVisible: isWideScreen() + ? get().isLeftSidebarVisible + : false, + }); + } else { + set({ rightMainPanelMode: null }); + } + }, + + setLeftSidebarVisible: (value) => set({ isLeftSidebarVisible: value }), + + setLeftMainPanelVisible: (value) => + set({ isLeftMainPanelVisible: value }), + + triggerPreviewRefresh: () => + set((s) => ({ previewRefreshKey: s.previewRefreshKey + 1 })), + + resetForCreateMode: () => + set({ + rightMainPanelMode: null, + }), }), - { name: 'ui-preferences' } + { + name: 'ui-preferences', + partialize: (state) => ({ + // UI preferences (all persisted) + repoActions: state.repoActions, + expanded: state.expanded, + contextBarPosition: state.contextBarPosition, + paneSizes: state.paneSizes, + collapsedPaths: state.collapsedPaths, + // Layout (only persist panel visibility, not mode states) + isLeftSidebarVisible: state.isLeftSidebarVisible, + isLeftMainPanelVisible: state.isLeftMainPanelVisible, + isRightSidebarVisible: state.isRightSidebarVisible, + }), + } ) ); @@ -191,3 +300,7 @@ export function usePersistedCollapsedPaths( return [pathSet, setPathSet]; } + +// Layout convenience hooks +export const useIsRightMainPanelVisible = () => + useUiPreferencesStore((s) => s.rightMainPanelMode !== null); diff --git a/shared/types.ts b/shared/types.ts index fa95ad98..b0b32c1f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -366,6 +366,8 @@ pr_status: MergeStatus | null, }; export type WorkspaceSummaryResponse = { summaries: Array, }; +export type DiffStats = { files_changed: number, lines_added: number, lines_removed: number, }; + export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, last_modified: bigint | null, }; export type DirectoryListResponse = { entries: Array, current_path: string, };