diff --git a/docs/data-model.md b/docs/data-model.md index fc987d94e..71050c594 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -420,15 +420,16 @@ Single file containing an array of all loop records. Writes are direct (not atom Array of project records. -| Field | Type | Description | -| ------------- | --------------------------- | ---------------------------------------- | -| `projectId` | `string` | Primary key | -| `rootPath` | `string` | Filesystem root of the project | -| `kind` | `"git" \| "non_git"` | | -| `displayName` | `string` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | +| Field | Type | Description | +| ------------- | --------------------------- | -------------------------------------------------------------------------------- | +| `projectId` | `string` | Primary key | +| `rootPath` | `string` | Filesystem root of the project | +| `kind` | `"git" \| "non_git"` | | +| `displayName` | `string` | | +| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad states by moving workspaces from duplicate path-keyed projects onto the canonical project, @@ -455,6 +456,7 @@ Array of workspace records. A workspace is a specific working directory within a | `createdAt` | `string` (ISO 8601) | | | `updatedAt` | `string` (ISO 8601) | | | `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | +| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | > **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. Path-derived grouping keys (e.g. `deriveWorkspaceDirectoryKey`, used at bootstrap to group agents into a workspace) are directory keys, not workspace identity, and must not be persisted or compared as ids. diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index f8df25b51..7d62105a2 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -41,9 +41,10 @@ import { type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarModel } from "@/components/sidebar/sidebar-model"; +import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; -import { type SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useHosts } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -78,6 +79,7 @@ type SidebarTheme = ReturnType["theme"]; interface SidebarSharedProps { theme: SidebarTheme; statusGroups: StatusGroup[]; + pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; projectNamesByKey: Map; @@ -143,6 +145,7 @@ export const LeftSidebar = memo(function LeftSidebar() { isRevalidating, refreshAll, statusGroups, + pinnedGroups, collapsedProjectKeys, toggleProjectCollapsed, groupMode, @@ -240,6 +243,7 @@ export const LeftSidebar = memo(function LeftSidebar() { const sharedProps = { theme, statusGroups, + pinnedGroups, projects, workspaceEntriesByKey, projectNamesByKey, @@ -536,6 +540,7 @@ function SidebarFooter({ function MobileSidebar({ theme, statusGroups, + pinnedGroups, projects, workspaceEntriesByKey, projectNamesByKey, @@ -561,6 +566,7 @@ function MobileSidebar({ handleViewSchedulesNavigate, }: MobileSidebarProps) { const pathname = usePathname(); + const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture(); @@ -620,7 +626,6 @@ function MobileSidebar({ variant="compact" /> - - {isInitialLoad ? ( + {isInitialLoad && !hasActiveHostFilter ? ( ) : ( )} @@ -676,6 +683,7 @@ function MobileSidebar({ function DesktopSidebar({ theme, statusGroups, + pinnedGroups, projects, workspaceEntriesByKey, projectNamesByKey, @@ -700,6 +708,7 @@ function DesktopSidebar({ handleViewSchedules, }: DesktopSidebarProps) { const pathname = usePathname(); + const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const isSessionsActive = pathname.includes("/sessions"); const isSchedulesActive = pathname.includes("/schedules"); const padding = useWindowControlsPadding("sidebar"); @@ -791,9 +800,8 @@ function DesktopSidebar({ /> - - {isInitialLoad ? ( + {isInitialLoad && !hasActiveHostFilter ? ( ) : ( )} @@ -887,6 +897,10 @@ function WorkspacesSectionHeader() { ); } +// Stable element so the sidebar list's listHeaderComponent prop keeps identity across +// renders (WorkspacesSectionHeader takes no props). +const workspacesSectionHeaderElement = ; + // Static styles for Animated.Views — must NOT use Unistyles dynamic theme to // avoid the "Unable to find node on an unmounted component" crash when Unistyles // tries to patch the native node that Reanimated also manages. @@ -912,15 +926,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], - // Align the title with the compact rows' icons and the project icons below - // (listContent + projectRow inner padding both spacing[2]). - paddingLeft: theme.spacing[2] + theme.spacing[2], - // Align the trailing action pill's right edge with the New workspace and - // project row pills (both 8px from the sidebar edge). - paddingRight: theme.spacing[2], - // Less than sidebarHeaderGroup's paddingBottom: the 28px-tall action buttons - // center the title and add their own offset above it, so equal padding reads - // as a larger gap than History's. Trim paddingTop to balance it visually. + // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the + // title lands at spacing[2] left to align with project icons, and the trailing + // pill sits flush with the list edge on the right. + paddingLeft: theme.spacing[2], + paddingRight: 0, paddingTop: theme.spacing[1], paddingBottom: theme.spacing[1], }, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 59b947afe..fbd75bd08 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -52,6 +52,12 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; +import { + useSidebarWorkspacePinController, + type ToggleSidebarWorkspacePin, +} from "@/hooks/use-sidebar-workspace-pin"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; @@ -67,6 +73,7 @@ import { type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { @@ -88,6 +95,7 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu"; +import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, @@ -220,6 +228,7 @@ function selectionForSelectedWorkspace( interface SidebarWorkspaceListProps { statusGroups: StatusGroup[]; + pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; projectNamesByKey: Map; @@ -232,6 +241,9 @@ interface SidebarWorkspaceListProps { onWorkspacePress?: () => void; onAddProject?: () => void; listFooterComponent?: ReactElement | null; + // Rendered inside the scroll area, below the Pinned section and above the workspace + // list. Holds the "Workspaces" section header so pinned items sit above it. + listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; } @@ -281,6 +293,9 @@ interface WorkspaceRowInnerProps { onRename?: () => void; onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; + isPinned?: boolean; + onTogglePin?: () => void; + reserveIdleStatusIndicatorSpace?: boolean; } export function PrBadge({ hint }: { hint: PrHint }) { @@ -621,6 +636,8 @@ function WorkspaceRowRightGroup({ onCopyBranchName, onCopyPath, onRename, + isPinned, + onTogglePin, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; @@ -637,6 +654,8 @@ function WorkspaceRowRightGroup({ onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; + isPinned?: boolean; + onTogglePin?: () => void; }) { const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; @@ -674,6 +693,8 @@ function WorkspaceRowRightGroup({ archiveStatus={archiveStatus} archivePendingLabel={archivePendingLabel} archiveShortcutKeys={archiveShortcutKeys} + isPinned={isPinned} + onTogglePin={onTogglePin} /> ) : null} @@ -1318,6 +1339,9 @@ function WorkspaceRowInner({ onCopyPath, onRename, archiveShortcutKeys, + isPinned, + onTogglePin, + reserveIdleStatusIndicatorSpace = true, }: WorkspaceRowInnerProps) { const _isCompact = useIsCompactFormFactor(); const isTouchPlatform = platformIsNative; @@ -1388,6 +1412,7 @@ function WorkspaceRowInner({ isCreating={isCreating} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} + reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} > @@ -1425,6 +1452,9 @@ function WorkspaceRowWithMenu({ isDragging, dragHandleProps, canCopyBranchName, + canPin, + onToggleWorkspacePin, + reserveIdleStatusIndicatorSpace = true, isCreating = false, }: { workspace: SidebarWorkspaceEntry; @@ -1437,6 +1467,9 @@ function WorkspaceRowWithMenu({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; + canPin: boolean; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; + reserveIdleStatusIndicatorSpace?: boolean; isCreating?: boolean; }) { const { t } = useTranslation(); @@ -1521,6 +1554,12 @@ function WorkspaceRowWithMenu({ [renameMutation], ); + const isPinned = workspace.pinnedAt != null; + const handleTogglePin = useCallback(() => { + onToggleWorkspacePin(workspace); + }, [onToggleWorkspacePin, workspace]); + const onTogglePin = canPin ? handleTogglePin : undefined; + const archiveShortcutKeys = useShortcutKeys("archive-workspace"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, @@ -1543,6 +1582,17 @@ function WorkspaceRowWithMenu({ }, }); + useKeyboardActionHandler({ + handlerId: `workspace-pin-${workspace.workspaceKey}`, + actions: ["workspace.pin"], + enabled: selected && canPin, + priority: 0, + handle: () => { + onTogglePin?.(); + return true; + }, + }); + return ( <> ); @@ -1746,6 +1820,8 @@ function ProjectBlock({ hostLabelByServerId, showHostLabels, supportsMultiplicityByServerId, + supportsPinningByServerId, + onToggleWorkspacePin, }: { project: SidebarProjectEntry; workspaceEntriesByKey: ReadonlyMap; @@ -1769,6 +1845,8 @@ function ProjectBlock({ hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; supportsMultiplicityByServerId: ReadonlyMap; + supportsPinningByServerId: ReadonlyMap; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const rowModel = useMemo( () => @@ -1805,6 +1883,8 @@ function ProjectBlock({ shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} canCopyBranchName={project.projectKind === "git"} + canPin={supportsPinningByServerId.get(item.serverId) === true} + onToggleWorkspacePin={onToggleWorkspacePin} isCreating={creatingWorkspaceIds.has(item.workspaceId)} selectionEnabled={selectionEnabled} activeWorkspaceSelection={activeWorkspaceSelection} @@ -1817,6 +1897,8 @@ function ProjectBlock({ }, [ project.projectKind, + onToggleWorkspacePin, + supportsPinningByServerId, showHostLabels, activeWorkspaceSelection, creatingWorkspaceIds, @@ -1990,6 +2072,8 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo previous.hostLabelByServerId === next.hostLabelByServerId && previous.showHostLabels === next.showHostLabels && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && + previous.supportsPinningByServerId === next.supportsPinningByServerId && + previous.onToggleWorkspacePin === next.onToggleWorkspacePin && previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && @@ -2034,6 +2118,7 @@ const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual); export function SidebarWorkspaceList({ statusGroups, + pinnedGroups, projects, workspaceEntriesByKey, projectNamesByKey, @@ -2046,6 +2131,7 @@ export function SidebarWorkspaceList({ onWorkspacePress, onAddProject, listFooterComponent, + listHeaderComponent, parentGestureRef, }: SidebarWorkspaceListProps) { const pathname = usePathname(); @@ -2059,21 +2145,29 @@ export function SidebarWorkspaceList({ }, [hosts]); const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); + const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); + const onToggleWorkspacePin = useSidebarWorkspacePinController(); const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]); const content = groupMode === "status" ? ( ) : ( ); @@ -2094,36 +2191,54 @@ export function SidebarWorkspaceList({ function SidebarStatusModeWrapper({ statusGroups, + pinnedGroups, + workspaceEntriesByKey, projectNamesByKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, hostLabelByServerId, showHostLabels, + supportsPinningByServerId, + onToggleWorkspacePin, + listHeaderComponent, }: { statusGroups: StatusGroup[]; + pinnedGroups: PinnedSidebarGroups; + workspaceEntriesByKey: ReadonlyMap; projectNamesByKey: Map; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; + supportsPinningByServerId: ReadonlyMap; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; + listHeaderComponent?: ReactElement | null; }) { const showShortcutBadges = useShowShortcutBadges(); return ( { + const entry = workspaceEntriesByKey.get(workspace.workspaceKey); + return entry ? [entry] : []; + })} projectNamesByKey={projectNamesByKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} hostLabelByServerId={hostLabelByServerId} showHostLabels={showHostLabels} + supportsPinningByServerId={supportsPinningByServerId} + onToggleWorkspacePin={onToggleWorkspacePin} + listHeaderComponent={listHeaderComponent} /> ); } function ProjectModeList({ projects, + pinnedGroups, workspaceEntriesByKey, collapsedProjectKeys, onToggleProjectCollapsed, @@ -2131,11 +2246,14 @@ function ProjectModeList({ onWorkspacePress, onAddProject, listFooterComponent, + listHeaderComponent, parentGestureRef, pathname, hostLabelByServerId, showHostLabels, supportsMultiplicityByServerId, + supportsPinningByServerId, + onToggleWorkspacePin, }: Omit< SidebarWorkspaceListProps, "statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" @@ -2144,13 +2262,20 @@ function ProjectModeList({ hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; supportsMultiplicityByServerId: ReadonlyMap; + supportsPinningByServerId: ReadonlyMap; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { + const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const { t } = useTranslation(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), ); const showShortcutBadges = useShowShortcutBadges(); + const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); + const togglePinnedCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.togglePinnedCollapsed, + ); const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); @@ -2163,6 +2288,7 @@ function ProjectModeList({ ); const selectionEnabled = isWorkspaceRoute; const activeWorkspaceSelection = useActiveWorkspaceSelection(); + const { pinnedChats, unpinnedProjects } = pinnedGroups; const projectIconTargets = useMemo( () => projects.flatMap((project) => { @@ -2308,10 +2434,18 @@ function ProjectModeList({ ); }, []); - const renderProject = useCallback( - ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => { + const renderProjectBlock = useCallback( + ( + item: SidebarProjectEntry, + dragState: { + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableRenderItemInfo["dragHandleProps"]; + }, + ) => { return ( ); }, @@ -2345,6 +2481,8 @@ function ProjectModeList({ hostLabelByServerId, showHostLabels, supportsMultiplicityByServerId, + supportsPinningByServerId, + onToggleWorkspacePin, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, @@ -2357,8 +2495,62 @@ function ProjectModeList({ ], ); + const renderProject = useCallback( + ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => + renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }), + [renderProjectBlock], + ); + + const renderPinnedChat = useCallback( + (workspace: SidebarWorkspacePlacement) => { + // A hoisted chat loses its project context, so surface the project name (plus + // host when the sidebar spans multiple hosts) as the subtitle. + const hostLabel = showHostLabels + ? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId) + : null; + return ( + + ); + }, + [ + activeWorkspaceSelection, + creatingWorkspaceIds, + hostLabelByServerId, + onWorkspacePress, + selectionEnabled, + shortcutIndexByWorkspaceKey, + showHostLabels, + showShortcutBadges, + supportsPinningByServerId, + onToggleWorkspacePin, + workspaceEntriesByKey, + ], + ); + const content = ( <> + {pinnedChats.length > 0 ? ( + + + {pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)} + + ) : null} + {unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null} {projects.length === 0 ? ( @@ -2372,7 +2564,7 @@ function ProjectModeList({ ) : ( ({ projectListContainer: { width: "100%", }, + pinnedSection: { + marginBottom: theme.spacing[1], + }, projectBlock: { marginBottom: theme.spacing[1], }, diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx new file mode 100644 index 000000000..1ca29421b --- /dev/null +++ b/packages/app/src/components/sidebar/pinned-section-header.tsx @@ -0,0 +1,63 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { Pressable, Text } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import type { Theme } from "@/styles/theme"; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedChevronRight = withUnistyles(ChevronRight); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +export function PinnedSectionHeader({ + collapsed, + onToggle, +}: { + collapsed: boolean; + onToggle: () => void; +}) { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown; + + return ( + + {({ hovered }) => ( + <> + {t("sidebar.pinned.title")} + {hovered || isNative || isCompact ? ( + + ) : null} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + userSelect: "none", + }, + title: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index 8a01652dd..101cc5572 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -5,25 +5,25 @@ import { type SidebarWorkspacesListResult, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarWorkspaceEntries } from "@/hooks/use-sidebar-workspace-entries"; -import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model"; +import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; +import { usePinnedSidebarKeys, type PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; -import { - buildSidebarShortcutModel, - buildStatusSidebarShortcutModel, - type SidebarShortcutModel, -} from "@/utils/sidebar-shortcuts"; +import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts"; +import { buildSidebarProjection } from "./sidebar-projection"; interface SidebarModel extends SidebarWorkspacesListResult { workspaceEntriesByKey: ReadonlyMap; groupMode: SidebarGroupMode; statusGroups: StatusGroup[]; + pinnedGroups: PinnedSidebarGroups; collapsedProjectKeys: ReadonlySet; toggleProjectCollapsed: (projectKey: string) => void; shortcutModel: SidebarShortcutModel; } const SidebarModelContext = createContext(null); +const EMPTY_WORKSPACE_ENTRIES = new Map(); export function SidebarModelProvider({ active, @@ -40,6 +40,7 @@ export function SidebarModelProvider({ const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, ); + const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); const toggleProjectCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleProjectCollapsed, ); @@ -48,38 +49,49 @@ export function SidebarModelProvider({ list.workspacePlacements, active !== false || isStatusMode, ); - const statusGroups = useMemo( + const projectionWorkspaceEntriesByKey = isStatusMode + ? workspaceEntriesByKey + : EMPTY_WORKSPACE_ENTRIES; + const pinnedKeys = usePinnedSidebarKeys(list.projects); + const projection = useMemo( () => - isStatusMode - ? buildStatusGroups(Array.from(workspaceEntriesByKey.values()), list.projectNamesByKey) - : [], - [isStatusMode, list.projectNamesByKey, workspaceEntriesByKey], - ); - const shortcutModel = useMemo(() => { - if (isStatusMode) { - return buildStatusSidebarShortcutModel({ - groups: statusGroups, + buildSidebarProjection({ + projects: list.projects, + pinnedKeys, + workspaceEntriesByKey: projectionWorkspaceEntriesByKey, + projectNamesByKey: list.projectNamesByKey, + groupMode, + pinnedCollapsed, + collapsedProjectKeys, collapsedStatusGroupKeys, - }); - } - return buildSidebarShortcutModel({ projects: list.projects, collapsedProjectKeys }); - }, [collapsedProjectKeys, collapsedStatusGroupKeys, isStatusMode, list.projects, statusGroups]); + }), + [ + collapsedProjectKeys, + collapsedStatusGroupKeys, + groupMode, + list.projectNamesByKey, + list.projects, + pinnedCollapsed, + pinnedKeys, + projectionWorkspaceEntriesByKey, + ], + ); const value = useMemo( () => ({ ...list, workspaceEntriesByKey, groupMode, - statusGroups, + statusGroups: projection.statusGroups, + pinnedGroups: projection.pinnedGroups, collapsedProjectKeys, toggleProjectCollapsed, - shortcutModel, + shortcutModel: projection.shortcutModel, }), [ collapsedProjectKeys, groupMode, list, - shortcutModel, - statusGroups, + projection, toggleProjectCollapsed, workspaceEntriesByKey, ], diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts new file mode 100644 index 000000000..a7ab96486 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import type { + SidebarProjectEntry, + SidebarWorkspaceEntry, + SidebarWorkspacePlacement, +} from "@/hooks/use-sidebar-workspaces-list"; +import { buildSidebarProjection } from "./sidebar-projection"; + +function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBucket"] = "done") { + const placement: SidebarWorkspacePlacement = { + workspaceKey: `srv:${id}`, + serverId: "srv", + workspaceId: id, + projectKey: "project", + projectName: "Project", + projectKind: "git", + workspaceKind: "worktree", + name: id, + }; + const entry: SidebarWorkspaceEntry = { + ...placement, + title: null, + currentBranch: null, + statusBucket, + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + prHint: null, + archiveHasUncommittedChanges: null, + archiveUnpushedCommitCount: null, + scripts: [], + hasRunningScripts: false, + }; + return { placement, entry }; +} + +function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { + return { + projectKey: "project", + projectName: "Project", + projectKind: "git", + iconWorkingDir: "/repo", + hosts: [ + { + serverId: "srv", + iconWorkingDir: "/repo", + canCreateWorktree: true, + }, + ], + workspaces, + }; +} + +function projectionInput(options?: { + groupMode?: "project" | "status"; + pinnedCollapsed?: boolean; +}) { + const pinned = makeWorkspace("pinned", "running"); + const unpinned = makeWorkspace("unpinned", "needs_input"); + return { + projects: [makeProject([pinned.placement, unpinned.placement])], + pinnedKeys: { + pinnedWorkspaceKeys: [pinned.placement.workspaceKey], + pinnedAtByKey: { [pinned.placement.workspaceKey]: "2026-07-12T12:00:00.000Z" }, + }, + workspaceEntriesByKey: new Map([ + [pinned.entry.workspaceKey, pinned.entry], + [unpinned.entry.workspaceKey, unpinned.entry], + ]), + projectNamesByKey: new Map([["project", "Project"]]), + groupMode: options?.groupMode ?? ("project" as const), + pinnedCollapsed: options?.pinnedCollapsed ?? false, + collapsedProjectKeys: new Set(), + collapsedStatusGroupKeys: new Set(), + }; +} + +describe("buildSidebarProjection", () => { + it("uses one pin-aware projection for project rows and shortcut order", () => { + const projection = buildSidebarProjection(projectionInput()); + + expect(projection.pinnedGroups.pinnedChats.map((entry) => entry.workspaceId)).toEqual([ + "pinned", + ]); + const remainingProject = projection.pinnedGroups.unpinnedProjects[0]; + expect(remainingProject?.workspaces.map((entry) => entry.workspaceId)).toEqual(["unpinned"]); + expect(projection.shortcutModel.shortcutTargets).toEqual([ + { serverId: "srv", workspaceId: "pinned" }, + { serverId: "srv", workspaceId: "unpinned" }, + ]); + }); + + it("keeps pinned chats above status groups and removes them from those groups", () => { + const projection = buildSidebarProjection(projectionInput({ groupMode: "status" })); + + expect(projection.statusGroups.map((group) => group.bucket)).toEqual(["needs_input"]); + expect(projection.statusGroups[0]?.rows.map((entry) => entry.workspaceId)).toEqual([ + "unpinned", + ]); + expect(projection.shortcutModel.shortcutTargets).toEqual([ + { serverId: "srv", workspaceId: "pinned" }, + { serverId: "srv", workspaceId: "unpinned" }, + ]); + }); + + it("does not number pinned chats while the pinned section is collapsed", () => { + const projection = buildSidebarProjection( + projectionInput({ groupMode: "status", pinnedCollapsed: true }), + ); + + expect(projection.shortcutModel.shortcutTargets).toEqual([ + { serverId: "srv", workspaceId: "unpinned" }, + ]); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts new file mode 100644 index 000000000..641fb7e2a --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -0,0 +1,74 @@ +import { buildStatusGroups, type StatusGroup } from "@/hooks/sidebar-status-view-model"; +import { + splitPinnedSidebarGroups, + type PinnedSidebarGroups, + type PinnedSidebarKeys, +} from "@/hooks/use-sidebar-pins"; +import type { + SidebarProjectEntry, + SidebarWorkspaceEntry, +} from "@/hooks/use-sidebar-workspaces-list"; +import type { SidebarGroupMode } from "@/stores/sidebar-view-store"; +import { + buildSidebarShortcutSections, + type SidebarShortcutModel, + type SidebarShortcutSection, +} from "@/utils/sidebar-shortcuts"; + +export interface SidebarProjection { + pinnedGroups: PinnedSidebarGroups; + statusGroups: StatusGroup[]; + shortcutModel: SidebarShortcutModel; +} + +export function buildSidebarProjection(input: { + projects: SidebarProjectEntry[]; + pinnedKeys: PinnedSidebarKeys; + workspaceEntriesByKey: ReadonlyMap; + projectNamesByKey: Map; + groupMode: SidebarGroupMode; + pinnedCollapsed: boolean; + collapsedProjectKeys: ReadonlySet; + collapsedStatusGroupKeys: ReadonlySet; +}): SidebarProjection { + const pinnedGroups = splitPinnedSidebarGroups({ + projects: input.projects, + keys: input.pinnedKeys, + }); + const pinnedWorkspaceKeys = new Set(input.pinnedKeys.pinnedWorkspaceKeys); + const statusGroups = + input.groupMode === "status" + ? buildStatusGroups( + Array.from(input.workspaceEntriesByKey.values()).filter( + (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), + ), + input.projectNamesByKey, + ) + : []; + + const sections: SidebarShortcutSection[] = []; + if (!input.pinnedCollapsed) { + sections.push({ workspaces: pinnedGroups.pinnedChats }); + } + if (input.groupMode === "status") { + sections.push( + ...statusGroups.map((group) => ({ + workspaces: group.rows, + collapsed: input.collapsedStatusGroupKeys.has(group.bucket), + })), + ); + } else { + sections.push( + ...pinnedGroups.unpinnedProjects.map((project) => ({ + workspaces: project.workspaces, + collapsed: input.collapsedProjectKeys.has(project.projectKey), + })), + ); + } + + return { + pinnedGroups, + statusGroups, + shortcutModel: buildSidebarShortcutSections({ sections }), + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index dd677aa00..9ec840dd2 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from "react"; +import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; @@ -41,6 +41,8 @@ import { } from "@/components/sidebar/sidebar-workspace-row-content"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu"; +import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; +import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin"; // Themed icon wrappers const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -59,28 +61,82 @@ const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleX = withUnistyles(CircleX); interface StatusWorkspaceListProps { groups: StatusGroup[]; + pinnedWorkspaces: SidebarWorkspaceEntry[]; projectNamesByKey: Map; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; + supportsPinningByServerId: ReadonlyMap; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; + listHeaderComponent?: ReactNode; } export function SidebarStatusWorkspaceList({ groups, + pinnedWorkspaces, projectNamesByKey, shortcutIndexByWorkspaceKey, showShortcutBadges, onWorkspacePress, hostLabelByServerId, showHostLabels, + supportsPinningByServerId, + onToggleWorkspacePin, + listHeaderComponent, }: StatusWorkspaceListProps) { const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, ); + const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); + const togglePinnedCollapsed = useSidebarCollapsedSectionsStore( + (state) => state.togglePinnedCollapsed, + ); const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map(); + const content = ( + <> + {pinnedWorkspaces.length > 0 ? ( + + + {pinnedCollapsed + ? null + : pinnedWorkspaces.map((workspace) => ( + + ))} + + ) : null} + {listHeaderComponent} + + + ); return ( @@ -91,16 +147,7 @@ export function SidebarStatusWorkspaceList({ showsVerticalScrollIndicator={false} testID="sidebar-status-list-scroll" > - + {content} ) : ( - + {content} )} @@ -134,6 +172,8 @@ function StatusGroupList({ onWorkspacePress, hostLabelByServerId, showHostLabels, + supportsPinningByServerId, + onToggleWorkspacePin, }: { groups: StatusGroup[]; collapsedStatusGroupKeys: ReadonlySet; @@ -143,6 +183,8 @@ function StatusGroupList({ onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; + supportsPinningByServerId: ReadonlyMap; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { return ( <> @@ -166,6 +208,8 @@ function StatusGroupList({ })} shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} + canPin={supportsPinningByServerId.get(workspace.serverId) === true} + onToggleWorkspacePin={onToggleWorkspacePin} onWorkspacePress={onWorkspacePress} /> ))} @@ -279,12 +323,18 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ subtitle, shortcutNumber, showShortcutBadge, + canPin, + onToggleWorkspacePin, + reserveIdleStatusIndicatorSpace = true, onWorkspacePress, }: { workspace: SidebarWorkspaceEntry; subtitle: string; shortcutNumber: number | null; showShortcutBadge: boolean; + canPin: boolean; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; + reserveIdleStatusIndicatorSpace?: boolean; onWorkspacePress?: () => void; }) { const activeWorkspaceSelection = useActiveWorkspaceSelection(); @@ -305,6 +355,9 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ selected={selected} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} + canPin={canPin} + onToggleWorkspacePin={onToggleWorkspacePin} + reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} onPress={handlePress} /> ); @@ -316,6 +369,9 @@ function StatusWorkspaceRowWithMenu({ selected, shortcutNumber, showShortcutBadge, + canPin, + onToggleWorkspacePin, + reserveIdleStatusIndicatorSpace = true, onPress, }: { workspace: SidebarWorkspaceEntry; @@ -323,6 +379,9 @@ function StatusWorkspaceRowWithMenu({ selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; + canPin: boolean; + onToggleWorkspacePin: ToggleSidebarWorkspacePin; + reserveIdleStatusIndicatorSpace?: boolean; onPress: () => void; }) { const { t } = useTranslation(); @@ -392,6 +451,11 @@ function StatusWorkspaceRowWithMenu({ }, [renameMutation], ); + const isPinned = workspace.pinnedAt != null; + const handleTogglePin = useCallback(() => { + onToggleWorkspacePin(workspace); + }, [onToggleWorkspacePin, workspace]); + const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ @@ -415,6 +479,17 @@ function StatusWorkspaceRowWithMenu({ }, }); + useKeyboardActionHandler({ + handlerId: `workspace-pin-${workspace.workspaceKey}`, + actions: ["workspace.pin"], + enabled: selected && canPin, + priority: 0, + handle: () => { + onTogglePin?.(); + return true; + }, + }); + return ( <> void; onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; + isPinned?: boolean; + onTogglePin?: () => void; + reserveIdleStatusIndicatorSpace?: boolean; }) { const isTouchPlatform = platformIsNative; @@ -524,12 +608,15 @@ function StatusWorkspaceRowInner({ isLoading={isArchiving} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} + reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace} > {shouldRenderActionSlot ? ( void; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; @@ -587,8 +678,8 @@ function StatusWorkspaceActionSlot({ /> ) : null} - - {onArchive ? ( + + {showKebab && onArchive ? ( ) : null} @@ -633,6 +726,9 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, + pinnedSection: { + marginBottom: theme.spacing[1], + }, statusGroupBlock: { marginBottom: theme.spacing[1], }, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index 5e16ea688..e04fc7ab6 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { Archive, CircleCheck, Copy, MoreVertical, Pencil } from "lucide-react-native"; +import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native"; import { isNative, isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -24,6 +24,8 @@ const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); const ThemedPencil = withUnistyles(Pencil); const ThemedCircleCheck = withUnistyles(CircleCheck); +const ThemedPin = withUnistyles(Pin); +const ThemedPinOff = withUnistyles(PinOff); const copyLeadingIcon = ; const renameLeadingIcon = ; @@ -31,6 +33,8 @@ const markAsReadLeadingIcon = ( ); const archiveLeadingIcon = ; +const pinLeadingIcon = ; +const unpinLeadingIcon = ; function renderTriggerIcon({ hovered }: { hovered?: boolean }) { return ( @@ -52,6 +56,8 @@ interface SidebarWorkspaceMenuProps { archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; + isPinned?: boolean; + onTogglePin?: () => void; } export function SidebarWorkspaceMenu({ @@ -65,6 +71,8 @@ export function SidebarWorkspaceMenu({ archiveStatus, archivePendingLabel, archiveShortcutKeys, + isPinned, + onTogglePin, }: SidebarWorkspaceMenuProps) { const { t } = useTranslation(); const archiveTrailing = useMemo( @@ -120,6 +128,15 @@ export function SidebarWorkspaceMenu({ Mark as read ) : null} + {onTogglePin ? ( + + {isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")} + + ) : null} @@ -186,10 +190,12 @@ function WorkspaceStatusIndicator({ bucket, workspaceKind, loading = false, + reserveIdleSpace = true, }: { bucket: SidebarWorkspaceEntry["statusBucket"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; + reserveIdleSpace?: boolean; }) { const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); @@ -226,7 +232,9 @@ function WorkspaceStatusIndicator({ } if (bucket === "done") { - return ; + return reserveIdleSpace ? ( + + ) : null; } let KindIcon: typeof ThemedMonitor; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 85378c695..c44548d3a 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -37,6 +37,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { // Raw user-set title (null when the name is derived from branch/directory). // Prefills the rename input and signals whether a reset is available. title: string | null; + pinnedAt?: string | null; // Checkout branch (null when not a git checkout or detached HEAD). currentBranch: string | null; archivingAt: string | null; @@ -157,6 +158,7 @@ export function createSidebarWorkspaceEntry(input: { workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, title: input.workspace.title ?? null, + pinnedAt: input.workspace.pinnedAt, currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch), statusBucket: effectiveStatus.status, statusEnteredAt: effectiveStatus.enteredAt, diff --git a/packages/app/src/hooks/use-sidebar-pins.test.ts b/packages/app/src/hooks/use-sidebar-pins.test.ts new file mode 100644 index 000000000..3c2962314 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-pins.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { + SidebarProjectEntry, + SidebarWorkspacePlacement, +} from "@/hooks/sidebar-workspaces-view-model"; +import { splitPinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; + +function placement(workspaceKey: string): SidebarWorkspacePlacement { + return { + workspaceKey, + serverId: "s1", + workspaceId: workspaceKey, + projectKey: "p1", + projectName: "Project 1", + projectKind: "git", + workspaceKind: "worktree", + name: workspaceKey, + }; +} + +function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { + return { + projectKey, + projectName: projectKey, + projectKind: "git", + iconWorkingDir: "", + hosts: [], + workspaces, + }; +} + +describe("splitPinnedSidebarGroups", () => { + it("drops the empty shell when every chat of a project is pinned", () => { + const only = placement("w1"); + const projects = [project("p1", [only])]; + const result = splitPinnedSidebarGroups({ + projects, + keys: { + pinnedWorkspaceKeys: ["w1"], + pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, + }, + }); + expect(result.pinnedChats).toHaveLength(1); + expect(result.unpinnedProjects).toHaveLength(0); + }); + + it("keeps a genuinely empty project so its new-workspace row stays reachable", () => { + const projects = [project("p1", [])]; + const result = splitPinnedSidebarGroups({ + projects, + keys: { pinnedWorkspaceKeys: [], pinnedAtByKey: {} }, + }); + expect(result.unpinnedProjects).toHaveLength(1); + }); + + it("keeps remaining chats when only some are pinned", () => { + const projects = [project("p1", [placement("w1"), placement("w2")])]; + const result = splitPinnedSidebarGroups({ + projects, + keys: { + pinnedWorkspaceKeys: ["w1"], + pinnedAtByKey: { w1: "2026-01-01T00:00:00Z" }, + }, + }); + expect(result.pinnedChats.map((w) => w.workspaceKey)).toEqual(["w1"]); + expect(result.unpinnedProjects[0]?.workspaces.map((w) => w.workspaceKey)).toEqual(["w2"]); + }); + + it("orders pinned chats by most-recently-pinned first", () => { + const projects = [project("p1", [placement("older"), placement("newer")])]; + const result = splitPinnedSidebarGroups({ + projects, + keys: { + pinnedWorkspaceKeys: ["older", "newer"], + pinnedAtByKey: { + older: "2026-01-01T00:00:00Z", + newer: "2026-02-01T00:00:00Z", + }, + }, + }); + + expect(result.pinnedChats.map((workspace) => workspace.workspaceKey)).toEqual([ + "newer", + "older", + ]); + }); +}); diff --git a/packages/app/src/hooks/use-sidebar-pins.ts b/packages/app/src/hooks/use-sidebar-pins.ts new file mode 100644 index 000000000..37524b246 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -0,0 +1,142 @@ +import { useMemo, useRef } from "react"; +import { shallow } from "zustand/shallow"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import type { + SidebarProjectEntry, + SidebarWorkspacePlacement, +} from "@/hooks/use-sidebar-workspaces-list"; +import { useSessionStore } from "@/stores/session-store"; + +export interface PinnedSidebarKeys { + pinnedWorkspaceKeys: string[]; + // workspaceKey -> pinnedAt ISO string, used to order by recency. + pinnedAtByKey: Record; +} + +export interface PinnedSidebarGroups { + // Individually pinned chats, hoisted into the Pinned section and removed from their + // project below. Most recently pinned first. + pinnedChats: SidebarWorkspacePlacement[]; + // Everything else, with pinned chats removed. Feeds the draggable project list. + unpinnedProjects: SidebarProjectEntry[]; +} + +function buildPinnedSidebarKeys( + projects: SidebarProjectEntry[], + workspaceMaps: ReadonlyMap>, +): PinnedSidebarKeys { + const pinnedWorkspaceKeys: string[] = []; + const pinnedAtByKey: Record = {}; + + for (const project of projects) { + for (const placement of project.workspaces) { + const workspace = workspaceMaps.get(placement.serverId)?.get(placement.workspaceId); + if (workspace?.pinnedAt) { + pinnedWorkspaceKeys.push(placement.workspaceKey); + pinnedAtByKey[placement.workspaceKey] = workspace.pinnedAt; + } + } + } + return { pinnedWorkspaceKeys, pinnedAtByKey }; +} + +function arePinnedSidebarKeysEqual(left: PinnedSidebarKeys, right: PinnedSidebarKeys): boolean { + if (left.pinnedWorkspaceKeys.length !== right.pinnedWorkspaceKeys.length) { + return false; + } + for (let index = 0; index < left.pinnedWorkspaceKeys.length; index += 1) { + const workspaceKey = left.pinnedWorkspaceKeys[index]; + if ( + workspaceKey !== right.pinnedWorkspaceKeys[index] || + (workspaceKey && left.pinnedAtByKey[workspaceKey] !== right.pinnedAtByKey[workspaceKey]) + ) { + return false; + } + } + return true; +} + +export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSidebarKeys { + const previousKeysRef = useRef({ + pinnedWorkspaceKeys: [], + pinnedAtByKey: {}, + }); + const serverIds = useMemo( + () => + Array.from( + new Set( + projects.flatMap((project) => project.workspaces.map((workspace) => workspace.serverId)), + ), + ), + [projects], + ); + const workspaceMaps = useStoreWithEqualityFn( + useSessionStore, + (state) => serverIds.map((serverId) => state.sessions[serverId]?.workspaces ?? null), + shallow, + ); + return useMemo(() => { + const workspaceMapByServerId = new Map< + string, + ReadonlyMap + >(); + for (let index = 0; index < serverIds.length; index += 1) { + const serverId = serverIds[index]; + const workspaceMap = workspaceMaps[index]; + if (serverId && workspaceMap) { + workspaceMapByServerId.set(serverId, workspaceMap); + } + } + const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId); + if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) { + return previousKeysRef.current; + } + previousKeysRef.current = nextKeys; + return nextKeys; + }, [projects, serverIds, workspaceMaps]); +} + +// Splits the sidebar into a dedicated Pinned section (chats) and the regular list below. +// Pinned chats are ordered most-recently-pinned first. +export function splitPinnedSidebarGroups(input: { + projects: SidebarProjectEntry[]; + keys: PinnedSidebarKeys; +}): PinnedSidebarGroups { + const { projects, keys } = input; + if (keys.pinnedWorkspaceKeys.length === 0) { + return { pinnedChats: [], unpinnedProjects: projects }; + } + const pinnedWorkspaceKeySet = new Set(keys.pinnedWorkspaceKeys); + const pinnedChats: SidebarWorkspacePlacement[] = []; + const unpinnedProjects: SidebarProjectEntry[] = []; + + for (const project of projects) { + const remainingWorkspaces: SidebarWorkspacePlacement[] = []; + for (const workspace of project.workspaces) { + if (pinnedWorkspaceKeySet.has(workspace.workspaceKey)) { + pinnedChats.push(workspace); + } else { + remainingWorkspaces.push(workspace); + } + } + // Every chat got hoisted into the Pinned section: drop the empty shell instead of + // leaving a duplicate project header below. A genuinely empty project (no chats to + // begin with) is kept so its "new workspace" row stays reachable. + if (remainingWorkspaces.length === 0 && project.workspaces.length > 0) { + continue; + } + unpinnedProjects.push( + remainingWorkspaces.length === project.workspaces.length + ? project + : { ...project, workspaces: remainingWorkspaces }, + ); + } + + pinnedChats.sort((a, b) => + (keys.pinnedAtByKey[b.workspaceKey] ?? "").localeCompare( + keys.pinnedAtByKey[a.workspaceKey] ?? "", + ), + ); + + return { pinnedChats, unpinnedProjects }; +} diff --git a/packages/app/src/hooks/use-sidebar-workspace-pin.ts b/packages/app/src/hooks/use-sidebar-workspace-pin.ts new file mode 100644 index 000000000..2019442fb --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-workspace-pin.ts @@ -0,0 +1,49 @@ +import { useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation } from "@tanstack/react-query"; +import { useToast } from "@/contexts/toast-context"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; + +export type ToggleSidebarWorkspacePin = (workspace: SidebarWorkspaceEntry) => void; + +export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin { + const { t } = useTranslation(); + const toast = useToast(); + const pendingWorkspaceKeysRef = useRef(new Set()); + const mutation = useMutation({ + mutationFn: async ({ + workspace, + pinned, + }: { + workspace: SidebarWorkspaceEntry; + pinned: boolean; + }) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); + } + await client.setWorkspacePinned(workspace.workspaceId, pinned); + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : t("sidebar.workspace.toasts.hostDisconnected"), + ); + }, + onSettled: (_data, _error, { workspace }) => { + pendingWorkspaceKeysRef.current.delete(workspace.workspaceKey); + }, + }); + const mutate = mutation.mutate; + + return useCallback( + (workspace: SidebarWorkspaceEntry) => { + if (pendingWorkspaceKeysRef.current.has(workspace.workspaceKey)) { + return; + } + pendingWorkspaceKeysRef.current.add(workspace.workspaceKey); + mutate({ workspace, pinned: workspace.pinnedAt == null }); + }, + [mutate], + ); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b48585470..ad09ff2ba 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -774,6 +774,9 @@ export const ar: TranslationResources = { }, }, sidebar: { + pinned: { + title: "المثبتة", + }, host: { noHost: "لا مضيف", switchTitle: "تبديل المضيف", @@ -835,6 +838,8 @@ export const ar: TranslationResources = { copyPath: "نسخ المسار", copyBranchName: "انسخ اسم الفرع", rename: "إعادة تسمية مساحة العمل", + pin: "تثبيت في الأعلى", + unpin: "إلغاء التثبيت", archive: "أرشيف", archiveWorkspace: "أرشفة مساحة العمل", hideFromSidebar: "إخفاء من الشريط الجانبي", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index abf7dff6a..7f95dfba0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -781,6 +781,9 @@ export const en = { }, }, sidebar: { + pinned: { + title: "Pinned", + }, host: { noHost: "No host", switchTitle: "Switch host", @@ -842,6 +845,8 @@ export const en = { copyPath: "Copy path", copyBranchName: "Copy branch name", rename: "Rename workspace", + pin: "Pin to top", + unpin: "Unpin", archive: "Archive", archiveWorkspace: "Archive workspace", hideFromSidebar: "Hide from sidebar", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b1ca990c1..5cc22d340 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -801,6 +801,9 @@ export const es: TranslationResources = { }, }, sidebar: { + pinned: { + title: "Anclados", + }, host: { noHost: "Sin anfitrión", switchTitle: "Cambiar de anfitrión", @@ -862,6 +865,8 @@ export const es: TranslationResources = { copyPath: "Copiar ruta", copyBranchName: "Copiar nombre de sucursal", rename: "Cambiar nombre del espacio de trabajo", + pin: "Anclar arriba", + unpin: "Desanclar", archive: "Archivo", archiveWorkspace: "Archivar espacio de trabajo", hideFromSidebar: "Ocultar de la barra lateral", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 614957bc9..496601cb4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -800,6 +800,9 @@ export const fr: TranslationResources = { }, }, sidebar: { + pinned: { + title: "Épinglés", + }, host: { noHost: "Aucun hôte", switchTitle: "Changer d'hôte", @@ -861,6 +864,8 @@ export const fr: TranslationResources = { copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", rename: "Renommer l'espace de travail", + pin: "Épingler en haut", + unpin: "Désépingler", archive: "Archive", archiveWorkspace: "Archiver l’espace de travail", hideFromSidebar: "Masquer de la barre latérale", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index cdd7001ac..c54d6e8d6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -786,6 +786,9 @@ export const ja: TranslationResources = { }, }, sidebar: { + pinned: { + title: "固定済み", + }, host: { noHost: "ホストなし", switchTitle: "ホストを切り替え", @@ -847,6 +850,8 @@ export const ja: TranslationResources = { copyPath: "パスをコピー", copyBranchName: "ブランチ名をコピー", rename: "ワークスペースの名前を変更", + pin: "上部に固定", + unpin: "固定解除", archive: "アーカイブ", archiveWorkspace: "ワークスペースをアーカイブ", hideFromSidebar: "サイドバーから非表示", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f57e7e31b..ccd2973d3 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -792,6 +792,9 @@ export const ptBR: TranslationResources = { }, }, sidebar: { + pinned: { + title: "Fixados", + }, host: { noHost: "Nenhum host", switchTitle: "Trocar host", @@ -853,6 +856,8 @@ export const ptBR: TranslationResources = { copyPath: "Copiar caminho", copyBranchName: "Copiar nome da branch", rename: "Renomear workspace", + pin: "Fixar no topo", + unpin: "Desafixar", archive: "Arquivar", archiveWorkspace: "Arquivar workspace", hideFromSidebar: "Ocultar da barra lateral", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e5868a3ec..5be0232e3 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -793,6 +793,9 @@ export const ru: TranslationResources = { }, }, sidebar: { + pinned: { + title: "Закреплённые", + }, host: { noHost: "Нет хоста", switchTitle: "Сменить хост", @@ -854,6 +857,8 @@ export const ru: TranslationResources = { copyPath: "Копировать путь", copyBranchName: "Скопировать название ветки", rename: "Переименовать рабочую область", + pin: "Закрепить вверху", + unpin: "Открепить", archive: "Архив", archiveWorkspace: "Архивировать рабочее пространство", hideFromSidebar: "Скрыть с боковой панели", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f07bfe2f3..bb34514c9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -769,6 +769,9 @@ export const zhCN: TranslationResources = { }, }, sidebar: { + pinned: { + title: "已置顶", + }, host: { noHost: "没有 Host", switchTitle: "切换 Host", @@ -828,6 +831,8 @@ export const zhCN: TranslationResources = { copyPath: "复制路径", copyBranchName: "复制分支名称", rename: "重命名 workspace", + pin: "置顶", + unpin: "取消置顶", archive: "归档", archiveWorkspace: "归档工作区", hideFromSidebar: "从侧边栏隐藏", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 2a7889ad6..d0246594c 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -45,6 +45,7 @@ export type KeyboardActionId = | "workspace.new" | "worktree.new" | "workspace.archive" + | "workspace.pin" | "view.toggle.focus" | "theme.cycle" | "message-input.action"; diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 61abf5389..926b8d75c 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -29,7 +29,8 @@ export type KeyboardActionId = | "sidebar.toggle.right" | "workspace.new" | "worktree.new" - | "workspace.archive"; + | "workspace.archive" + | "workspace.pin"; export type KeyboardActionDefinition = | { id: "agent.interrupt"; scope: KeyboardActionScope } @@ -60,7 +61,8 @@ export type KeyboardActionDefinition = | { id: "sidebar.toggle.right"; scope: KeyboardActionScope } | { id: "workspace.new"; scope: KeyboardActionScope } | { id: "worktree.new"; scope: KeyboardActionScope } - | { id: "workspace.archive"; scope: KeyboardActionScope }; + | { id: "workspace.archive"; scope: KeyboardActionScope } + | { id: "workspace.pin"; scope: KeyboardActionScope }; export interface KeyboardActionHandler { handlerId: string; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 08dfe7840..e67158475 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -251,6 +251,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + // --- Pin workspace --- + { + id: "workspace-pin-cmd-shift-p-mac", + action: "workspace.pin", + combo: "Cmd+Shift+P", + when: { mac: true, commandCenter: false }, + help: { + id: "pin-workspace", + section: "projects", + label: "Pin chat", + keys: ["mod", "shift", "P"], + }, + }, + { + id: "workspace-pin-ctrl-shift-p-non-mac", + action: "workspace.pin", + combo: "Ctrl+Shift+P", + when: { mac: false, commandCenter: false, terminal: false }, + help: { + id: "pin-workspace", + section: "projects", + label: "Pin chat", + keys: ["mod", "shift", "P"], + }, + }, + // --- Tab management --- { id: "workspace-tab-new-cmd-t-mac", diff --git a/packages/app/src/keyboard/route-shortcut.test.ts b/packages/app/src/keyboard/route-shortcut.test.ts index 3cf5608b0..4fadab02d 100644 --- a/packages/app/src/keyboard/route-shortcut.test.ts +++ b/packages/app/src/keyboard/route-shortcut.test.ts @@ -31,6 +31,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => { ["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }], ["workspace.new", { id: "workspace.new", scope: "sidebar" }], ["workspace.archive", { id: "workspace.archive", scope: "sidebar" }], + ["workspace.pin", { id: "workspace.pin", scope: "sidebar" }], ["worktree.new", { id: "worktree.new", scope: "sidebar" }], ["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }], ["workspace.tab.close.current", { id: "workspace.tab.close-current", scope: "workspace" }], diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts index d771a8758..4f82a7452 100644 --- a/packages/app/src/keyboard/route-shortcut.ts +++ b/packages/app/src/keyboard/route-shortcut.ts @@ -47,6 +47,7 @@ const PASSTHROUGH_DISPATCH: Record = { "workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" }, "workspace.new": { id: "workspace.new", scope: "sidebar" }, "workspace.archive": { id: "workspace.archive", scope: "sidebar" }, + "workspace.pin": { id: "workspace.pin", scope: "sidebar" }, "worktree.new": { id: "worktree.new", scope: "sidebar" }, "workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" }, "workspace.tab.close.current": { id: "workspace.tab.close-current", scope: "workspace" }, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 158478e97..902b7ebee 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -135,6 +135,7 @@ export interface WorkspaceDescriptor { workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; title?: string | null; + pinnedAt?: string | null; status: WorkspaceDescriptorPayload["status"]; statusEnteredAt: Date | null; archivingAt: string | null; @@ -167,6 +168,7 @@ export function normalizeWorkspaceDescriptor( workspaceKind: payload.workspaceKind, name: payload.name, title: payload.title ?? null, + pinnedAt: payload.pinnedAt ?? null, status: payload.status, statusEnteredAt, archivingAt: payload.archivingAt ?? null, diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts index 20363ac87..abda2aac7 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -6,6 +6,7 @@ import { mergePersistedCollapsedProjects, serializeCollapsedProjects, setProjectCollapsed, + togglePinnedCollapsed, toggleProjectCollapsed, toggleStatusGroupCollapsed, } from "./state"; @@ -14,6 +15,7 @@ interface SidebarCollapsedSectionsState extends CollapsedProjectsState { toggleProjectCollapsed: (projectKey: string) => void; setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; toggleStatusGroupCollapsed: (statusGroupKey: string) => void; + togglePinnedCollapsed: () => void; } export const useSidebarCollapsedSectionsStore = create()( @@ -21,12 +23,14 @@ export const useSidebarCollapsedSectionsStore = create ({ collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set(), + collapsedPinned: false, toggleProjectCollapsed: (projectKey) => set((state) => toggleProjectCollapsed(state, projectKey)), setProjectCollapsed: (projectKey, collapsed) => set((state) => setProjectCollapsed(state, projectKey, collapsed)), toggleStatusGroupCollapsed: (statusGroupKey) => set((state) => toggleStatusGroupCollapsed(state, statusGroupKey)), + togglePinnedCollapsed: () => set((state) => togglePinnedCollapsed(state)), }), { name: "sidebar-collapsed-sections", diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts index 0936dfcd8..051d7cc7c 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -4,12 +4,17 @@ import { mergePersistedCollapsedProjects, serializeCollapsedProjects, setProjectCollapsed, + togglePinnedCollapsed, toggleProjectCollapsed, toggleStatusGroupCollapsed, } from "@/stores/sidebar-collapsed-sections-store/state"; function emptyState(): CollapsedProjectsState { - return { collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set() }; + return { + collapsedProjectKeys: new Set(), + collapsedStatusGroupKeys: new Set(), + collapsedPinned: false, + }; } describe("sidebar collapsed projects transitions", () => { @@ -29,14 +34,24 @@ describe("sidebar collapsed projects transitions", () => { const state: CollapsedProjectsState = { collapsedProjectKeys: new Set(["project-a", "project-b"]), collapsedStatusGroupKeys: new Set(["running"]), + collapsedPinned: true, }; expect(serializeCollapsedProjects(state)).toEqual({ collapsedProjectKeys: ["project-a", "project-b"], collapsedStatusGroupKeys: ["running"], + collapsedPinned: true, }); }); + it("toggles and restores the pinned section collapse flag", () => { + const toggled = togglePinnedCollapsed(emptyState()); + expect(toggled.collapsedPinned).toBe(true); + + const restored = mergePersistedCollapsedProjects({ collapsedPinned: true }, emptyState()); + expect(restored.collapsedPinned).toBe(true); + }); + it("restores collapsed project keys from persisted preferences", () => { const restored = mergePersistedCollapsedProjects( { collapsedProjectKeys: ["project-a", "project-b", 42] }, diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts index 877d218fe..3a03ab0ac 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -1,11 +1,17 @@ export interface CollapsedProjectsState { collapsedProjectKeys: Set; collapsedStatusGroupKeys: Set; + collapsedPinned: boolean; } export interface PersistedCollapsedProjects { collapsedProjectKeys?: unknown; collapsedStatusGroupKeys?: unknown; + collapsedPinned?: unknown; +} + +export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState { + return { ...state, collapsedPinned: !state.collapsedPinned }; } export function toggleProjectCollapsed( @@ -51,10 +57,12 @@ export function setProjectCollapsed( export function serializeCollapsedProjects(state: CollapsedProjectsState): { collapsedProjectKeys: string[]; collapsedStatusGroupKeys: string[]; + collapsedPinned: boolean; } { return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys), collapsedStatusGroupKeys: Array.from(state.collapsedStatusGroupKeys), + collapsedPinned: state.collapsedPinned, }; } @@ -62,14 +70,23 @@ export function mergePersistedCollapsedProjects; } +export interface SidebarShortcutSection { + workspaces: readonly SidebarWorkspacePlacement[]; + collapsed?: boolean; +} + function createShortcutTarget( workspace: SidebarWorkspacePlacement, ): SidebarShortcutWorkspaceTarget { @@ -28,44 +33,43 @@ export function buildSidebarShortcutModel(input: { collapsedProjectKeys: ReadonlySet; shortcutLimit?: number; }): SidebarShortcutModel { - const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); - const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []; - const shortcutIndexByWorkspaceKey = new Map(); - - for (const project of input.projects) { - if (input.collapsedProjectKeys.has(project.projectKey)) { - continue; - } - - for (const workspace of project.workspaces) { - if (shortcutTargets.length >= maxShortcuts) { - break; - } - - const shortcutNumber = shortcutTargets.length + 1; - shortcutTargets.push(createShortcutTarget(workspace)); - shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber); - } - } - - return { shortcutTargets, shortcutIndexByWorkspaceKey }; + return buildSidebarShortcutSections({ + sections: input.projects.map((project) => ({ + workspaces: project.workspaces, + collapsed: input.collapsedProjectKeys.has(project.projectKey), + })), + shortcutLimit: input.shortcutLimit, + }); } export function buildStatusSidebarShortcutModel(input: { groups: readonly StatusGroup[]; collapsedStatusGroupKeys?: ReadonlySet; shortcutLimit?: number; +}): SidebarShortcutModel { + return buildSidebarShortcutSections({ + sections: input.groups.map((group) => ({ + workspaces: group.rows, + collapsed: input.collapsedStatusGroupKeys?.has(group.bucket), + })), + shortcutLimit: input.shortcutLimit, + }); +} + +export function buildSidebarShortcutSections(input: { + sections: readonly SidebarShortcutSection[]; + shortcutLimit?: number; }): SidebarShortcutModel { const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); const shortcutTargets: SidebarShortcutWorkspaceTarget[] = []; const shortcutIndexByWorkspaceKey = new Map(); - for (const group of input.groups) { - if (input.collapsedStatusGroupKeys?.has(group.bucket)) { + for (const section of input.sections) { + if (section.collapsed) { continue; } - for (const workspace of group.rows) { + for (const workspace of section.workspaces) { if (shortcutTargets.length >= maxShortcuts) { break; } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 571f9221e..a19be362f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2311,6 +2311,26 @@ export class DaemonClient { return { title: payload.title }; } + async setWorkspacePinned( + workspaceId: string, + pinned: boolean, + requestId?: string, + ): Promise<{ pinnedAt: string | null }> { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "workspace.pin.set.request", + workspaceId, + pinned, + }, + responseType: "workspace.pin.set.response", + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "setWorkspacePinned rejected"); + } + return { pinnedAt: payload.pinnedAt }; + } + async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a1465c92b..74e7c5a3e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -831,6 +831,13 @@ export const WorkspaceTitleSetRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspacePinSetRequestSchema = z.object({ + type: z.literal("workspace.pin.set.request"), + workspaceId: z.string(), + pinned: z.boolean(), + requestId: z.string(), +}); + export const SetVoiceModeMessageSchema = z.object({ type: z.literal("set_voice_mode"), enabled: z.boolean(), @@ -1450,6 +1457,19 @@ export const WorkspaceTitleSetResponseSchema = z.object({ payload: WorkspaceTitleSetResponsePayloadSchema, }); +export const WorkspacePinSetResponsePayloadSchema = z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + pinnedAt: z.string().nullable(), + error: z.string().nullable(), +}); + +export const WorkspacePinSetResponseSchema = z.object({ + type: z.literal("workspace.pin.set.response"), + payload: WorkspacePinSetResponsePayloadSchema, +}); + export const SetVoiceModeResponseMessageSchema = z.object({ type: z.literal("set_voice_mode_response"), payload: z.object({ @@ -2084,6 +2104,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectRenameRequestSchema, ProjectRemoveRequestSchema, WorkspaceTitleSetRequestSchema, + WorkspacePinSetRequestSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, @@ -2397,6 +2418,8 @@ export const ServerInfoStatusPayloadSchema = z agentForkContext: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. + workspacePinning: z.boolean().optional(), }) .optional(), }) @@ -2675,6 +2698,8 @@ export const WorkspaceDescriptorPayloadSchema = z // its input and offer a "reset to branch name" action. Null means the name // is derived from the branch/directory. title: z.string().nullable().optional(), + // COMPAT(workspacePinning): added in v0.1.107, remove optional after 2027-01-12. + pinnedAt: z.string().nullable().optional(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, // Best-effort workspace status entry timestamp. Old daemons omit the @@ -4346,6 +4371,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectRenameResponseSchema, ProjectRemoveResponseSchema, WorkspaceTitleSetResponseSchema, + WorkspacePinSetResponseSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, @@ -4502,6 +4528,8 @@ export type WorkspaceTitleSetResponse = z.infer; +export type WorkspacePinSetResponse = z.infer; +export type WorkspacePinSetResponsePayload = z.infer; export type WorkspaceCreateRequest = z.infer; export type WorkspaceCreateResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; @@ -4634,6 +4662,7 @@ export type UpdateAgentRequestMessage = z.infer; export type ProjectRemoveRequest = z.infer; export type WorkspaceTitleSetRequest = z.infer; +export type WorkspacePinSetRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 53edfa871..1783dcb70 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1673,6 +1673,8 @@ export class Session { return this.handleWorkspaceClearAttentionRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); + case "workspace.pin.set.request": + return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId); case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg); case "project_icon_request": @@ -2293,8 +2295,15 @@ export class Session { ); try { - const existing = await this.workspaceRegistry.get(workspaceId); - if (!existing) { + const trimmed = title?.trim() ?? ""; + const nextTitle = trimmed.length === 0 ? null : trimmed; + const updatedAt = new Date().toISOString(); + const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({ + ...existing, + title: nextTitle, + updatedAt, + })); + if (!updated) { this.emit({ type: "workspace.title.set.response", payload: { @@ -2308,15 +2317,6 @@ export class Session { return; } - const trimmed = title?.trim() ?? ""; - const nextTitle = trimmed.length === 0 ? null : trimmed; - - await this.workspaceRegistry.upsert({ - ...existing, - title: nextTitle, - updatedAt: new Date().toISOString(), - }); - this.emit({ type: "workspace.title.set.response", payload: { @@ -2358,6 +2358,52 @@ export class Session { } } + private async handleWorkspacePinSetRequest( + workspaceId: string, + pinned: boolean, + requestId: string, + ): Promise { + const logContext = { workspaceId, pinned, requestId }; + this.sessionLogger.info(logContext, "session: workspace.pin.set.request"); + const emitResponse = (accepted: boolean, pinnedAt: string | null, error: string | null) => { + this.emit({ + type: "workspace.pin.set.response", + payload: { requestId, workspaceId, accepted, pinnedAt, error }, + }); + }; + + try { + const nextPinnedAt = pinned ? new Date().toISOString() : null; + const updatedAt = new Date().toISOString(); + const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({ + ...existing, + pinnedAt: nextPinnedAt, + updatedAt, + })); + if (!updated) { + emitResponse(false, null, "Workspace not found"); + return; + } + emitResponse(true, nextPinnedAt, null); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true }); + } catch (error) { + this.sessionLogger.error( + { ...logContext, err: error }, + "session: workspace.pin.set.request error", + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to pin workspace: ${getErrorMessage(error)}`, + }, + }); + emitResponse(false, null, getErrorMessageOr(error, "Failed to pin workspace")); + } + } + /** * Handle text message to agent (with optional image attachments) */ @@ -3573,6 +3619,7 @@ export class Session { workspaceKind: workspace.kind, name: resolveWorkspaceDisplayName(workspace), title: workspace.title, + pinnedAt: workspace.pinnedAt, archivingAt: null, status: "done", statusEnteredAt: null, @@ -3657,6 +3704,7 @@ export class Session { derivedDisplayName: result.worktree.branchName || result.workspace.displayName, }), title: result.workspace.title, + pinnedAt: result.workspace.pinnedAt, archivingAt: null, status: "done", statusEnteredAt: result.workspace.createdAt, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 6d5bb4c14..5475a5854 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -119,6 +119,10 @@ interface SessionTestAccess { list(...args: unknown[]): Promise; archive(workspaceId: string, archivedAt: string): Promise; get(workspaceId: string): Promise; + update( + workspaceId: string, + updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, + ): Promise; upsert(record: unknown): Promise; }; agentUpdates: AgentUpdatesService; @@ -558,7 +562,7 @@ function createSessionForWorkspaceTests( clearAgentAttention: async () => {}, notifyAgentState: () => {}, }); - const workspaceRegistry = options.workspaceRegistry ?? { + const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? { initialize: async () => {}, existsOnDisk: async () => true, list: async () => [ @@ -584,6 +588,22 @@ function createSessionForWorkspaceTests( updatedAt: "2026-03-01T12:00:00.000Z", }) : null, + update: async (workspaceId, updater) => { + if (workspaceId !== "ws-repo-running") { + return null; + } + return updater( + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-running", + projectId: "proj-repo-running", + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + }, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -6569,9 +6589,12 @@ test("workspace.title.set.request stores the title and emits an updated descript session.projectRegistry.list = async () => Array.from(projects.values()); session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; - session.workspaceRegistry.upsert = async (record: unknown) => { - const parsed = record as typeof workspace; - workspaces.set(parsed.workspaceId, parsed); + session.workspaceRegistry.update = async (id, updater) => { + const existing = workspaces.get(id); + if (!existing) return null; + const updated = updater(existing); + workspaces.set(id, updated); + return updated; }; session.workspaceUpdatesSubscription = { @@ -6611,6 +6634,72 @@ test("workspace.title.set.request stores the title and emits an updated descript }); }); +test("workspace.pin.set.request stores the pin timestamp and emits an updated descriptor", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + const project = createPersistedProjectRecord({ + projectId: "proj-1", + rootPath: REPO_CWD, + kind: "git", + displayName: "acme/repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: project.projectId, + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspaces = new Map([[workspace.workspaceId, workspace]]); + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.update = async (id, updater) => { + const existing = workspaces.get(id); + if (!existing) return null; + const updated = updater(existing); + workspaces.set(id, updated); + return updated; + }; + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-workspaces", + filter: {}, + isBootstrapping: false, + lastEmittedByWorkspaceId: new Map(), + pendingUpdatesByWorkspaceId: new Map(), + }; + + await session.handleMessage({ + type: "workspace.pin.set.request", + workspaceId: workspace.workspaceId, + pinned: true, + requestId: "req-pin-1", + }); + + const response = findByType(emitted, "workspace.pin.set.response"); + expect(response?.payload).toMatchObject({ + requestId: "req-pin-1", + workspaceId: "ws-1", + accepted: true, + error: null, + }); + expect(response?.payload.pinnedAt).toEqual(expect.any(String)); + expect(workspaces.get("ws-1")?.pinnedAt).toBe(response?.payload.pinnedAt); + expect(findByType(emitted, "workspace_update")?.payload).toMatchObject({ + kind: "upsert", + workspace: { + id: "ws-1", + pinnedAt: response?.payload.pinnedAt, + }, + }); +}); + test("workspace.title.set.request with whitespace-only title clears the title", async () => { const emitted: SessionOutboundMessage[] = []; const session = asTestSession( @@ -6631,9 +6720,12 @@ test("workspace.title.set.request with whitespace-only title clears the title", const workspaces = new Map([[workspace.workspaceId, workspace]]); session.workspaceRegistry.list = async () => Array.from(workspaces.values()); session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null; - session.workspaceRegistry.upsert = async (record: unknown) => { - const parsed = record as typeof workspace; - workspaces.set(parsed.workspaceId, parsed); + session.workspaceRegistry.update = async (id, updater) => { + const existing = workspaces.get(id); + if (!existing) return null; + const updated = updater(existing); + workspaces.set(id, updated); + return updated; }; await session.handleMessage({ @@ -6659,7 +6751,7 @@ test("workspace.title.set.request returns accepted=false when workspace is not f const session = asTestSession( createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), ); - session.workspaceRegistry.get = async () => null; + session.workspaceRegistry.update = async () => null; await session.handleMessage({ type: "workspace.title.set.request", diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 6a74ad5af..4061fa464 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -230,6 +230,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { existsOnDisk: async () => true, list: async () => [], get: async () => null, + update: async () => null, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -1261,6 +1262,8 @@ export class VoiceAssistantWebSocketServer { agentForkContext: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. + workspacePinning: true, }, }; } diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 8830740fe..46e9ccfa8 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -523,6 +523,7 @@ describe("WorkspaceDirectory empty projects", () => { createdAt: NOW, updatedAt: NOW, archivedAt: null, + pinnedAt: null, ...input, } satisfies PersistedProjectRecord; } diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 246befc74..0d14cea39 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -211,4 +211,42 @@ describe("workspace registries", () => { expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); expect(await workspaceRegistry.list()).toEqual([]); }); + + test("composes concurrent workspace field updates without losing either change", async () => { + await workspaceRegistry.initialize(); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: "proj-1", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ); + + await Promise.all([ + workspaceRegistry.update("ws-1", (record) => ({ + ...record, + title: "Payments work", + updatedAt: "2026-03-02T00:00:00.000Z", + })), + workspaceRegistry.update("ws-1", (record) => ({ + ...record, + pinnedAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + })), + ]); + + const reloadedRegistry = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloadedRegistry.initialize(); + expect(await reloadedRegistry.get("ws-1")).toMatchObject({ + title: "Payments work", + pinnedAt: "2026-03-03T00:00:00.000Z", + }); + }); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index b2d79801d..bddf33734 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -56,6 +56,11 @@ const PersistedWorkspaceRecordSchema = z.object({ createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), + pinnedAt: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), }); export type PersistedProjectRecord = z.infer; @@ -76,6 +81,10 @@ export interface WorkspaceRegistry { existsOnDisk(): Promise; list(): Promise; get(workspaceId: string): Promise; + update( + workspaceId: string, + updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, + ): Promise; upsert(record: PersistedWorkspaceRecord): Promise; archive(workspaceId: string, archivedAt: string): Promise; remove(workspaceId: string): Promise; @@ -138,6 +147,18 @@ class FileBackedRegistry { await this.enqueuePersist(); } + async update(id: string, updater: (record: TRecord) => TRecord): Promise { + await this.load(); + const existing = this.cache.get(id); + if (!existing) { + return null; + } + const next = this.schema.parse(updater(existing)); + this.cache.set(id, next); + await this.enqueuePersist(); + return next; + } + async archive(id: string, archivedAt: string): Promise { await this.load(); const existing = this.cache.get(id); @@ -257,6 +278,7 @@ export function createPersistedWorkspaceRecord(input: { createdAt: string; updatedAt: string; archivedAt?: string | null; + pinnedAt?: string | null; }): PersistedWorkspaceRecord { return PersistedWorkspaceRecordSchema.parse({ ...input, @@ -264,6 +286,7 @@ export function createPersistedWorkspaceRecord(input: { branch: input.branch ?? null, baseBranch: input.baseBranch ?? null, archivedAt: input.archivedAt ?? null, + pinnedAt: input.pinnedAt ?? null, }); }