diff --git a/FORK.md b/FORK.md index dee503d3e8d..465d07ed3dc 100644 --- a/FORK.md +++ b/FORK.md @@ -45,5 +45,6 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### UX Changes - Desktop context-menu style is configurable. +- Sidebar environments can be hidden or shown dynamically from the project toolbar. - Threads can be archived with middle click. - Terminal selection has a copy action. diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 40af65fa06e..2b62dae8ae4 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -482,6 +482,20 @@ describe("resolveAdjacentThreadId", () => { direction: "previous", }), ).toBe(threads[2]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: ThreadId.make("hidden-thread"), + direction: "next", + }), + ).toBeNull(); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: ThreadId.make("hidden-thread"), + direction: "previous", + }), + ).toBeNull(); expect( resolveAdjacentThreadId({ threadIds: threads, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 64d1c00a168..fe3260e2cb5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -150,6 +150,7 @@ import { import { Input } from "./ui/input"; import { Menu, + MenuCheckboxItem, MenuGroup, MenuPopup, MenuRadioGroup, @@ -243,6 +244,13 @@ const PROJECT_GROUPING_MODE_LABELS: Record = const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; +interface SidebarEnvironmentVisibilityOption { + environmentId: string; + label: string; + visible: boolean; + projectCount: number; +} + function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); return null; @@ -2716,6 +2724,76 @@ function ProjectSortMenu({ ); } +function EnvironmentVisibilityMenu({ + environments, + hiddenCount, + onVisibilityChange, +}: { + environments: readonly SidebarEnvironmentVisibilityOption[]; + hiddenCount: number; + onVisibilityChange: (environmentId: string, visible: boolean) => void; +}) { + if (environments.length <= 1) { + return null; + } + + const visibleEnvironmentCount = environments.filter((environment) => environment.visible).length; + + return ( + + + + } + > + + + + {hiddenCount > 0 + ? `${hiddenCount} hidden environment${hiddenCount === 1 ? "" : "s"}` + : "Environments"} + + + + +
Environments
+ {environments.map((environment) => { + const isLastVisibleEnvironment = environment.visible && visibleEnvironmentCount <= 1; + return ( + { + if (isLastVisibleEnvironment && checked !== true) { + return; + } + onVisibilityChange(environment.environmentId, checked === true); + }} + > + + {environment.label} + + {environment.projectCount} project{environment.projectCount === 1 ? "" : "s"} + + + + ); + })} +
+
+
+ ); +} + function SortableProjectItem({ projectId, disabled = false, @@ -2857,7 +2935,10 @@ interface SidebarProjectsContentProps { threadSortOrder: SidebarThreadSortOrder; projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; + environmentVisibilityOptions: readonly SidebarEnvironmentVisibilityOption[]; + hiddenEnvironmentCount: number; updateSettings: ReturnType; + setSidebarEnvironmentVisible: (environmentId: string, visible: boolean) => void; openAddProject: () => void; isManualProjectSorting: boolean; projectDnDSensors: ReturnType; @@ -2898,7 +2979,10 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( threadSortOrder, projectGroupingMode, threadPreviewCount, + environmentVisibilityOptions, + hiddenEnvironmentCount, updateSettings, + setSidebarEnvironmentVisible, openAddProject, isManualProjectSorting, projectDnDSensors, @@ -2950,6 +3034,12 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); + const handleEnvironmentVisibilityChange = useCallback( + (environmentId: string, visible: boolean) => { + setSidebarEnvironmentVisible(environmentId, visible); + }, + [setSidebarEnvironmentVisible], + ); return ( @@ -3016,6 +3106,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( onProjectGroupingModeChange={handleProjectGroupingModeChange} onThreadPreviewCountChange={handleThreadPreviewCountChange} /> + store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); + const sidebarEnvironmentHiddenById = useUiStateStore( + (store) => store.sidebarEnvironmentHiddenById, + ); const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const setSidebarEnvironmentVisible = useUiStateStore( + (store) => store.setSidebarEnvironmentVisible, + ); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); @@ -3178,6 +3279,27 @@ export default function Sidebar() { ), [environments], ); + const environmentVisibilityOptions = useMemo(() => { + const projectCountsByEnvironmentId = new Map(); + for (const project of projects) { + projectCountsByEnvironmentId.set( + project.environmentId, + (projectCountsByEnvironmentId.get(project.environmentId) ?? 0) + 1, + ); + } + const canHideSidebarEnvironments = environments.length > 1; + return environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + visible: + !canHideSidebarEnvironments || + sidebarEnvironmentHiddenById[environment.environmentId] !== true, + projectCount: projectCountsByEnvironmentId.get(environment.environmentId) ?? 0, + })); + }, [environments, projects, sidebarEnvironmentHiddenById]); + const hiddenEnvironmentCount = environmentVisibilityOptions.filter( + (environment) => !environment.visible, + ).length; const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -3189,28 +3311,59 @@ export default function Sidebar() { ], }); }, [projectOrder, projects]); + const visibleOrderedProjects = useMemo( + () => + orderedProjects.filter( + (project) => + environments.length <= 1 || sidebarEnvironmentHiddenById[project.environmentId] !== true, + ), + [environments.length, orderedProjects, sidebarEnvironmentHiddenById], + ); + const visibleSidebarThreads = useMemo( + () => + sidebarThreads.filter( + (thread) => + environments.length <= 1 || sidebarEnvironmentHiddenById[thread.environmentId] !== true, + ), + [environments.length, sidebarEnvironmentHiddenById, sidebarThreads], + ); // Build a mapping from physical project key → logical project key for // cross-environment grouping. Projects that share a repositoryIdentity // canonicalKey are treated as one logical project in the sidebar. const physicalToLogicalKey = useMemo(() => { return buildPhysicalToLogicalProjectKeyMap({ - projects: orderedProjects, + projects: visibleOrderedProjects, settings: projectGroupingSettings, }); - }, [orderedProjects, projectGroupingSettings]); + }, [visibleOrderedProjects, projectGroupingSettings]); const projectPhysicalKeyByScopedRef = useMemo( () => new Map( - orderedProjects.map((project) => [ + visibleOrderedProjects.map((project) => [ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), derivePhysicalProjectKey(project), ]), ), - [orderedProjects], + [visibleOrderedProjects], ); const sidebarProjects = useMemo(() => { + return buildSidebarProjectSnapshots({ + projects: visibleOrderedProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + }); + }, [ + environmentLabelById, + desktopLocalEnvironmentIds, + visibleOrderedProjects, + projectGroupingSettings, + primaryEnvironmentId, + ]); + const sidebarProjectsForProjectOrder = useMemo(() => { return buildSidebarProjectSnapshots({ projects: orderedProjects, settings: projectGroupingSettings, @@ -3225,6 +3378,13 @@ export default function Sidebar() { projectGroupingSettings, primaryEnvironmentId, ]); + const sidebarProjectForProjectOrderByKey = useMemo( + () => + new Map( + sidebarProjectsForProjectOrder.map((project) => [project.projectKey, project] as const), + ), + [sidebarProjectsForProjectOrder], + ); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), @@ -3259,7 +3419,7 @@ export default function Sidebar() { // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); - for (const thread of sidebarThreads) { + for (const thread of visibleSidebarThreads) { const physicalKey = projectPhysicalKeyByScopedRef.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), @@ -3273,7 +3433,7 @@ export default function Sidebar() { } } return next; - }, [sidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); + }, [visibleSidebarThreads, physicalToLogicalKey, projectPhysicalKeyByScopedRef]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -3336,8 +3496,8 @@ export default function Sidebar() { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); + const activeProject = sidebarProjectForProjectOrderByKey.get(String(active.id)); + const overProject = sidebarProjectForProjectOrderByKey.get(String(over.id)); if (!activeProject || !overProject) return; const activeMemberKeys = activeProject.memberProjects.map( (member) => member.physicalProjectKey, @@ -3345,7 +3505,7 @@ export default function Sidebar() { const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); }, - [orderedProjects, sidebarProjectSortOrder, reorderProjects, sidebarProjects], + [orderedProjects, sidebarProjectForProjectOrderByKey, sidebarProjectSortOrder, reorderProjects], ); const handleProjectDragStart = useCallback( @@ -3382,8 +3542,8 @@ export default function Sidebar() { }, []); const visibleThreads = useMemo( - () => sidebarThreads.filter((thread) => thread.archivedAt === null), - [sidebarThreads], + () => visibleSidebarThreads.filter((thread) => thread.archivedAt === null), + [visibleSidebarThreads], ); const sortedProjects = useMemo(() => { const sortableProjects = sidebarProjects.map((project) => ({ @@ -3730,7 +3890,10 @@ export default function Sidebar() { threadSortOrder={sidebarThreadSortOrder} projectGroupingMode={sidebarProjectGroupingMode} threadPreviewCount={sidebarThreadPreviewCount} + environmentVisibilityOptions={environmentVisibilityOptions} + hiddenEnvironmentCount={hiddenEnvironmentCount} updateSettings={updateSettings} + setSidebarEnvironmentVisible={setSidebarEnvironmentVisible} openAddProject={openAddProjectCommandPalette} isManualProjectSorting={isManualProjectSorting} projectDnDSensors={projectDnDSensors} diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 07e27d23f46..ee6a76a3da9 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -28,6 +28,7 @@ function MenuPopup({ alignOffset, side = "bottom", anchor, + collisionAvoidance, ...props }: MenuPrimitive.Popup.Props & { align?: MenuPrimitive.Positioner.Props["align"]; @@ -35,6 +36,7 @@ function MenuPopup({ alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"]; side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; + collisionAvoidance?: MenuPrimitive.Positioner.Props["collisionAvoidance"]; }) { return ( @@ -43,6 +45,7 @@ function MenuPopup({ alignOffset={alignOffset} anchor={anchor} className="z-50" + collisionAvoidance={collisionAvoidance} data-slot="menu-positioner" side={side} sideOffset={sideOffset} diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..99d4b82645f 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -21,6 +21,7 @@ function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, projectOrder: [], + sidebarEnvironmentHiddenById: {}, threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -168,6 +169,7 @@ describe("parsePersistedState", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + sidebarEnvironmentHiddenById: {}, threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, @@ -252,6 +254,7 @@ describe("uiStateStore persistence", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + sidebarEnvironmentHiddenById: {}, threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, @@ -274,6 +277,7 @@ describe("uiStateStore persistence", () => { logical: false, }, projectOrder: ["physical-b", "physical-a"], + sidebarEnvironmentHiddenById: {}, threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..a985437ea9b 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -19,6 +19,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ export interface PersistedUiState { projectExpandedById?: Record; projectOrder?: string[]; + sidebarEnvironmentHiddenById?: Record; threadLastVisitedAtById?: Record; collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; @@ -30,6 +31,7 @@ export interface PersistedUiState { export interface UiProjectState { projectExpandedById: Record; projectOrder: string[]; + sidebarEnvironmentHiddenById: Record; } export interface UiThreadState { @@ -46,6 +48,7 @@ export interface UiState extends UiProjectState, UiThreadState, UiEndpointState const initialState: UiState = { projectExpandedById: {}, projectOrder: [], + sidebarEnvironmentHiddenById: {}, threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, defaultAdvertisedEndpointKey: null, @@ -81,6 +84,17 @@ function sanitizeBooleanRecord(value: unknown): Record { ); } +function sanitizeTrueRecord(value: unknown): Record { + if (!value || typeof value !== "object") { + return {}; + } + return Object.fromEntries( + Object.entries(value).filter( + (entry): entry is [string, true] => entry[0].length > 0 && entry[1] === true, + ), + ); +} + function sanitizeTimestampRecord(value: unknown): Record { if (!value || typeof value !== "object") { return {}; @@ -123,6 +137,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { return { projectExpandedById, projectOrder, + sidebarEnvironmentHiddenById: sanitizeTrueRecord(parsed.sidebarEnvironmentHiddenById), threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, @@ -208,6 +223,7 @@ export function persistState(state: UiState): void { JSON.stringify({ projectExpandedById, projectOrder: state.projectOrder, + sidebarEnvironmentHiddenById: state.sidebarEnvironmentHiddenById, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, threadChangedFilesExpandedById, @@ -411,12 +427,44 @@ export function reorderProjects( }; } +export function setSidebarEnvironmentVisible( + state: UiState, + environmentId: string, + visible: boolean, +): UiState { + if (environmentId.length === 0) { + return state; + } + if (visible) { + if (state.sidebarEnvironmentHiddenById[environmentId] !== true) { + return state; + } + const sidebarEnvironmentHiddenById = { ...state.sidebarEnvironmentHiddenById }; + delete sidebarEnvironmentHiddenById[environmentId]; + return { + ...state, + sidebarEnvironmentHiddenById, + }; + } + if (state.sidebarEnvironmentHiddenById[environmentId] === true) { + return state; + } + return { + ...state, + sidebarEnvironmentHiddenById: { + ...state.sidebarEnvironmentHiddenById, + [environmentId]: true, + }, + }; +} + interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; + setSidebarEnvironmentVisible: (environmentId: string, visible: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], @@ -436,6 +484,8 @@ export const useUiStateStore = create((set) => ({ set((state) => setDefaultAdvertisedEndpointKey(state, key)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), + setSidebarEnvironmentVisible: (environmentId, visible) => + set((state) => setSidebarEnvironmentVisible(state, environmentId, visible)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds),