Improve sidebar workspace readability and host identity (#2790)

* fix(app): align turn footer actions

Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls.

* feat(app): improve sidebar workspace identity

Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified.

* fix(app): keep host badges readable

Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast.

* fix(app): announce selected sidebar badge mode

* fix(app): retain icon-only host names for screen readers

* fix(app): name hoisted workspace rows

* fix(app): retain host identity in workspace labels

* fix(app): retain workspace status in row labels

* fix(app): defer host appearance until identity resolves

* fix(app): serialize host appearance writes

* fix(app): serialize host registry writes

* fix(app): bound sidebar host badge labels

* test(app): match workspace status labels

* fix(app): consolidate sidebar identity behavior

* test(app): assert status grouping behavior

* fix(app): keep sidebar row actions accessible

* fix(app): stabilize optimistic message layout
This commit is contained in:
Mohamed Boudra
2026-08-02 16:58:20 +02:00
committed by GitHub
parent 553ac53643
commit ccb668b3b4
57 changed files with 3251 additions and 809 deletions
@@ -1,21 +1,27 @@
import type { ReactNode } from "react";
import { ActivityIndicator, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { SyncedLoader } from "@/components/synced-loader";
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getProjectStatusBadgeContent } from "@/utils/project-status-badge-content";
import {
getProjectStatusBadgeContent,
type ProjectStatusBadgeContent,
type ProjectStatusBadgeDotBucket,
} from "@/utils/project-status-badge-content";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getStatusDotColor } from "@/utils/status-dot-color";
// Every surfaced status shares one badge shell, so the ring never changes size or position
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
const STATUS_BADGE_SIZE = 14;
const STATUS_BADGE_BORDER_WIDTH = 1;
// Centers the badge on the icon's bottom-right corner: half in, half out.
const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2);
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
// sidebar row has no padding there to absorb the overhang.
const STATUS_BADGE_OFFSET = -5;
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
@@ -26,17 +32,12 @@ const STATUS_BADGE_OFFSET = -(STATUS_BADGE_SIZE / 2);
// against the 8pt dot — the two states read as the same-diameter disc.
const STATUS_BADGE_DOT_SIZE = 8;
const STATUS_BADGE_ALERT_SIZE = 10;
// SyncedLoader clamps its dot size at 2px, so sizes 5-10 all render the same 5x8 dot grid.
const STATUS_BADGE_LOADER_SIZE = 7;
// The dot-comet's 6 divs are geometrically centered, but the snake reads a hair right inside
// the round circle. This optical nudge (pt, leftward) lands its visual center on the circle
// center — the play-button-triangle trick. Dialed in visually: -0.67pt ≈ 2 device px left at
// 3x (a full -1pt / 3px read a touch too far left).
const STATUS_BADGE_LOADER_NUDGE_X = -0.67;
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
@@ -44,12 +45,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
const amberColorMapping = (theme: Theme) => ({
color: theme.colors.palette.amber[500],
});
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
/**
* Leading slot of a sidebar project row: chevron on hover, archive spinner while removing,
@@ -73,9 +68,6 @@ export function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
if (showChevron && chevron !== null) {
return (
<View style={styles.projectLeadingVisualSlot}>
@@ -95,7 +87,7 @@ export function ProjectLeadingVisual({
return (
<ProjectStatusIndicator
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
displayName={displayName}
projectViewKey={projectViewKey}
statusBucket={statusBucket}
/>
@@ -103,46 +95,65 @@ export function ProjectLeadingVisual({
}
// The project icon (the lettered box) is what marks a row as a *project* rather than a
// workspace, so it always stays. Status rides along as a corner badge instead of replacing
// the icon: "working" and "needs input" keep the same amber loader/alert glyph a workspace
// row uses, the quieter buckets get a dot, and "done" shows nothing. Every one of those sits
// in the identical ringed badge, so the badge reads as one fixed shell across all states.
// workspace, so it always stays and status annotates it instead of replacing it. Every
// surfaced bucket lands in the identical corner badge — an amber alert glyph for needs_input,
// a colored disc for the rest, nothing for done — so the badge reads as one fixed shell and
// only its contents change.
export function ProjectStatusIndicator({
iconDataUri,
placeholderInitial,
displayName,
projectViewKey,
statusBucket,
loading = false,
testID,
}: {
iconDataUri: string | null;
placeholderInitial: string;
displayName: string;
projectViewKey: string;
statusBucket: SidebarStateBucket | null;
loading?: boolean;
testID?: string;
}) {
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
.charAt(0)
.toUpperCase();
// A row that's still starting up and a row that's working are both "busy", and at 8pt
// there's no room to draw the difference — so `loading` just resolves to the running bucket
// and they share one badge.
const badgeBucket = loading ? "running" : statusBucket;
const badgeContent = getProjectStatusBadgeContent(badgeBucket);
return (
<View
style={styles.projectLeadingVisualSlot}
testID={
statusBucket && statusBucket !== "done"
testID ??
(statusBucket && statusBucket !== "done"
? `project-status-indicator-${statusBucket}`
: "project-icon-only"
: "project-icon-only")
}
>
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectViewKey={projectViewKey}
/>
<ProjectStatusBadge statusBucket={statusBucket} />
<View style={styles.projectIconBox}>
<ProjectIcon
iconDataUri={iconDataUri}
placeholderInitial={placeholderInitial}
projectViewKey={projectViewKey}
/>
{badgeContent === null || badgeBucket === null ? null : (
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
)}
</View>
</View>
);
}
function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket | null }) {
const content = getProjectStatusBadgeContent(statusBucket);
if (content === null || statusBucket === null) {
return null;
}
function ProjectStatusBadge({
content,
statusBucket,
}: {
content: ProjectStatusBadgeContent;
statusBucket: SidebarStateBucket;
}) {
return (
<View
role="status"
@@ -150,28 +161,20 @@ function ProjectStatusBadge({ statusBucket }: { statusBucket: SidebarStateBucket
style={styles.statusBadge}
testID="project-status-badge"
>
{renderProjectStatusBadgeContent(content)}
{content.kind === "alert" ? (
<ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />
) : (
<ProjectStatusDot bucket={content.bucket} />
)}
</View>
);
}
function renderProjectStatusBadgeContent(
content: NonNullable<ReturnType<typeof getProjectStatusBadgeContent>>,
): ReactNode {
switch (content.kind) {
case "loader":
// Nudged left a hair so the comet's visual center sits on the circle center (see the
// nudge constant). The wrapper carries the transform; the loader itself is untouched.
return (
<View style={styles.runningLoaderNudge}>
<ThemedSyncedLoader size={STATUS_BADGE_LOADER_SIZE} uniProps={syncedLoaderColorMapping} />
</View>
);
case "alert":
return <ThemedCircleAlert size={STATUS_BADGE_ALERT_SIZE} uniProps={amberColorMapping} />;
case "dot":
return <View testID="project-status-dot" style={getStatusDotColorStyle(content.bucket)} />;
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
if (bucket !== "running") {
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
}
return <PulsingStatusDot testID="project-status-dot" style={styles.statusDotRunning} />;
}
function ProjectIcon({
@@ -205,29 +208,43 @@ function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | nu
return <ChevronRight size={14} color="#9ca3af" />;
}
function getStatusDotColorStyle(bucket: "failed" | "attention"): ViewStyle {
function getStatusDotColorStyle(
bucket: Exclude<ProjectStatusBadgeDotBucket, "running">,
): ViewStyle {
return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention;
}
const styles = StyleSheet.create((theme) => {
// Dot statuses sit *inside* the shell rather than replacing it, so "ready to review" and
// "failed" carry the same ring as the loader and alert. The geometry is shared here and
// baked into each colored variant so the style prop stays a single stable object.
const statusDotBase = {
width: STATUS_BADGE_DOT_SIZE,
height: STATUS_BADGE_DOT_SIZE,
borderRadius: theme.borderRadius.full,
} as const;
// Dot statuses sit *inside* the shell rather than replacing it, so they carry the same ring
// the alert does. The geometry is shared here and baked into each colored variant so the
// style prop stays a single stable object; the colors come from the one bucket-to-color map
// so this badge can't drift from the status dots everywhere else.
const statusDot = (bucket: ProjectStatusBadgeDotBucket) =>
({
width: STATUS_BADGE_DOT_SIZE,
height: STATUS_BADGE_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
}) as const;
return {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
height: LEADING_SLOT_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
// Anchors the corner badge to the icon rather than to the taller slot.
projectIconBox: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIcon: {
width: "100%",
height: "100%",
@@ -243,9 +260,9 @@ const styles = StyleSheet.create((theme) => {
projectIconFallbackText: {
fontSize: 9,
},
// The one shell every status shares. It straddles the icon's bottom-right corner (half in,
// half out) so the lettered project box stays readable, and its ring is sidebar-colored so
// the badge separates from the icon underneath rather than blending into it.
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
// (half in, half out) so the lettered project box stays readable, and its ring is
// sidebar-colored so the badge separates from the icon underneath rather than blending in.
statusBadge: {
position: "absolute",
right: STATUS_BADGE_OFFSET,
@@ -260,17 +277,8 @@ const styles = StyleSheet.create((theme) => {
justifyContent: "center",
overflow: "hidden",
},
// Optical-centering nudge for the running dot-comet (see the nudge constant).
runningLoaderNudge: {
transform: [{ translateX: STATUS_BADGE_LOADER_NUDGE_X }],
},
statusDotFailed: {
...statusDotBase,
backgroundColor: theme.colors.palette.red[500],
},
statusDotAttention: {
...statusDotBase,
backgroundColor: theme.colors.palette.green[500],
},
statusDotRunning: statusDot("running"),
statusDotFailed: statusDot("failed"),
statusDotAttention: statusDot("attention"),
};
});