feat(ui): full-height left sidebar with decoupled header layout

Rework the desktop layout so the left sidebar spans the full window
height and reads as a distinct column, separate from the header.

- Persistent top-left controls (sidebar toggle + project actions) as a
  single overlay that no longer migrates between header and sidebar, so
  it stays put while panels animate. Drag regions carve out only under
  the buttons so the strip stays draggable and the buttons clickable.
- Header sits in the content column (bg-background); the divider lives
  on the chat content (border-t) so it doesn't run between header and
  right sidebar. Right sidebar gets a top border + small content padding.
- Drop the rounded chat card and corner masks for flat 1px borders.
- Right sidebar, context panel and git/files views inherit the
  background color instead of bg-sidebar.
- Remove hover background from Changes/Staged rows.
- Mini-chat header: borderless, bg-background.
This commit is contained in:
Bohdan Triapitsyn
2026-06-09 15:51:04 +03:00
parent 25da8493fe
commit f9acf68c8f
13 changed files with 338 additions and 151 deletions
@@ -0,0 +1,77 @@
import React from 'react';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSession } from '@/sync/sync-context';
import type { ProjectRef } from '@/lib/openchamberConfig';
export interface ProjectActionsContext {
projectRef: ProjectRef;
directory: string;
}
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
/**
* Resolves the active project ref + working directory used by
* {@link ProjectActionsButton}. Directory priority mirrors the header:
* worktree → session → draft → project path. A sticky ref keeps the last
* good context so the actions button doesn't flicker during session switches.
*/
export function useProjectActionsContext(): ProjectActionsContext | null {
const activeProject = useProjectsStore((state) => {
if (!state.activeProjectId) {
return null;
}
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
});
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSession = useSession(currentSessionId ?? null);
const worktreePath = useSessionUIStore((state) => {
if (!currentSessionId) return '';
return state.worktreeMetadata.get(currentSessionId)?.path ?? '';
});
const draftDirectory = useSessionUIStore((state) => {
if (!state.newSessionDraft?.open) {
return '';
}
return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
});
const worktreeDirectory = React.useMemo(() => normalize(worktreePath || ''), [worktreePath]);
const sessionDirectory = React.useMemo(() => {
const raw = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
return normalize(raw || '');
}, [currentSession?.directory]);
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory;
const actionDirectory = React.useMemo(
() => normalize(openDirectory || activeProject?.path || ''),
[activeProject?.path, openDirectory],
);
const activeProjectRef = React.useMemo<ProjectRef | null>(() => {
if (!activeProject) {
return null;
}
return { id: activeProject.id, path: activeProject.path };
}, [activeProject]);
const lastContextRef = React.useRef<ProjectActionsContext | null>(null);
React.useEffect(() => {
if (activeProjectRef && actionDirectory) {
lastContextRef.current = { projectRef: activeProjectRef, directory: actionDirectory };
}
}, [actionDirectory, activeProjectRef]);
return React.useMemo(() => {
if (activeProjectRef && actionDirectory) {
return { projectRef: activeProjectRef, directory: actionDirectory };
}
return lastContextRef.current;
}, [activeProjectRef, actionDirectory]);
}