Fix sidebar shortcut hints shifting rows

This commit is contained in:
Mohamed Boudra
2026-06-04 00:48:53 +07:00
parent ffe4d4046c
commit f6c0b60b7c
6 changed files with 233 additions and 67 deletions
@@ -101,7 +101,10 @@ import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-
import { import {
SidebarWorkspaceRowFrame, SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent, SidebarWorkspaceRowContent,
sidebarWorkspaceRowStyles, SidebarWorkspaceShortcutBadge,
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content"; } from "@/components/sidebar/sidebar-workspace-row-content";
import { import {
useHydratedWorkspaceEntries, useHydratedWorkspaceEntries,
@@ -652,7 +655,10 @@ function WorkspaceRowRightGroup({
onCopyPath?: () => void; onCopyPath?: () => void;
onRename?: () => void; onRename?: () => void;
}) { }) {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return ( return (
<> <>
{showScriptsIcon ? ( {showScriptsIcon ? (
@@ -665,30 +671,35 @@ function WorkspaceRowRightGroup({
</View> </View>
) : null} ) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null} {isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{showKebab && onArchive ? ( {shouldRenderActionSlot ? (
<WorkspaceKebabMenu <SidebarWorkspaceTrailingActionSlot>
workspaceKey={workspace.workspaceKey} <SidebarWorkspaceTrailingActionBase
onCopyPath={onCopyPath} visible={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
onCopyBranchName={onCopyBranchName} >
onRename={onRename} {workspace.diffStat ? (
onMarkAsRead={onMarkAsRead} <DiffStat
onArchive={onArchive} additions={workspace.diffStat.additions}
archiveLabel={archiveLabel} deletions={workspace.diffStat.deletions}
archiveStatus={archiveStatus} />
archivePendingLabel={archivePendingLabel} ) : null}
archiveShortcutKeys={archiveShortcutKeys} </SidebarWorkspaceTrailingActionBase>
/> <SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot}>
) : null} {onArchive ? (
{!showKebab && workspace.diffStat ? ( <WorkspaceKebabMenu
<DiffStat workspaceKey={workspace.workspaceKey}
additions={workspace.diffStat.additions} onCopyPath={onCopyPath}
deletions={workspace.diffStat.deletions} onCopyBranchName={onCopyBranchName}
/> onRename={onRename}
) : null} onMarkAsRead={onMarkAsRead}
{showShortcutBadge && shortcutNumber !== null ? ( onArchive={onArchive}
<View style={sidebarWorkspaceRowStyles.shortcutBadge}> archiveLabel={archiveLabel}
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{shortcutNumber}</Text> archiveStatus={archiveStatus}
</View> archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
) : null} ) : null}
</> </>
); );
@@ -1275,8 +1286,8 @@ function ProjectHeaderRow({
removeProjectStatus={removeProjectStatus} removeProjectStatus={removeProjectStatus}
/> />
{showShortcutBadge && shortcutNumber !== null ? ( {showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}> <View style={styles.projectShortcutBadgeOverlay} pointerEvents="none">
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text> <SidebarWorkspaceShortcutBadge number={shortcutNumber} />
</View> </View>
) : null} ) : null}
</> </>
@@ -1412,6 +1423,8 @@ function WorkspaceRowInner({
isHovered={isHovered} isHovered={isHovered}
isLoading={isArchiving || isCreating} isLoading={isArchiving || isCreating}
isCreating={isCreating} isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
> >
<WorkspaceRowRightGroup <WorkspaceRowRightGroup
workspace={workspace} workspace={workspace}
@@ -2789,6 +2802,7 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center", textAlign: "center",
}, },
projectRow: { projectRow: {
position: "relative",
minHeight: 36, minHeight: 36,
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
@@ -2925,6 +2939,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
projectActionTooltipShortcut: {}, projectActionTooltipShortcut: {},
projectShortcutBadgeOverlay: {
position: "absolute",
top: theme.spacing[2] + 1,
right: theme.spacing[2],
},
workspaceRow: { workspaceRow: {
minHeight: 36, minHeight: 36,
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
@@ -3045,24 +3064,6 @@ const styles = StyleSheet.create((theme) => ({
kebabButtonHovered: { kebabButtonHovered: {
backgroundColor: theme.colors.surface2, 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: { statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500], backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0, borderColor: theme.colors.surface0,
@@ -59,7 +59,9 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio
import { import {
SidebarWorkspaceRowFrame, SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent, SidebarWorkspaceRowContent,
sidebarWorkspaceRowStyles, SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content"; } from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
@@ -611,7 +613,10 @@ function StatusWorkspaceRowInner({
return ( return (
<SidebarWorkspaceRowFrame workspace={workspace}> <SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => { {({ isHovered, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const showKebabInSlot = showKebab && !showShortcut;
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered }); const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return ( return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}> <View style={styles.workspaceRowContainer} {...hoverHandlers}>
@@ -628,6 +633,8 @@ function StatusWorkspaceRowInner({
subtitle={projectName} subtitle={projectName}
isHovered={isHovered} isHovered={isHovered}
isLoading={isArchiving} isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
> >
<> <>
{showScriptsIcon ? ( {showScriptsIcon ? (
@@ -639,9 +646,11 @@ function StatusWorkspaceRowInner({
)} )}
</View> </View>
) : null} ) : null}
{showKebab && onArchive ? ( {shouldRenderActionSlot ? (
<StatusKebabMenu <StatusWorkspaceActionSlot
workspaceKey={workspace.workspaceKey} workspace={workspace}
showBase={Boolean(workspace.diffStat && !showKebabInSlot && !showShortcut)}
showOverlay={showKebabInSlot}
onCopyPath={onCopyPath} onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName} onCopyBranchName={onCopyBranchName}
onRename={onRename} onRename={onRename}
@@ -653,19 +662,6 @@ function StatusWorkspaceRowInner({
archiveShortcutKeys={archiveShortcutKeys} archiveShortcutKeys={archiveShortcutKeys}
/> />
) : null} ) : null}
{!showKebab && workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>
{shortcutNumber}
</Text>
</View>
) : null}
</> </>
</SidebarWorkspaceRowContent> </SidebarWorkspaceRowContent>
</Pressable> </Pressable>
@@ -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 (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showOverlay}>
{onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>
</SidebarWorkspaceTrailingActionSlot>
);
}
function StatusKebabMenu({ function StatusKebabMenu({
workspaceKey, workspaceKey,
onCopyPath, onCopyPath,
@@ -89,6 +89,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
isHovered, isHovered,
isLoading, isLoading,
isCreating = false, isCreating = false,
shortcutNumber = null,
showShortcutBadge = false,
children, children,
}: { }: {
workspace: SidebarWorkspaceEntry; workspace: SidebarWorkspaceEntry;
@@ -96,6 +98,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
isHovered: boolean; isHovered: boolean;
isLoading: boolean; isLoading: boolean;
isCreating?: boolean; isCreating?: boolean;
shortcutNumber?: number | null;
showShortcutBadge?: boolean;
children?: ReactNode; children?: ReactNode;
}) { }) {
const workspaceBranchTextStyle = useMemo( const workspaceBranchTextStyle = useMemo(
@@ -108,7 +112,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
); );
return ( return (
<> <View style={styles.workspaceRowContent}>
<View style={styles.workspaceRowMain}> <View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}> <View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator <WorkspaceStatusIndicator
@@ -122,6 +126,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
</View> </View>
<View style={styles.workspaceRowRight}>{children}</View> <View style={styles.workspaceRowRight}>{children}</View>
</View> </View>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadgeOverlay} pointerEvents="none">
<SidebarWorkspaceShortcutBadge number={shortcutNumber} />
</View>
) : null}
{subtitle ? ( {subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}> <Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle} {subtitle}
@@ -133,7 +142,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
<ChecksBadge checks={workspace.prHint.checks} /> <ChecksBadge checks={workspace.prHint.checks} />
</View> </View>
) : null} ) : null}
</> </View>
); );
}); });
@@ -346,7 +355,7 @@ const checksBadgeStyles = StyleSheet.create((theme) => ({
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
rowRight: { rowRight: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
gap: theme.spacing[2], gap: theme.spacing[2],
flexShrink: 0, flexShrink: 0,
}, },
@@ -368,12 +377,63 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
lineHeight: 14, 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 (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{number}</Text>
</View>
);
}
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionBase({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
if (!children) return null;
return <View style={visible ? undefined : sidebarWorkspaceRowStyles.hidden}>{children}</View>;
}
export function SidebarWorkspaceTrailingActionOverlay({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
if (!visible || !children) return null;
return <View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>;
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
},
workspaceRowMain: { workspaceRowMain: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
justifyContent: "space-between", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
width: "100%", width: "100%",
@@ -386,6 +446,11 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
}, },
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight, workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
shortcutBadgeOverlay: {
position: "absolute",
top: 1,
right: 0,
},
workspaceStatusDot: { workspaceStatusDot: {
position: "relative", position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH, width: WORKSPACE_STATUS_DOT_WIDTH,
@@ -28,6 +28,9 @@ export function WorkspaceShortcutTargetsSubscriber({
const collapsedProjectKeys = useSidebarCollapsedSectionsStore( const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys, (state) => state.collapsedProjectKeys,
); );
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore(
(state) => state.setSidebarShortcutWorkspaceTargets, (state) => state.setSidebarShortcutWorkspaceTargets,
); );
@@ -37,6 +40,7 @@ export function WorkspaceShortcutTargetsSubscriber({
return buildStatusSidebarShortcutModel({ return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces, workspaces: statusWorkspaces,
projectNamesByKey, projectNamesByKey,
collapsedStatusGroupKeys,
}); });
} }
@@ -44,7 +48,14 @@ export function WorkspaceShortcutTargetsSubscriber({
projects, projects,
collapsedProjectKeys, collapsedProjectKeys,
}); });
}, [collapsedProjectKeys, groupMode, projectNamesByKey, projects, statusWorkspaces]); }, [
collapsedProjectKeys,
collapsedStatusGroupKeys,
groupMode,
projectNamesByKey,
projects,
statusWorkspaces,
]);
useEffect(() => { useEffect(() => {
if (!enabled || !serverId) { if (!enabled || !serverId) {
@@ -199,6 +199,37 @@ describe("buildStatusSidebarShortcutModel", () => {
expect(model.shortcutIndexByWorkspaceKey.get("s1:running-old")).toBe(3); expect(model.shortcutIndexByWorkspaceKey.get("s1:running-old")).toBe(3);
expect(model.shortcutIndexByWorkspaceKey.get("s1:done-old")).toBe(4); 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", () => { describe("getRelativeSidebarShortcutTarget", () => {
@@ -53,6 +53,7 @@ export function buildSidebarShortcutModel(input: {
export function buildStatusSidebarShortcutModel(input: { export function buildStatusSidebarShortcutModel(input: {
workspaces: SidebarWorkspaceEntry[]; workspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>; projectNamesByKey: Map<string, string>;
collapsedStatusGroupKeys?: ReadonlySet<string>;
shortcutLimit?: number; shortcutLimit?: number;
}): SidebarShortcutModel { }): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)); const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
@@ -61,6 +62,10 @@ export function buildStatusSidebarShortcutModel(input: {
const shortcutIndexByWorkspaceKey = new Map<string, number>(); const shortcutIndexByWorkspaceKey = new Map<string, number>();
for (const group of groups) { for (const group of groups) {
if (input.collapsedStatusGroupKeys?.has(group.bucket)) {
continue;
}
for (const workspace of group.rows) { for (const workspace of group.rows) {
if (shortcutTargets.length >= maxShortcuts) { if (shortcutTargets.length >= maxShortcuts) {
break; break;