From a1e81685a126a8a7b97626ed1bcc5495a1f7a395 Mon Sep 17 00:00:00 2001 From: rafavncxs <99549268+half144@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:59:02 -0300 Subject: [PATCH] feat(app): pin chats to the top of the sidebar (#1981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): pin projects to top of the sidebar Co-Authored-By: Claude Opus 4.8 (1M context) * feat(app): dedicated Pinned sidebar section with UX fixes Replaces the invisible float-in-place pinning with a Codex-style Pinned section at the top of the sidebar that hoists pinned chats and projects. - Pin-aware keyboard shortcut numbering (badges match visual order) - Pinned chats show their project name as subtitle (context after hoisting) - Pinned entries ordered most-recently-pinned first - Collapsible Pinned section (persisted) - Double-click guard on workspace pin toggle Co-Authored-By: Claude Opus 4.8 (1M context) * feat(app): move Pinned section above the Workspaces header Pinned now sits at the top of the scroll, above the "Workspaces" section header (which moves into the scroll below it), matching the market-standard sidebar layout. Chevron sits beside the "Pinned" label. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(app): reveal Pinned collapse chevron on hover only Extracts PinnedSectionHeader with its own hover state; the chevron hides until hover on web and stays visible on touch/compact. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(app): pin the active chat via keyboard shortcut and inline pin button Adds a ⌘⇧P / Ctrl+Shift+P shortcut that pins the selected chat, mirroring the worktree.archive action across actions/dispatcher/route-shortcut, gated by `selected && supportsWorkspacePinning`. Also surfaces an inline pin button on row hover (always-on for touch) alongside the kebab in the shared trailing overlay, swapping Pin/PinOff by pinned state. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(server): unify project and workspace pin handlers Collapses the two near-identical pin.set handlers into a shared handlePinSetRequest that owns control flow, error handling and the workspace-update fan-out; each caller only supplies its registry apply step and typed response payload. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): drop empty pinned project shells and empty Workspaces header Pinning every chat of a project hoisted them into Pinned but left an empty project header (plus its new-workspace ghost row) below, duplicating the project. Skip projects emptied by pinning while keeping genuinely empty projects. Also hide the "Workspaces" section header when no unpinned projects remain, so it no longer floats above nothing. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(protocol): correct workspacePinning COMPAT version to v0.1.105 Feature ships in 0.1.105 per the changelog, not the placeholder v0.1.103. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): hide redundant chat pin control inside pinned projects A pinned project already hoists all its chats to the top, so the per-chat pin was a no-op that just toggled a dead icon. Suppress the inline pin button, the kebab pin item, and the pin keyboard shortcut on chats whose project is pinned; they still work for chats in unpinned projects. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): hide project pin control on empty projects Project pin state is derived from workspace descriptors, so a project with no workspaces would set pinnedAt on the server but emit no descriptor update — the pin silently never surfaced and the user got no feedback. Offer the pin only once the project has a chat, matching how chat pins are suppressed inside already-pinned projects. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): drop collapsed Pinned rows from keyboard shortcut numbering When the Pinned section is collapsed its chats and projects are hidden, but the shortcut model kept counting them: visible unpinned rows showed off-by-N badge numbers and pressing a number key jumped to a hidden pinned chat. Exclude the pinned rows from numbering when Pinned is collapsed, matching what renders. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): hydrate pinned sidebar rows * fix(app): keep filtered workspace controls reachable * feat(app): scope sidebar pinning to chats, drop project pinning Per PR review: pinning whole projects overlapped with project reordering and mixed projects into the pinned list. Keep chat pinning only. - Remove the project.pin.set RPC, its server handler, the projectPinnedAt descriptor field, and the client setProjectPinned method. - Drop the project pin control, isProjectPinned reads, and the suppressPin plumbing (its only source — a pinned project — is gone). - Fold pin-aware shortcut ordering into SidebarModelProvider (the live shortcut owner) and delete the orphaned use-sidebar-shortcut-model hook, so pinned chats and their shortcut numbers share one projection. - Prune the now-unused project pin i18n keys across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(server): inline the pin handler into the workspace path With project pinning gone, handlePinSetRequest had a single caller. Fold it back into handleWorkspacePinSetRequest and drop the project/workspace generalization. Co-Authored-By: Claude Opus 4.8 (1M context) * revert: keep main's use-sidebar-shortcut-model untouched This hook (and its test) already live on main. The scope-down had deleted them, but that's out of scope for this PR — the pin-aware shortcut ordering lives in SidebarModelProvider now, so main's hook is left exactly as it was. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(protocol): align workspacePinning COMPAT version in websocket-server Match the v0.1.105 version already corrected in messages.ts. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(app): reshape pinned sidebar updates * chore(app): remove stale pin button styles * refactor(app): simplify pinned workspace rows --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Mohamed Boudra --- docs/data-model.md | 20 +- packages/app/src/components/left-sidebar.tsx | 38 ++-- .../src/components/sidebar-workspace-list.tsx | 207 +++++++++++++++++- .../sidebar/pinned-section-header.tsx | 63 ++++++ .../src/components/sidebar/sidebar-model.tsx | 60 +++-- .../sidebar/sidebar-projection.test.ts | 115 ++++++++++ .../components/sidebar/sidebar-projection.ts | 74 +++++++ .../sidebar/sidebar-status-list.tsx | 148 ++++++++++--- .../sidebar/sidebar-workspace-menu.tsx | 19 +- .../sidebar/sidebar-workspace-row-content.tsx | 10 +- .../hooks/sidebar-workspaces-view-model.ts | 2 + .../app/src/hooks/use-sidebar-pins.test.ts | 87 ++++++++ packages/app/src/hooks/use-sidebar-pins.ts | 142 ++++++++++++ .../src/hooks/use-sidebar-workspace-pin.ts | 49 +++++ packages/app/src/i18n/resources/ar.ts | 5 + packages/app/src/i18n/resources/en.ts | 5 + packages/app/src/i18n/resources/es.ts | 5 + packages/app/src/i18n/resources/fr.ts | 5 + packages/app/src/i18n/resources/ja.ts | 5 + packages/app/src/i18n/resources/pt-BR.ts | 5 + packages/app/src/i18n/resources/ru.ts | 5 + packages/app/src/i18n/resources/zh-CN.ts | 5 + packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 6 +- .../app/src/keyboard/keyboard-shortcuts.ts | 26 +++ .../app/src/keyboard/route-shortcut.test.ts | 1 + packages/app/src/keyboard/route-shortcut.ts | 1 + packages/app/src/stores/session-store.ts | 2 + .../sidebar-collapsed-sections-store/index.ts | 4 + .../state.test.ts | 17 +- .../sidebar-collapsed-sections-store/state.ts | 24 +- packages/app/src/utils/sidebar-shortcuts.ts | 52 +++-- packages/client/src/daemon-client.ts | 20 ++ packages/protocol/src/messages.ts | 29 +++ packages/server/src/server/session.ts | 70 +++++- .../src/server/session.workspaces.test.ts | 108 ++++++++- .../server/src/server/websocket-server.ts | 3 + .../src/server/workspace-directory.test.ts | 1 + .../src/server/workspace-registry.test.ts | 38 ++++ .../server/src/server/workspace-registry.ts | 23 ++ 40 files changed, 1370 insertions(+), 130 deletions(-) create mode 100644 packages/app/src/components/sidebar/pinned-section-header.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-projection.test.ts create mode 100644 packages/app/src/components/sidebar/sidebar-projection.ts create mode 100644 packages/app/src/hooks/use-sidebar-pins.test.ts create mode 100644 packages/app/src/hooks/use-sidebar-pins.ts create mode 100644 packages/app/src/hooks/use-sidebar-workspace-pin.ts 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, }); }