Refactor WorkspacesLayout (#2052)

* init refactor

* changes context

* wip

* logs context

* workspaces layout context breakdown

* sidebar context

* move diffs to workspace context

* compress workspaces layout

* refactors

* types

* always show archived
This commit is contained in:
Louis Knight-Webb
2026-01-14 22:07:00 +00:00
committed by GitHub
parent 4071993561
commit ea5954c8f5
32 changed files with 1303 additions and 1369 deletions

View File

@@ -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(),

View File

@@ -56,11 +56,11 @@ pub struct WorkspaceSummaryResponse {
pub summaries: Vec<WorkspaceSummary>,
}
#[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.

View File

@@ -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<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);

View File

@@ -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,

View File

@@ -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<StaticPageId, CommandBarPage> = {
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<StaticPageId, CommandBarPage> = {
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 },

View File

@@ -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,

View File

@@ -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<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement | null>(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}
/>
);

View File

@@ -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) => {

View File

@@ -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<Record<string, PushState>>({});
const pushStatesRef = useRef<Record<string, PushState>>({});
pushStatesRef.current = pushStates;
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentPushRepoRef = useRef<string | null>(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 (
<GitPanel
repos={repoInfosWithPushButton}
workingBranchName={selectedWorkspace?.branch ?? ''}
onWorkingBranchNameChange={handleBranchNameChange}
onActionsClick={handleActionsClick}
onPushClick={handlePushClick}
onOpenInEditor={handleOpenInEditor}
onCopyPath={handleCopyPath}
onOpenSettings={handleOpenSettings}
onAddRepo={() => console.log('Add repo clicked')}
/>
);
}

View File

@@ -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 : '';

View File

@@ -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 {

View File

@@ -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) => {

View File

@@ -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();

View File

@@ -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 <GitPanelCreateContainer />;
}
if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.CHANGES) {
return (
<div className="flex flex-col h-full">
<div className="flex-[7] min-h-0 overflow-hidden">
<FileTreeContainer
key={selectedWorkspace?.id}
workspaceId={selectedWorkspace?.id}
diffs={diffs}
onSelectFile={(path) => {
selectFile(path);
setExpanded(`diff:${path}`, true);
}}
/>
</div>
<div className="flex-[3] min-h-0 overflow-hidden">
<GitPanelContainer
selectedWorkspace={selectedWorkspace}
repos={repos}
diffs={diffs}
/>
</div>
</div>
);
}
if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.LOGS) {
return (
<div className="flex flex-col h-full">
<div className="flex-[7] min-h-0 overflow-hidden">
<ProcessListContainer />
</div>
<div className="flex-[3] min-h-0 overflow-hidden">
<GitPanelContainer
selectedWorkspace={selectedWorkspace}
repos={repos}
diffs={diffs}
/>
</div>
</div>
);
}
if (rightMainPanelMode === RIGHT_MAIN_PANEL_MODES.PREVIEW) {
return (
<div className="flex flex-col h-full">
<div className="flex-[7] min-h-0 overflow-hidden">
<PreviewControlsContainer
attemptId={selectedWorkspace?.id}
onViewProcessInPanel={viewProcessInPanel}
/>
</div>
<div className="flex-[3] min-h-0 overflow-hidden">
<GitPanelContainer
selectedWorkspace={selectedWorkspace}
repos={repos}
diffs={diffs}
/>
</div>
</div>
);
}
return (
<GitPanelContainer
selectedWorkspace={selectedWorkspace}
repos={repos}
diffs={diffs}
/>
);
}

View File

@@ -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 (
<SessionChatBox
status={status}
onViewCode={handleViewCode}
workspaceId={workspaceId}
projectId={projectId}
editor={{
@@ -621,7 +632,6 @@ export function SessionChatBoxContainer({
filesChanged,
linesAdded,
linesRemoved,
onViewCode,
hasConflicts,
conflictedFilesCount,
}}

File diff suppressed because it is too large Load Diff

View File

@@ -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<HTMLElement>(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,
}}
/>
);
}

View File

@@ -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 (
<WorkspacesSidebar
workspaces={activeWorkspaces}
archivedWorkspaces={archivedWorkspaces}
selectedWorkspaceId={selectedWorkspaceId ?? null}
onSelectWorkspace={selectWorkspace}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onAddWorkspace={navigateToCreate}
onArchiveWorkspace={handleArchiveWorkspace}
onPinWorkspace={handlePinWorkspace}
isCreateMode={isCreateMode}
draftTitle={persistedDraftTitle}
onSelectCreate={navigateToCreate}
showArchive={showArchive}
onShowArchiveChange={setShowArchive}
/>
);
}

View File

@@ -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<HTMLInputElement>(null);
@@ -554,7 +555,7 @@ export function SessionChatBox({
</span>
</span>
)}
<PrimaryButton variant="tertiary" onClick={stats?.onViewCode}>
<PrimaryButton variant="tertiary" onClick={onViewCode}>
<span className="text-sm space-x-half">
<span>
{t('diff.filesChanged', { count: filesChanged })}

View File

@@ -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 (
<div className={cn('text-sm', className)} style={{ maxWidth }}>

View File

@@ -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';

View File

@@ -23,7 +23,6 @@ interface WorkspacesMainProps {
isLoading: boolean;
containerRef: RefObject<HTMLElement | null>;
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}

View File

@@ -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 (
<div className="w-full h-full bg-secondary flex flex-col">
{/* Header + Search */}
@@ -156,29 +154,27 @@ export function WorkspacesSidebar({
</div>
{/* Fixed footer toggle - only show if there are archived workspaces */}
{hasArchivedWorkspaces && (
<div className="border-t border-primary p-base">
<button
onClick={() => onShowArchiveChange?.(!showArchive)}
className="w-full flex items-center gap-base text-sm text-low hover:text-normal transition-colors duration-100"
>
{showArchive ? (
<>
<ArrowLeftIcon className="size-icon-xs" />
<span>{t('common:workspaces.backToActive')}</span>
</>
) : (
<>
<ArchiveIcon className="size-icon-xs" />
<span>{t('common:workspaces.viewArchive')}</span>
<span className="ml-auto text-xs bg-tertiary px-1.5 py-0.5 rounded">
{archivedWorkspaces.length}
</span>
</>
)}
</button>
</div>
)}
<div className="border-t border-primary p-base">
<button
onClick={() => onShowArchiveChange?.(!showArchive)}
className="w-full flex items-center gap-base text-sm text-low hover:text-normal transition-colors duration-100"
>
{showArchive ? (
<>
<ArrowLeftIcon className="size-icon-xs" />
<span>{t('common:workspaces.backToActive')}</span>
</>
) : (
<>
<ArchiveIcon className="size-icon-xs" />
<span>{t('common:workspaces.viewArchive')}</span>
<span className="ml-auto text-xs bg-tertiary px-1.5 py-0.5 rounded">
{archivedWorkspaces.length}
</span>
</>
)}
</button>
</div>
</div>
);
}

View File

@@ -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<string>;
/** Find a diff path matching the given text (supports partial/right-hand match) */
findMatchingDiffPath: (text: string) => string | null;
}
const EMPTY_SET = new Set<string>();
const defaultValue: ChangesViewContextValue = {
selectedFilePath: null,
fileInView: null,
selectFile: () => {},
setFileInView: () => {},
viewFileInChanges: () => {},
diffPaths: EMPTY_SET,
findMatchingDiffPath: () => null,
};
const ChangesViewContext = createContext<ChangesViewContextValue>(defaultValue);
interface ChangesViewProviderProps {
children: React.ReactNode;
}
export function ChangesViewProvider({ children }: ChangesViewProviderProps) {
const { diffPaths } = useWorkspaceContext();
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
const [fileInView, setFileInView] = useState<string | null>(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 (
<ChangesViewContext.Provider value={value}>
{children}
</ChangesViewContext.Provider>
);
}
export function useChangesView(): ChangesViewContextValue {
return useContext(ChangesViewContext);
}

View File

@@ -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<CreateModeContextValue | null>(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<CreateModeContextValue>(
() => ({

View File

@@ -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<string>;
/** Find a diff path matching the given text (supports partial/right-hand match) */
findMatchingDiffPath: (text: string) => string | null;
}
const EMPTY_SET = new Set<string>();
const defaultValue: FileNavigationContextValue = {
viewFileInChanges: () => {},
diffPaths: EMPTY_SET,
findMatchingDiffPath: () => null,
};
const FileNavigationContext =
createContext<FileNavigationContextValue>(defaultValue);
interface FileNavigationProviderProps {
children: React.ReactNode;
viewFileInChanges: (path: string) => void;
diffPaths: Set<string>;
}
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 (
<FileNavigationContext.Provider value={value}>
{children}
</FileNavigationContext.Provider>
);
}
export function useFileNavigation(): FileNavigationContextValue {
return useContext(FileNavigationContext);
}

View File

@@ -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<LogNavigationContextValue>(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 (
<LogNavigationContext.Provider value={value}>
{children}
</LogNavigationContext.Provider>
);
}
export function useLogNavigation(): LogNavigationContextValue {
return useContext(LogNavigationContext);
}

View File

@@ -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<LogsPanelContextValue>(defaultValue);
interface LogsPanelProviderProps {
children: ReactNode;
}
export function LogsPanelProvider({ children }: LogsPanelProviderProps) {
const { rightMainPanelMode, setRightMainPanelMode } = useUiPreferencesStore();
const [logsPanelContent, setLogsPanelContent] =
useState<LogsPanelContent | null>(null);
const [logSearchQuery, setLogSearchQuery] = useState('');
const [logMatchIndices, setLogMatchIndices] = useState<number[]>([]);
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 (
<LogsPanelContext.Provider value={value}>
{children}
</LogsPanelContext.Provider>
);
}
export function useLogsPanel(): LogsPanelContextValue {
return useContext(LogsPanelContext);
}

View File

@@ -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<string>;
/** Aggregate diff statistics */
diffStats: DiffStats;
}
const WorkspaceContext = createContext<WorkspaceContextValue | null>(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,
]
);

View File

@@ -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<LayoutState>()(
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);

View File

@@ -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<string, RepoAction>;
expanded: Record<string, boolean>;
contextBarPosition: ContextBarPosition;
paneSizes: Record<string, number | string>;
collapsedPaths: Record<string, string[]>;
// 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<State>()(
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<State>()(
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);

View File

@@ -366,6 +366,8 @@ pr_status: MergeStatus | null, };
export type WorkspaceSummaryResponse = { summaries: Array<WorkspaceSummary>, };
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<DirectoryEntry>, current_path: string, };