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