feat(app): pin chats to the top of the sidebar (#1981)

* feat(app): pin projects to top of the sidebar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
rafavncxs
2026-07-13 05:59:02 -03:00
committed by GitHub
parent 8cd83b3a91
commit a1e81685a1
40 changed files with 1370 additions and 130 deletions

View File

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

View File

@@ -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<typeof useUnistyles>["theme"];
interface SidebarSharedProps {
theme: SidebarTheme;
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
@@ -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"
/>
</View>
<WorkspacesSectionHeader />
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
@@ -639,7 +644,7 @@ function MobileSidebar({
)}
</Pressable>
{isInitialLoad ? (
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
@@ -648,6 +653,7 @@ function MobileSidebar({
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusGroups={statusGroups}
pinnedGroups={pinnedGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
@@ -656,6 +662,7 @@ function MobileSidebar({
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -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({
/>
</View>
</View>
<WorkspacesSectionHeader />
{isInitialLoad ? (
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
@@ -802,12 +810,14 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusGroups={statusGroups}
pinnedGroups={pinnedGroups}
projects={projects}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -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 = <WorkspacesSectionHeader />;
// 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],
},

View File

@@ -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<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
@@ -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<GestureType | undefined>;
}
@@ -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}
</SidebarWorkspaceTrailingActionOverlay>
@@ -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}
>
<WorkspaceRowRightGroup
workspace={workspace}
@@ -1404,6 +1429,8 @@ function WorkspaceRowInner({
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
isPinned={isPinned}
onTogglePin={onTogglePin}
/>
</SidebarWorkspaceRowContent>
</Pressable>
@@ -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 (
<>
<WorkspaceRowInner
@@ -1567,6 +1617,9 @@ function WorkspaceRowWithMenu({
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
isPinned={isPinned}
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -1589,6 +1642,9 @@ interface WorkspaceRowItemProps {
shortcutNumber: number | null;
showShortcutBadge: boolean;
canCopyBranchName: boolean;
canPin: boolean;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
reserveIdleStatusIndicatorSpace?: boolean;
isCreating?: boolean;
selectionEnabled: boolean;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
@@ -1605,6 +1661,9 @@ function WorkspaceRowItem({
shortcutNumber,
showShortcutBadge,
canCopyBranchName,
canPin,
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
isCreating = false,
selectionEnabled,
activeWorkspaceSelection,
@@ -1628,6 +1687,9 @@ function WorkspaceRowItem({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
canCopyBranchName={canCopyBranchName}
canPin={canPin}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
isCreating={isCreating}
selected={isWorkspaceSelected({
selection: activeWorkspaceSelection,
@@ -1666,6 +1728,9 @@ function areWorkspaceRowItemPropsEqual(
previous.shortcutNumber === next.shortcutNumber &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
previous.canPin === next.canPin &&
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace &&
previous.isCreating === next.isCreating &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.drag === next.drag &&
@@ -1687,6 +1752,9 @@ function WorkspaceRow({
isDragging,
dragHandleProps,
canCopyBranchName,
canPin,
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
isCreating = false,
selected,
}: {
@@ -1699,6 +1767,9 @@ function WorkspaceRow({
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
canPin: boolean;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
reserveIdleStatusIndicatorSpace?: boolean;
isCreating?: boolean;
selected: boolean;
}) {
@@ -1718,6 +1789,9 @@ function WorkspaceRow({
isDragging={isDragging}
dragHandleProps={dragHandleProps}
canCopyBranchName={canCopyBranchName}
canPin={canPin}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
isCreating={isCreating}
/>
);
@@ -1746,6 +1820,8 @@ function ProjectBlock({
hostLabelByServerId,
showHostLabels,
supportsMultiplicityByServerId,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
project: SidebarProjectEntry;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
@@ -1769,6 +1845,8 @@ function ProjectBlock({
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
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" ? (
<SidebarStatusModeWrapper
statusGroups={statusGroups}
pinnedGroups={pinnedGroups}
workspaceEntriesByKey={workspaceEntriesByKey}
projectNamesByKey={projectNamesByKey}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
listHeaderComponent={listHeaderComponent}
/>
) : (
<ProjectModeList
projects={projects}
pinnedGroups={pinnedGroups}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={onToggleProjectCollapsed}
@@ -2081,11 +2175,14 @@ export function SidebarWorkspaceList({
onWorkspacePress={onWorkspacePress}
onAddProject={onAddProject}
listFooterComponent={listFooterComponent}
listHeaderComponent={listHeaderComponent}
parentGestureRef={parentGestureRef}
pathname={pathname}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
);
@@ -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<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
listHeaderComponent?: ReactElement | null;
}) {
const showShortcutBadges = useShowShortcutBadges();
return (
<SidebarStatusWorkspaceList
groups={statusGroups}
pinnedWorkspaces={pinnedGroups.pinnedChats.flatMap((workspace) => {
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<string, string>;
showHostLabels: boolean;
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { t } = useTranslation();
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
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<SidebarProjectEntry>) => {
const renderProjectBlock = useCallback(
(
item: SidebarProjectEntry,
dragState: {
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableRenderItemInfo<SidebarProjectEntry>["dragHandleProps"];
},
) => {
return (
<MemoProjectBlock
key={item.projectKey}
project={item}
workspaceEntriesByKey={workspaceEntriesByKey}
collapsed={collapsedProjectKeys.has(item.projectKey)}
@@ -2325,15 +2459,17 @@ function ProjectModeList({
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
drag={dragState.drag}
isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps}
useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
);
},
@@ -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<SidebarProjectEntry>) =>
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 (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
subtitle={hostLabel ? `${workspace.projectName} · ${hostLabel}` : workspace.projectName}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={false}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
/>
);
},
[
activeWorkspaceSelection,
creatingWorkspaceIds,
hostLabelByServerId,
onWorkspacePress,
selectionEnabled,
shortcutIndexByWorkspaceKey,
showHostLabels,
showShortcutBadges,
supportsPinningByServerId,
onToggleWorkspacePin,
workspaceEntriesByKey,
],
);
const content = (
<>
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
</View>
) : null}
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
{projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle} testID="sidebar-project-empty-state">
@@ -2372,7 +2564,7 @@ function ProjectModeList({
) : (
<DraggableList
testID="sidebar-project-list"
data={projects}
data={unpinnedProjects}
keyExtractor={projectKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
@@ -2429,6 +2621,9 @@ const styles = StyleSheet.create((theme) => ({
projectListContainer: {
width: "100%",
},
pinnedSection: {
marginBottom: theme.spacing[1],
},
projectBlock: {
marginBottom: theme.spacing[1],
},

View File

@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.header}
testID="sidebar-pinned-section-header"
>
{({ hovered }) => (
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
) : null}
</>
)}
</Pressable>
);
}
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,
},
}));

View File

@@ -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<string, SidebarWorkspaceEntry>;
groupMode: SidebarGroupMode;
statusGroups: StatusGroup[];
pinnedGroups: PinnedSidebarGroups;
collapsedProjectKeys: ReadonlySet<string>;
toggleProjectCollapsed: (projectKey: string) => void;
shortcutModel: SidebarShortcutModel;
}
const SidebarModelContext = createContext<SidebarModel | null>(null);
const EMPTY_WORKSPACE_ENTRIES = new Map<string, SidebarWorkspaceEntry>();
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,
],

View File

@@ -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<string>(),
collapsedStatusGroupKeys: new Set<string>(),
};
}
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" },
]);
});
});

View File

@@ -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<string, SidebarWorkspaceEntry>;
projectNamesByKey: Map<string, string>;
groupMode: SidebarGroupMode;
pinnedCollapsed: boolean;
collapsedProjectKeys: ReadonlySet<string>;
collapsedStatusGroupKeys: ReadonlySet<string>;
}): 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 }),
};
}

View File

@@ -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<string, string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
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 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed
? null
: pinnedWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
subtitle={buildStatusRowSubtitle({
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
hostLabel: showHostLabels
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
: null,
})}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={false}
onWorkspacePress={onWorkspacePress}
/>
))}
</View>
) : null}
{listHeaderComponent}
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
supportsPinningByServerId={supportsPinningByServerId}
onToggleWorkspacePin={onToggleWorkspacePin}
/>
</>
);
return (
<View style={styles.container}>
@@ -91,16 +147,7 @@ export function SidebarStatusWorkspaceList({
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
{content}
</NestableScrollContainer>
) : (
<ScrollView
@@ -109,16 +156,7 @@ export function SidebarStatusWorkspaceList({
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
hostLabelByServerId={hostLabelByServerId}
showHostLabels={showHostLabels}
/>
{content}
</ScrollView>
)}
</View>
@@ -134,6 +172,8 @@ function StatusGroupList({
onWorkspacePress,
hostLabelByServerId,
showHostLabels,
supportsPinningByServerId,
onToggleWorkspacePin,
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
@@ -143,6 +183,8 @@ function StatusGroupList({
onWorkspacePress?: () => void;
hostLabelByServerId: ReadonlyMap<string, string>;
showHostLabels: boolean;
supportsPinningByServerId: ReadonlyMap<string, boolean>;
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 (
<>
<StatusWorkspaceRowInner
@@ -434,6 +509,9 @@ function StatusWorkspaceRowWithMenu({
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
isPinned={isPinned}
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -466,6 +544,9 @@ function StatusWorkspaceRowInner({
onRename,
onMarkAsRead,
archiveShortcutKeys,
isPinned,
onTogglePin,
reserveIdleStatusIndicatorSpace = true,
}: {
workspace: SidebarWorkspaceEntry;
subtitle: string;
@@ -483,6 +564,9 @@ function StatusWorkspaceRowInner({
onRename?: () => 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 ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
showKebab={showKebabInSlot}
isPinned={isPinned}
onTogglePin={onTogglePin}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
@@ -553,7 +640,9 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
showBase,
showOverlay,
showKebab,
isPinned,
onTogglePin,
onCopyPath,
onCopyBranchName,
onRename,
@@ -566,7 +655,9 @@ function StatusWorkspaceActionSlot({
}: {
workspace: SidebarWorkspaceEntry;
showBase: boolean;
showOverlay: boolean;
showKebab: boolean;
isPinned?: boolean;
onTogglePin?: () => void;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
@@ -587,8 +678,8 @@ function StatusWorkspaceActionSlot({
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<SidebarWorkspaceTrailingActionOverlay visible={showKebab}>
{showKebab && onArchive ? (
<SidebarWorkspaceMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
@@ -600,6 +691,8 @@ function StatusWorkspaceActionSlot({
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
@@ -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],
},

View File

@@ -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 = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
@@ -31,6 +33,8 @@ const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
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
</DropdownMenuItem>
) : null}
{onTogglePin ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-pin-${workspaceKey}`}
leading={isPinned ? unpinLeadingIcon : pinLeadingIcon}
onSelect={onTogglePin}
>
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}

View File

@@ -100,6 +100,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
isCreating = false,
shortcutNumber = null,
showShortcutBadge = false,
reserveIdleStatusIndicatorSpace = true,
children,
}: {
workspace: SidebarWorkspaceEntry;
@@ -110,6 +111,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
isCreating?: boolean;
shortcutNumber?: number | null;
showShortcutBadge?: boolean;
/** Keep the empty leading slot when the workspace has no active status. */
reserveIdleStatusIndicatorSpace?: boolean;
children?: ReactNode;
}) {
const {
@@ -133,6 +136,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isLoading}
reserveIdleSpace={reserveIdleStatusIndicatorSpace}
/>
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
@@ -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 <View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />;
return reserveIdleSpace ? (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-done" />
) : null;
}
let KindIcon: typeof ThemedMonitor;

View File

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

View File

@@ -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",
]);
});
});

View File

@@ -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<string, string>;
}
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<string, ReadonlyMap<string, { pinnedAt?: string | null }>>,
): PinnedSidebarKeys {
const pinnedWorkspaceKeys: string[] = [];
const pinnedAtByKey: Record<string, string> = {};
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<PinnedSidebarKeys>({
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<string, { pinnedAt?: string | null }>
>();
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 };
}

View File

@@ -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<string>());
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],
);
}

View File

@@ -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: "إخفاء من الشريط الجانبي",

View File

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

View File

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

View File

@@ -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 lespace de travail",
hideFromSidebar: "Masquer de la barre latérale",

View File

@@ -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: "サイドバーから非表示",

View File

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

View File

@@ -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: "Скрыть с боковой панели",

View File

@@ -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: "从侧边栏隐藏",

View File

@@ -45,6 +45,7 @@ export type KeyboardActionId =
| "workspace.new"
| "worktree.new"
| "workspace.archive"
| "workspace.pin"
| "view.toggle.focus"
| "theme.cycle"
| "message-input.action";

View File

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

View File

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

View File

@@ -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" }],

View File

@@ -47,6 +47,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"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" },

View File

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

View File

@@ -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<SidebarCollapsedSectionsState>()(
@@ -21,12 +23,14 @@ export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsS
(set) => ({
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",

View File

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

View File

@@ -1,11 +1,17 @@
export interface CollapsedProjectsState {
collapsedProjectKeys: Set<string>;
collapsedStatusGroupKeys: Set<string>;
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<S extends CollapsedProjectsState
persisted: PersistedCollapsedProjects | undefined,
current: S,
): S {
if (!persisted?.collapsedProjectKeys) {
if (!persisted?.collapsedStatusGroupKeys) return current;
if (
!persisted?.collapsedProjectKeys &&
!persisted?.collapsedStatusGroupKeys &&
persisted?.collapsedPinned === undefined
) {
return current;
}
const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys);
const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys);
const restoredPinned =
typeof persisted.collapsedPinned === "boolean"
? persisted.collapsedPinned
: current.collapsedPinned;
if (
areSetsEqual(current.collapsedProjectKeys, restoredProjects) &&
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups)
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) &&
current.collapsedPinned === restoredPinned
) {
return current;
}
@@ -77,6 +94,7 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
...current,
collapsedProjectKeys: restoredProjects,
collapsedStatusGroupKeys: restoredStatusGroups,
collapsedPinned: restoredPinned,
};
}

View File

@@ -14,6 +14,11 @@ export interface SidebarShortcutModel {
shortcutIndexByWorkspaceKey: Map<string, number>;
}
export interface SidebarShortcutSection {
workspaces: readonly SidebarWorkspacePlacement[];
collapsed?: boolean;
}
function createShortcutTarget(
workspace: SidebarWorkspacePlacement,
): SidebarShortcutWorkspaceTarget {
@@ -28,44 +33,43 @@ export function buildSidebarShortcutModel(input: {
collapsedProjectKeys: ReadonlySet<string>;
shortcutLimit?: number;
}): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutIndexByWorkspaceKey = new Map<string, number>();
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<string>;
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<string, number>();
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;
}

View File

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

View File

@@ -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<typeof WorkspaceTitleSetResponse
export type WorkspaceTitleSetResponsePayload = z.infer<
typeof WorkspaceTitleSetResponsePayloadSchema
>;
export type WorkspacePinSetResponse = z.infer<typeof WorkspacePinSetResponseSchema>;
export type WorkspacePinSetResponsePayload = z.infer<typeof WorkspacePinSetResponsePayloadSchema>;
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
@@ -4634,6 +4662,7 @@ export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessage
export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;

View File

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

View File

@@ -119,6 +119,10 @@ interface SessionTestAccess {
list(...args: unknown[]): Promise<unknown[]>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
get(workspaceId: string): Promise<unknown>;
update(
workspaceId: string,
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
): Promise<unknown>;
upsert(record: unknown): Promise<unknown>;
};
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",

View File

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

View File

@@ -523,6 +523,7 @@ describe("WorkspaceDirectory empty projects", () => {
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
pinnedAt: null,
...input,
} satisfies PersistedProjectRecord;
}

View File

@@ -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",
});
});
});

View File

@@ -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<typeof PersistedProjectRecordSchema>;
@@ -76,6 +81,10 @@ export interface WorkspaceRegistry {
existsOnDisk(): Promise<boolean>;
list(): Promise<PersistedWorkspaceRecord[]>;
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
update(
workspaceId: string,
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord | null>;
upsert(record: PersistedWorkspaceRecord): Promise<void>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
remove(workspaceId: string): Promise<void>;
@@ -138,6 +147,18 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
await this.enqueuePersist();
}
async update(id: string, updater: (record: TRecord) => TRecord): Promise<TRecord | null> {
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<void> {
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,
});
}