feat(app): add running status ring and lighten status dots

Keep the animated ring phase-locked across hosts and derive its knockout surface from each host's actual interaction state. Align filled status circles at 6px while preserving the larger needs-input alert.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
This commit is contained in:
Mohamed Boudra
2026-08-09 11:33:58 +02:00
co-authored by Christoph Leiter
parent 6ae6231d1f
commit c0daf8e066
23 changed files with 584 additions and 153 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th
There is exactly one token per status signal — `statusSuccess`, `statusDanger`, `statusWarning`, `statusMerged` — and every status surface uses it: PR state icons, CI check icons and pies, diff stats, file-change icons, status pills, usage bars. A surface does not get a quieter or louder variant because of where it sits. If a dense list feels loud, that is a density or weight problem; fix the density, not the color. The tokens are generated, not hand-picked — see the rule in `packages/app/src/styles/theme.ts` and regenerate rather than nudging one value. The level is set by the densest consumer, the sidebar workspace list.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: pushed away from the surface rather than toward it (darker in light, lighter in dark) and at 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's lightness the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. All four move together; regenerate the set, never one hue.
Status **dots** are the one exception, and they are a family of their own — `statusDotSuccess`, `statusDotDanger`, `statusDotWarning`, `statusDotRunning`, read only by `getStatusDotColor` (`packages/app/src/utils/status-dot-color.ts`). Same hues and the same generation rule, but their own band: 90% of gamut chroma against the status family's 55–60%. A dot is a few points of solid color with no shape to read and no label attached, and the running one pulses, so at the status band's chroma the dots read dimmer than the metadata beside them — backwards, since the dot is the row's state. Lightness is set by hue separation rather than by distance from the surface: at 6pt four dark hues on a light surface all read as one dark blob no matter how much contrast they have. So the light band runs as bright as the contrast floor allows at L=0.62, the last step where all four clear 3:1 against the sidebar's `surface2`; the dark band sits at L=0.72, where danger turns pink above. All four move together; regenerate the set, never one hue.
Status pills are the status token as foreground on a 10%-alpha tint of the same token (`${token}1a`), with a 20% border (`${token}33`). The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical; a pill never reaches into `palette`.
@@ -7,6 +7,7 @@ import {
} from "@getpaseo/protocol/agent-lifecycle";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
export function AgentStatusDot({
status,
@@ -56,8 +57,8 @@ function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
const styles = StyleSheet.create((theme) => ({
dot: {
width: 8,
height: 8,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
},
}));
@@ -376,18 +376,21 @@ function projectKebabStyle({
function getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -871,6 +874,7 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const [isPressed, setIsPressed] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const localDaemonServerId = useLocalDaemonServerId();
@@ -917,6 +921,17 @@ function ProjectHeaderRow({
setContextMenuOpen(open);
if (open) setIsHovered(false);
}, []);
const handleProjectPressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleProjectPressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const projectRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
@@ -937,7 +952,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -982,10 +997,10 @@ function ProjectHeaderRow({
<PressHighlight
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowHovered}
onPressIn={interaction.handlePressIn}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1008,10 +1023,10 @@ function ProjectHeaderRow({
enabledOnMobile={false}
accessibilityRole="button"
style={projectRowStyle}
highlightStyle={styles.projectRowHovered}
onPressIn={interaction.handlePressIn}
highlightStyle={styles.projectRowPressed}
onPressIn={handleProjectPressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleProjectPressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.viewKey}`}
>
@@ -1064,6 +1079,7 @@ function WorkspaceRowInner({
reserveIdleStatusIndicatorSpace = true,
}: WorkspaceRowInnerProps) {
const _isCompact = useIsCompactFormFactor();
const [isPressed, setIsPressed] = useState(false);
const isTouchPlatform = platformIsNative;
const interaction = useLongPressDragInteraction({
drag,
@@ -1083,6 +1099,17 @@ function WorkspaceRowInner({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
interaction.handlePressIn(event);
},
[interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
interaction.handlePressOut();
}, [interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -1093,6 +1120,7 @@ function WorkspaceRowInner({
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
@@ -1127,10 +1155,10 @@ function WorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPressIn={interaction.handlePressIn}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -1140,7 +1168,7 @@ function WorkspaceRowInner({
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -12,8 +12,13 @@ import {
} from "@/utils/project-status-badge-content";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { STATUS_INDICATOR_ALERT_SIZE } from "@/utils/status-indicator-geometry";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_FILLED_DOT_SIZE,
} from "@/utils/status-indicator-geometry";
import { StatusRing } from "@/components/status-ring";
import { getStatusRingOffset } from "@/components/status-ring/geometry";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
@@ -29,7 +34,6 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
const STATUS_BADGE_DOT_SIZE = 6;
// 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;
@@ -65,7 +69,7 @@ export function ProjectLeadingVisual({
statusBucket: SidebarStateBucket | null;
projectViewKey: string;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
chevron?: "expand" | "collapse" | null;
showChevron?: boolean;
isArchiving?: boolean;
@@ -116,7 +120,7 @@ export function ProjectStatusIndicator({
projectViewKey: string;
statusBucket: SidebarStateBucket | null;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
loading?: boolean;
testID?: string;
}) {
@@ -164,8 +168,23 @@ function ProjectStatusBadge({
}: {
content: ProjectStatusBadgeContent;
statusBucket: SidebarStateBucket;
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
}) {
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
// so nesting it inside would clip it against the very thing that was meant to separate it from
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
if (content.kind === "dot" && content.bucket === "running") {
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
style={styles.statusRingAnchor}
testID="project-status-badge"
>
<StatusRing backdrop={backdrop} />
</View>
);
}
return (
<View
role="status"
@@ -182,14 +201,14 @@ function ProjectStatusBadge({
);
}
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
function getStatusBadgeBackdropStyle(backdrop: SidebarSurfaceBackdrop): ViewStyle {
switch (backdrop) {
case "surfaceSidebar":
return styles.statusBadgeOnSidebar;
case "surfaceSidebarHover":
return styles.statusBadgeOnSidebarHover;
case "surface2":
return styles.statusBadgeOnSurface2;
default:
return styles.statusBadgeOnSidebar;
}
}
@@ -240,8 +259,8 @@ const styles = StyleSheet.create((theme) => {
// 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,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined,
}) as const;
@@ -282,6 +301,12 @@ const styles = StyleSheet.create((theme) => {
justifyContent: "center",
overflow: "hidden",
},
// Same corner as the shell, re-centred for the wider ring.
statusRingAnchor: {
position: "absolute",
right: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
bottom: getStatusRingOffset(STATUS_BADGE_OFFSET, STATUS_BADGE_SIZE),
},
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
@@ -1,4 +1,4 @@
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
/**
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
@@ -9,19 +9,19 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
*
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
* as long as a finger is down, which is not long enough to see a mismatched ring.
*/
export function getSidebarRowBackdrop({
isDragging = false,
isPressed = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
isPressed?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SurfaceBackdrop {
if (isDragging) return "surface2";
}): SidebarSurfaceBackdrop {
if (isDragging || isPressed) return "surface2";
if (selected || isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
}
@@ -687,12 +687,15 @@ function StatusWorkspaceRowInner({
inStatusGroup?: boolean;
}) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const handlePressIn = useCallback(() => setIsPressed(true), []);
const handlePressOut = useCallback(() => setIsPressed(false), []);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
@@ -713,6 +716,7 @@ function StatusWorkspaceRowInner({
showShortcut,
});
const workspaceRowStyle = getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
@@ -743,7 +747,9 @@ function StatusWorkspaceRowInner({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -753,7 +759,7 @@ function StatusWorkspaceRowInner({
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isPressed, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
@@ -857,10 +863,12 @@ function StatusWorkspaceActionSlot({
}
function getStatusWorkspaceRowStyle({
isPressed,
selected,
isHovered,
inStatusGroup,
}: {
isPressed: boolean;
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
@@ -870,6 +878,7 @@ function getStatusWorkspaceRowStyle({
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isPressed && styles.workspaceRowPressed,
];
}
@@ -4,7 +4,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import {
WorkspaceMetaRow,
type WorkspaceServiceSummary,
@@ -19,12 +19,14 @@ import {
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { getStatusDotColor } from "@/utils/status-dot-color";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_DOT_SIZE,
STATUS_INDICATOR_FILLED_DOT_SIZE,
} from "@/utils/status-indicator-geometry";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
@@ -32,11 +34,6 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
@@ -143,7 +140,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
leadingProjectIconDataUri?: string | null;
serviceSummary?: WorkspaceServiceSummary | null;
/** The row's current background, so the project status badge can knock out of it. */
backdrop: SurfaceBackdrop;
backdrop: SidebarSurfaceBackdrop;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
@@ -221,14 +218,14 @@ function WorkspaceStatusIndicator({
loading?: boolean;
reserveIdleSpace?: boolean;
}) {
// Busy is a dot here for the same reason it is on a project icon: every status in the
// sidebar is a dot, and a row with a project icon simply moves that dot onto the icon.
// A row starting up and a row working are both busy, so they share the dot and differ only
// in testID.
// Busy is the only status that moves, and it is the ring rather than a dot for the same
// reason it is a dot elsewhere: every status in the sidebar sits in this one slot, so busy
// has to fill it without displacing anything. A row starting up and a row working are both
// busy, so they share the ring and differ only in testID.
if (loading) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-loading">
<View style={styles.standaloneRunningDot} />
<StatusRing />
</View>
);
}
@@ -236,7 +233,7 @@ function WorkspaceStatusIndicator({
if (shouldRenderSyncedStatusLoader({ bucket })) {
return (
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-running">
<View style={styles.standaloneRunningDot} />
<StatusRing />
</View>
);
}
@@ -275,50 +272,16 @@ function WorkspaceStatusIndicator({
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
offset,
}: {
dotColorStyle: ViewStyle;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, offset, size],
);
return <View style={overlayStyle} />;
function StatusDotOverlay({ dotColorStyle }: { dotColorStyle: ViewStyle }) {
return <View style={[styles.statusDotOverlay, dotColorStyle]} />;
}
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
@@ -569,24 +532,22 @@ const styles = StyleSheet.create((theme) => ({
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
standaloneStatusDot: {
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
},
standaloneRunningDot: {
width: STATUS_INDICATOR_DOT_SIZE,
height: STATUS_INDICATOR_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
},
idleStatusDot: {
width: 8,
height: 8,
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.foregroundExtraMuted,
opacity: 0.3,
@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo, useState, type Ref } from "react";
import { useTranslation } from "react-i18next";
import { View, Text } from "react-native";
import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
@@ -264,6 +264,7 @@ function WorkspaceRowBody({
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isTouchPlatform = platformIsNative;
const [isPressed, setIsPressed] = useState(false);
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
@@ -284,6 +285,17 @@ function WorkspaceRowBody({
}
onPress();
}, [interaction.didLongPressRef, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
if (draggable) interaction.handlePressIn(event);
},
[draggable, interaction],
);
const handleWorkspacePressOut = useCallback(() => {
setIsPressed(false);
if (draggable) interaction.handlePressOut();
}, [draggable, interaction]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -292,7 +304,12 @@ function WorkspaceRowBody({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const serviceSummary = isDesktop ? selectWorkspaceServiceSummary(workspace.scripts) : null;
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
const workspaceRowStyle = getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
});
return (
<View
{...(draggable ? dragAttributes : {})}
@@ -325,10 +342,10 @@ function WorkspaceRowBody({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowHovered}
onPressIn={draggable ? interaction.handlePressIn : undefined}
highlightStyle={styles.workspaceRowPressed}
onPressIn={handleWorkspacePressIn}
onTouchMove={draggable ? interaction.handleTouchMove : undefined}
onPressOut={draggable ? interaction.handlePressOut : undefined}
onPressOut={handleWorkspacePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
@@ -336,7 +353,7 @@ function WorkspaceRowBody({
workspace={workspace}
hostBadge={hostBadge}
serviceSummary={serviceSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
backdrop={getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -458,18 +475,21 @@ function WorkspaceRowTrailingActions({
function getWorkspaceRowStyle({
isDragging,
isPressed,
selected,
isHovered,
}: {
isDragging: boolean;
isPressed: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
isDragging && styles.workspaceRowDragging,
isPressed && styles.workspaceRowPressed,
];
}
@@ -495,6 +515,9 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
@@ -710,7 +710,7 @@ function DragOverlayTabChipInner({
return (
<View style={chipStyle}>
<WorkspaceTabIcon presentation={presentation} active size={14} />
<WorkspaceTabIcon presentation={presentation} active size={14} backdrop="surface1" />
<Text numberOfLines={1} style={chipLabelStyle}>
{label}
</Text>
@@ -0,0 +1,58 @@
import { useLayoutEffect } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
function advanceSharedRotation(): void {
"worklet";
if (activeRingCount.value === 0) {
clockRunning.value = false;
return;
}
sharedRotation.value = getStatusRingRotation(Date.now());
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
activeRingCount.value += 1;
if (!clockRunning.value) {
clockRunning.value = true;
sharedRotation.value = getStatusRingRotation(Date.now());
requestAnimationFrame(advanceSharedRotation);
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const registered = useSharedValue(false);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
};
}, [registered]);
return sharedRotation;
}
@@ -0,0 +1,35 @@
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
import type { View as NativeView } from "react-native";
import { STATUS_RING_PERIOD_MS } from "@/components/status-ring/geometry";
const STATUS_RING_KEYFRAMES: PropertyIndexedKeyframes = {
transform: ["rotate(0deg)", "rotate(360deg)"],
};
const STATUS_RING_TIMING: KeyframeAnimationOptions = {
duration: STATUS_RING_PERIOD_MS,
easing: "linear",
iterations: Number.POSITIVE_INFINITY,
};
const STATUS_RING_TIMELINE_START_MS = 0;
export function useStatusRingAnimationRef(): RefCallback<NativeView> {
const arcElement = useRef<HTMLElement | null>(null);
const setArcElement = useCallback((instance: NativeView | null) => {
arcElement.current = instance instanceof HTMLElement ? instance : null;
}, []);
useLayoutEffect(() => {
const element = arcElement.current;
if (!element) {
return;
}
const animation = element.animate(STATUS_RING_KEYFRAMES, STATUS_RING_TIMING);
animation.startTime = STATUS_RING_TIMELINE_START_MS;
return () => {
animation.cancel();
};
}, []);
return setArcElement;
}
@@ -0,0 +1,121 @@
import type { ReactNode } from "react";
import { StyleSheet as RNStyleSheet, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { getStatusDotColor } from "@/utils/status-dot-color";
import {
STATUS_RING_FRAME_SIZE,
STATUS_RING_HEAD_OPACITY,
STATUS_RING_SIZE,
STATUS_RING_STROKE,
STATUS_RING_TRACK_OPACITY,
} from "@/components/status-ring/geometry";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
export interface StatusRingProps {
// The surface the ring is drawn on top of, named rather than resolved, because theme colours
// are only legible inside `StyleSheet.create` — see docs/unistyles.md. The frame fills itself
// with this so the ring reads as a hole punched in whatever it overlaps: the agent icon under a
// tab dot, the project tile under a sidebar badge. Leave it out where the ring sits on flat
// background and has nothing to knock out.
backdrop?: SurfaceBackdrop | null;
}
/**
* The static half of the running indicator: the knockout, the track, and the centre dot. The
* platform entry points supply the rotating quarter and own only how it is driven.
*
* The ring has no colour prop. It only ever means one thing — an agent is running — so it reads
* that one colour out of the theme itself. Passing the colour in would materialise it at the call
* site, outside anything Unistyles tracks, and leave the mark in the old theme until something
* incidental re-rendered the row.
*/
export function StatusRingFrame({ backdrop, children }: StatusRingProps & { children: ReactNode }) {
return (
<View style={[styles.frame, getBackdropStyle(backdrop)]}>
<View style={styles.track} />
{children}
<View style={styles.centerDot} />
</View>
);
}
function getBackdropStyle(backdrop: SurfaceBackdrop | null | undefined) {
switch (backdrop) {
case "surface0":
return styles.backdropSurface0;
case "surface1":
return styles.backdropSurface1;
case "surfaceSidebar":
return styles.backdropSurfaceSidebar;
case "surfaceSidebarHover":
return styles.backdropSurfaceSidebarHover;
case "surface2":
return styles.backdropSurface2;
default:
return null;
}
}
// The rotating quarter's geometry, deliberately plain React Native rather than Unistyles: on
// native this style lands on a Reanimated `Animated.View`, and applying a theme-tracked style to
// one crashes when the theme changes — Unistyles and Reanimated both mutate the same native node.
// See docs/unistyles.md. The colour it needs lives on `styles.arc`, one level in, on a view
// Reanimated never touches.
export const rotatorStyles = RNStyleSheet.create({
rotator: {
position: "absolute",
width: STATUS_RING_SIZE,
height: STATUS_RING_SIZE,
},
});
export const styles = StyleSheet.create((theme) => {
const circle = {
width: STATUS_RING_SIZE,
height: STATUS_RING_SIZE,
borderRadius: theme.borderRadius.full,
borderWidth: STATUS_RING_STROKE,
} as const;
const runningColor = getStatusDotColor({ theme, bucket: "running" }) ?? undefined;
return {
frame: {
width: STATUS_RING_FRAME_SIZE,
height: STATUS_RING_FRAME_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
backdropSurface0: { backgroundColor: theme.colors.surface0 },
backdropSurface1: { backgroundColor: theme.colors.surface1 },
backdropSurfaceSidebar: { backgroundColor: theme.colors.surfaceSidebar },
backdropSurfaceSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
backdropSurface2: { backgroundColor: theme.colors.surface2 },
// The closed ring the quarter runs on. Same colour rather than a grey so the indicator is one
// object at one hue; the opacity is what puts it behind the quarter instead of competing.
track: {
...circle,
position: "absolute",
borderColor: runningColor,
opacity: STATUS_RING_TRACK_OPACITY,
},
// The moving quarter. Three sides transparent leaves the top border alone, which on a fully
// rounded box is a 90° arc — the edge that makes the rotation legible against the track.
arc: {
...circle,
borderColor: "transparent",
borderTopColor: runningColor,
opacity: STATUS_RING_HEAD_OPACITY,
},
centerDot: {
width: STATUS_INDICATOR_FILLED_DOT_SIZE,
height: STATUS_INDICATOR_FILLED_DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: runningColor,
},
};
});
@@ -0,0 +1,49 @@
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
// The running indicator is a status dot with a rotating ring around it, and the whole set of
// numbers below is a budget rather than four independent choices. The centre matches every other
// filled status circle; the gap and stroke grow outward from it without changing that shared size.
export const STATUS_RING_GAP = 1.5;
export const STATUS_RING_STROKE = 1.5;
// Derived outward from the dot, which means the dot is not a free variable: shrink it without
// opening the gap by the same amount and the ring closes in behind it, the whole mark scales
// down, and nothing about how the centre reads against the ring actually changes.
export const STATUS_RING_SIZE =
STATUS_INDICATOR_FILLED_DOT_SIZE + (STATUS_RING_GAP + STATUS_RING_STROKE) * 2;
// The knockout margin, and the reason the frame is wider than the ring. These indicators sit on
// the bottom-right corner of an agent or project icon, and a dot only stays readable there
// because it carries a ring of surface colour separating it from the art underneath. A ring drawn
// out to the frame's edge has no such separation and collides with the icon, so the frame is the
// knockout and the ring lives inside it.
export const STATUS_RING_HALO_INSET = 1;
export const STATUS_RING_FRAME_SIZE = STATUS_RING_SIZE + STATUS_RING_HALO_INSET * 2;
// The track and the quarter riding it are the same color and differ only here. The track is low
// enough that the quarter is unmistakably the thing moving, high enough that the ring still reads
// as a ring when the quarter is on the far side of it. The quarter is held off full so the centre
// dot stays the solid part of the mark — the ring is what the dot is doing, not a second dot.
export const STATUS_RING_TRACK_OPACITY = 0.3;
export const STATUS_RING_HEAD_OPACITY = 0.9;
// One period for every ring in the app. Each platform owns one mount-independent timeline: web
// animations share an absolute document-timeline start, while native publishes this wall-clock
// phase from one UI-thread clock. A row that appears mid-flight therefore lands in step with the
// rings already turning.
export const STATUS_RING_PERIOD_MS = 900;
export function getStatusRingRotation(nowMs: number): number {
"worklet";
return ((nowMs % STATUS_RING_PERIOD_MS) / STATUS_RING_PERIOD_MS) * 360;
}
/**
* Re-anchors a corner status dot's offset for the ring that replaces it.
*
* The dots this replaces are pinned to an icon's bottom-right corner rather than centred in a
* slot, so growing them in place would walk the indicator off the corner. Callers keep their
* existing dot offset and pass it through here to grow around the same centre point.
*/
export function getStatusRingOffset(dotOffset: number, dotSize: number): number {
return dotOffset - (STATUS_RING_FRAME_SIZE - dotSize) / 2;
}
@@ -0,0 +1,32 @@
import { memo } from "react";
import { View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import {
StatusRingFrame,
type StatusRingProps,
rotatorStyles,
styles,
} from "@/components/status-ring/frame";
import { useStatusRingRotation } from "@/components/status-ring/clock";
/**
* Native running indicator. The rotation is published by one shared UI-thread clock rather than
* per-instance timing, so a ring that mounts mid-flight is already in phase — see `clock.ts`.
*
* The rotated view carries no theme-tracked style; the coloured arc is nested inside it. Putting
* a Unistyles style on a Reanimated view crashes on theme change (docs/unistyles.md).
*/
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
const rotation = useStatusRingRotation();
const rotatorStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}));
return (
<StatusRingFrame backdrop={backdrop}>
<Animated.View style={[rotatorStyles.rotator, rotatorStyle]}>
<View style={styles.arc} />
</Animated.View>
</StatusRingFrame>
);
});
@@ -0,0 +1,26 @@
import { memo } from "react";
import { View } from "react-native";
import {
StatusRingFrame,
type StatusRingProps,
rotatorStyles,
styles,
} from "@/components/status-ring/frame";
import { useStatusRingAnimationRef } from "@/components/status-ring/clock.web";
/**
* Web running indicator. A sidebar full of agents can hold dozens of these at once, so the browser
* drives the rotation from one absolute document-timeline epoch rather than from mount time — see
* `clock.web.ts`.
*/
export const StatusRing = memo(function StatusRing({ backdrop }: StatusRingProps) {
const rotatorRef = useStatusRingAnimationRef();
return (
<StatusRingFrame backdrop={backdrop}>
<View ref={rotatorRef} style={rotatorStyles.rotator}>
<View style={styles.arc} />
</View>
</StatusRingFrame>
);
});
@@ -508,7 +508,7 @@ function TabHandleContent({
return (
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
<View style={styles.tabIcon}>
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} backdrop="surface0" />
</View>
{showLabel && presentation.titleState === "loading" ? (
<View style={tabLabelSkeletonStyle} />
@@ -175,6 +175,7 @@ import {
import { findAdjacentPane } from "@/utils/split-navigation";
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import {
buildHostRootRoute,
@@ -429,10 +430,12 @@ function MobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
backdrop,
}: {
activeTab: WorkspaceTabDescriptor | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
backdrop: SurfaceBackdrop;
}) {
if (!activeTab) {
return null;
@@ -443,6 +446,7 @@ function MobileActiveTabTrigger({
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
backdrop={backdrop}
/>
);
}
@@ -451,10 +455,12 @@ function ResolvedMobileActiveTabTrigger({
activeTab,
normalizedServerId,
normalizedWorkspaceId,
backdrop,
}: {
activeTab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
backdrop: SurfaceBackdrop;
}) {
const { t } = useTranslation();
return (
@@ -466,7 +472,7 @@ function ResolvedMobileActiveTabTrigger({
{(presentation) => (
<>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
<WorkspaceTabIcon presentation={presentation} active />
<WorkspaceTabIcon presentation={presentation} active backdrop={backdrop} />
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
@@ -741,14 +747,19 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
style={switcherTriggerStyle}
onPress={handleOpenSwitcher}
>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
</View>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
{({ pressed }) => (
<>
<View style={styles.switcherTriggerLeft}>
<MobileActiveTabTrigger
activeTab={activeTab}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
backdrop={pressed ? "surface1" : "surface0"}
/>
</View>
<ThemedChevronDown size={14} uniProps={mutedColorMapping} />
</>
)}
</Pressable>
<Combobox
@@ -8,7 +8,10 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
import { getPanelRegistration } from "@/panels/panel-registry";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { StatusRing } from "@/components/status-ring";
import { getStatusRingOffset } from "@/components/status-ring/geometry";
import {
STATUS_INDICATOR_ALERT_SIZE,
STATUS_INDICATOR_DOT_SIZE,
@@ -112,6 +115,13 @@ interface WorkspaceTabIconProps {
active?: boolean;
size?: number;
statusDotBorderColor?: string;
/**
* The surface this icon is sitting on, so the running ring can knock out of it. This icon is
* shared by the desktop tab strip, the tab switcher trigger, the split drag chip and the
* subagents track, which are on different surfaces and some of which change surface on hover —
* it cannot work the colour out for itself.
*/
backdrop: SurfaceBackdrop;
}
const ThemedCheckIcon = withUnistyles(Check);
@@ -127,12 +137,13 @@ export function WorkspaceTabIcon({
active = false,
size = 14,
statusDotBorderColor,
backdrop,
}: WorkspaceTabIconProps): ReactElement {
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
const bucket = presentation.statusBucket;
const isRunning = bucket === "running";
let statusDotColor: string | undefined;
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
else if (bucket === "running") statusDotColor = styles.statusDotRunning.color;
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
const showNeedsInputAlert = bucket === "needs_input";
const Icon = presentation.icon;
@@ -154,13 +165,16 @@ export function WorkspaceTabIcon({
return (
<View style={agentIconWrapperStyle}>
<Icon size={size} color={iconColor} />
{statusDotColor ? (
{isRunning ? (
<View
style={statusDotStyle}
accessibilityRole={bucket === "running" ? "progressbar" : undefined}
accessibilityLabel={bucket === "running" ? "Agent running" : undefined}
/>
style={styles.statusRing}
accessibilityRole="progressbar"
accessibilityLabel="Agent running"
>
<StatusRing backdrop={backdrop} />
</View>
) : null}
{statusDotColor ? <View style={statusDotStyle} /> : null}
{showNeedsInputAlert ? (
<View style={styles.statusAlertOverlay}>
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
@@ -186,13 +200,18 @@ export function WorkspaceTabOptionRow({
trailingAccessory,
}: WorkspaceTabOptionRowProps): ReactElement {
const { t } = useTranslation();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
(Boolean(hovered) || pressed || active) && styles.optionRowActive,
],
const isOptionActive = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) =>
Boolean(hovered) || pressed || active,
[active],
);
const pressableStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionMainPressable,
isOptionActive(state) && styles.optionRowActive,
],
[isOptionActive],
);
const optionRowStyle = useMemo(
() => [styles.optionRow, active && styles.optionRowActive],
[active],
@@ -200,16 +219,27 @@ export function WorkspaceTabOptionRow({
return (
<View style={optionRowStyle}>
<Pressable onPress={onPress} style={pressableStyle}>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
{(state) => {
const optionActive = isOptionActive(state);
return (
<>
<View style={styles.optionLeadingSlot}>
<WorkspaceTabIcon
presentation={presentation}
active={selected || active}
backdrop={optionActive ? "surface1" : "surface0"}
/>
</View>
<View style={styles.optionContent}>
<Text numberOfLines={1} style={styles.optionLabel}>
{presentation.titleState === "loading"
? t("workspace.tabs.loading")
: presentation.label}
</Text>
</View>
</>
);
}}
</Pressable>
{presentation.modified ? (
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
@@ -241,6 +271,11 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
statusRing: {
position: "absolute",
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
},
statusDotBorderDefault: {
borderColor: theme.colors.surface0,
},
+11 -1
View File
@@ -11,4 +11,14 @@
* `StyleSheet.create` — see docs/unistyles.md. Each knockout maps the name to a style of its own.
* Add a token here when a knockout lands on a surface that isn't listed.
*/
export type SurfaceBackdrop = "surfaceSidebar" | "surfaceSidebarHover" | "surface2";
export type SurfaceBackdrop =
| "surface0"
| "surface1"
| "surface2"
| "surfaceSidebar"
| "surfaceSidebarHover";
export type SidebarSurfaceBackdrop = Extract<
SurfaceBackdrop,
"surfaceSidebar" | "surfaceSidebarHover" | "surface2"
>;
+20 -11
View File
@@ -165,11 +165,18 @@ const darkStatusColors = {
// for them. Same four hues and the same generation rule as the status colors above, but its
// own band, because a dot is doing a different job than a check icon or a host badge.
//
// A dot is 6-8pt of solid color with no shape to read and no label attached. At the status
// A dot is 6pt of solid color with no shape to read and no label attached. At the status
// band's lightness the dots read dimmer than the static text and icons beside them on the same
// row, which is backwards — the dot is the row's state. So the band is pushed away from the
// surface rather than toward it (darker in light, lighter in dark) and carries more chroma:
// 90% of gamut max against the status family's 55-60%.
// row, which is backwards — the dot is the row's state. The loudness comes from chroma: 90% of
// gamut max against the status family's 55-60%.
//
// Lightness is set by hue separation, not by distance from the surface. A dark dot on a light
// surface has plenty of contrast but the four hues collapse into each other at 6pt — dark green,
// dark red, dark amber and dark blue all read as "dark blob", and the point of the dot is telling
// them apart at a glance. So the light band runs as bright as the contrast floor allows: L=0.62
// is the last step where all four clear 3:1 against the sidebar's surface2 (success is the
// binding one at 3.10, and drops under 3 by L=0.64), which is WCAG's non-text minimum for a
// control that carries state.
//
// All four move together. A dot matching its siblings in lightness and chroma says only which
// state the row is in; one that does not says "this row matters more", which is a claim the
@@ -178,14 +185,16 @@ const darkStatusColors = {
// 90% and not 100%: at the gamut edge the lopsidedness is worst — green reaches C=0.215 while
// blue manages 0.116 — so the set stops reading as one family and green wins. Red running out
// of chroma as lightness climbs is what caps the dark band at L=0.72; higher turns the failed
// dot pink. Running is blue at hue 250, clear of identity-colors' blue at 256.6 so a blue host
// badge and a working dot on the same row do not read as related.
// dot pink, and the light band pastels out the same way just above its own cap. Running is blue
// at hue 250, clear of
// identity-colors' blue at 256.6 so a blue host badge and a working dot on the same row do not
// read as related.
const lightStatusDotColors = {
// L=0.46, chroma 90% of gamut max
statusDotSuccess: "#186933",
statusDotDanger: "#a11c1c",
statusDotWarning: "#774e14",
statusDotRunning: "#165a96",
// L=0.62, chroma 90% of gamut max
statusDotSuccess: "#299f51",
statusDotDanger: "#f12e2f",
statusDotWarning: "#b37824",
statusDotRunning: "#268ae0",
};
const darkStatusDotColors = {
+4 -1
View File
@@ -200,7 +200,10 @@ function SubagentsTrackRow({
>
{({ pressed }) => (
<View style={hovered || pressed ? styles.rowActive : styles.row}>
<WorkspaceTabIcon presentation={presentation} />
<WorkspaceTabIcon
presentation={presentation}
backdrop={hovered || pressed ? "surface2" : "surface1"}
/>
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
@@ -32,9 +32,3 @@ export function getStatusDotColor(input: {
}
return null;
}
export function isEmphasizedStatusDotBucket(
bucket: SidebarStateBucket | null | undefined,
): boolean {
return bucket === "needs_input" || bucket === "attention";
}
@@ -1,2 +1,3 @@
export const STATUS_INDICATOR_DOT_SIZE = 8;
export const STATUS_INDICATOR_FILLED_DOT_SIZE = 6;
export const STATUS_INDICATOR_DOT_SIZE = STATUS_INDICATOR_FILLED_DOT_SIZE + 2;
export const STATUS_INDICATOR_ALERT_SIZE = 12;