diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 0ca57b98e..f43ac7624 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -101,7 +101,10 @@ import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status- import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, - sidebarWorkspaceRowStyles, + SidebarWorkspaceShortcutBadge, + SidebarWorkspaceTrailingActionBase, + SidebarWorkspaceTrailingActionOverlay, + SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { useHydratedWorkspaceEntries, @@ -652,7 +655,10 @@ function WorkspaceRowRightGroup({ onCopyPath?: () => void; onRename?: () => void; }) { + const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); + const showKebabInSlot = showKebab && !showShortcut; + const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); return ( <> {showScriptsIcon ? ( @@ -665,30 +671,35 @@ function WorkspaceRowRightGroup({ ) : null} {isCreating ? Creating... : null} - {showKebab && onArchive ? ( - - ) : null} - {!showKebab && workspace.diffStat ? ( - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - + {shouldRenderActionSlot ? ( + + + {workspace.diffStat ? ( + + ) : null} + + + {onArchive ? ( + + ) : null} + + ) : null} ); @@ -1275,8 +1286,8 @@ function ProjectHeaderRow({ removeProjectStatus={removeProjectStatus} /> {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} + + ) : null} @@ -1412,6 +1423,8 @@ function WorkspaceRowInner({ isHovered={isHovered} isLoading={isArchiving || isCreating} isCreating={isCreating} + shortcutNumber={shortcutNumber} + showShortcutBadge={showShortcutBadge} > ({ textAlign: "center", }, projectRow: { + position: "relative", minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], @@ -2925,6 +2939,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, projectActionTooltipShortcut: {}, + projectShortcutBadgeOverlay: { + position: "absolute", + top: theme.spacing[2] + 1, + right: theme.spacing[2], + }, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], @@ -3045,24 +3064,6 @@ const styles = StyleSheet.create((theme) => ({ kebabButtonHovered: { backgroundColor: theme.colors.surface2, }, - shortcutBadge: { - minWidth: 18, - height: 18, - paddingHorizontal: theme.spacing[1], - alignItems: "center", - justifyContent: "center", - borderRadius: theme.borderRadius.sm, - borderWidth: 1, - borderColor: theme.colors.surface2, - backgroundColor: theme.colors.surface0, - flexShrink: 0, - }, - shortcutBadgeText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - lineHeight: 14, - }, statusDotNeedsInput: { backgroundColor: theme.colors.palette.amber[500], borderColor: theme.colors.surface0, diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 8fabec4bd..831734130 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -59,7 +59,9 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, - sidebarWorkspaceRowStyles, + SidebarWorkspaceTrailingActionBase, + SidebarWorkspaceTrailingActionOverlay, + SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; @@ -611,7 +613,10 @@ function StatusWorkspaceRowInner({ return ( {({ isHovered, hoverHandlers }) => { + const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); + const showKebabInSlot = showKebab && !showShortcut; + const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered }); return ( @@ -628,6 +633,8 @@ function StatusWorkspaceRowInner({ subtitle={projectName} isHovered={isHovered} isLoading={isArchiving} + shortcutNumber={shortcutNumber} + showShortcutBadge={showShortcutBadge} > <> {showScriptsIcon ? ( @@ -639,9 +646,11 @@ function StatusWorkspaceRowInner({ )} ) : null} - {showKebab && onArchive ? ( - ) : null} - {!showKebab && workspace.diffStat ? ( - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - - {shortcutNumber} - - - ) : null} @@ -676,6 +672,63 @@ function StatusWorkspaceRowInner({ ); } +function StatusWorkspaceActionSlot({ + workspace, + showBase, + showOverlay, + onCopyPath, + onCopyBranchName, + onRename, + onMarkAsRead, + onArchive, + archiveLabel, + archiveStatus, + archivePendingLabel, + archiveShortcutKeys, +}: { + workspace: SidebarWorkspaceEntry; + showBase: boolean; + showOverlay: boolean; + onCopyPath?: () => void; + onCopyBranchName?: () => void; + onRename?: () => void; + onMarkAsRead?: () => void; + onArchive?: () => void; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + archiveShortcutKeys?: ShortcutKey[][] | null; +}) { + return ( + + + {workspace.diffStat ? ( + + ) : null} + + + {onArchive ? ( + + ) : null} + + + ); +} + function StatusKebabMenu({ workspaceKey, onCopyPath, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 7a8112ae7..19fee6816 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -89,6 +89,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte isHovered, isLoading, isCreating = false, + shortcutNumber = null, + showShortcutBadge = false, children, }: { workspace: SidebarWorkspaceEntry; @@ -96,6 +98,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte isHovered: boolean; isLoading: boolean; isCreating?: boolean; + shortcutNumber?: number | null; + showShortcutBadge?: boolean; children?: ReactNode; }) { const workspaceBranchTextStyle = useMemo( @@ -108,7 +112,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte ); return ( - <> + {children} + {showShortcutBadge && shortcutNumber !== null ? ( + + + + ) : null} {subtitle ? ( {subtitle} @@ -133,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte ) : null} - + ); }); @@ -346,7 +355,7 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ rowRight: { flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", gap: theme.spacing[2], flexShrink: 0, }, @@ -368,12 +377,63 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.medium, lineHeight: 14, }, + hidden: { opacity: 0 }, + trailingActionSlot: { + position: "relative", + minWidth: 18, + minHeight: 20, + flexShrink: 0, + alignItems: "flex-end", + justifyContent: "flex-start", + }, + trailingActionOverlay: { + position: "absolute", + top: 0, + right: 0, + }, })); +export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { + return ( + + {number} + + ); +} + +export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) { + return {children}; +} + +export function SidebarWorkspaceTrailingActionBase({ + visible, + children, +}: { + visible: boolean; + children: ReactNode; +}) { + if (!children) return null; + return {children}; +} + +export function SidebarWorkspaceTrailingActionOverlay({ + visible, + children, +}: { + visible: boolean; + children: ReactNode; +}) { + if (!visible || !children) return null; + return {children}; +} + const styles = StyleSheet.create((theme) => ({ + workspaceRowContent: { + position: "relative", + }, workspaceRowMain: { flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", justifyContent: "space-between", gap: theme.spacing[2], width: "100%", @@ -386,6 +446,11 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, workspaceRowRight: sidebarWorkspaceRowStyles.rowRight, + shortcutBadgeOverlay: { + position: "absolute", + top: 1, + right: 0, + }, workspaceStatusDot: { position: "relative", width: WORKSPACE_STATUS_DOT_WIDTH, diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx index f90e7940f..f5762cc42 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.tsx @@ -28,6 +28,9 @@ export function WorkspaceShortcutTargetsSubscriber({ const collapsedProjectKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedProjectKeys, ); + const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( + (state) => state.collapsedStatusGroupKeys, + ); const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( (state) => state.setSidebarShortcutWorkspaceTargets, ); @@ -37,6 +40,7 @@ export function WorkspaceShortcutTargetsSubscriber({ return buildStatusSidebarShortcutModel({ workspaces: statusWorkspaces, projectNamesByKey, + collapsedStatusGroupKeys, }); } @@ -44,7 +48,14 @@ export function WorkspaceShortcutTargetsSubscriber({ projects, collapsedProjectKeys, }); - }, [collapsedProjectKeys, groupMode, projectNamesByKey, projects, statusWorkspaces]); + }, [ + collapsedProjectKeys, + collapsedStatusGroupKeys, + groupMode, + projectNamesByKey, + projects, + statusWorkspaces, + ]); useEffect(() => { if (!enabled || !serverId) { diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index f191471a1..cfd6b2b22 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -199,6 +199,37 @@ describe("buildStatusSidebarShortcutModel", () => { expect(model.shortcutIndexByWorkspaceKey.get("s1:running-old")).toBe(3); expect(model.shortcutIndexByWorkspaceKey.get("s1:done-old")).toBe(4); }); + + it("excludes collapsed status groups from shortcut targets", () => { + const workspaces = [ + workspace({ + serverId: "s1", + workspaceId: "needs-input", + workspaceDirectory: "/repo/needs-input", + name: "needs input", + projectKey: "p1", + statusBucket: "needs_input", + }), + workspace({ + serverId: "s1", + workspaceId: "running", + workspaceDirectory: "/repo/running", + name: "running", + projectKey: "p1", + statusBucket: "running", + }), + ]; + + const model = buildStatusSidebarShortcutModel({ + workspaces, + projectNamesByKey: new Map([["p1", "Project 1"]]), + collapsedStatusGroupKeys: new Set(["needs_input"]), + }); + + expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "running" }]); + expect(model.shortcutIndexByWorkspaceKey.get("s1:needs-input")).toBeUndefined(); + expect(model.shortcutIndexByWorkspaceKey.get("s1:running")).toBe(1); + }); }); describe("getRelativeSidebarShortcutTarget", () => { diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index eff5f9cdf..2c75c8905 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -53,6 +53,7 @@ export function buildSidebarShortcutModel(input: { export function buildStatusSidebarShortcutModel(input: { workspaces: SidebarWorkspaceEntry[]; projectNamesByKey: Map; + collapsedStatusGroupKeys?: ReadonlySet; shortcutLimit?: number; }): SidebarShortcutModel { const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); @@ -61,6 +62,10 @@ export function buildStatusSidebarShortcutModel(input: { const shortcutIndexByWorkspaceKey = new Map(); for (const group of groups) { + if (input.collapsedStatusGroupKeys?.has(group.bucket)) { + continue; + } + for (const workspace of group.rows) { if (shortcutTargets.length >= maxShortcuts) { break;