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:
co-authored by
Christoph Leiter
parent
6ae6231d1f
commit
c0daf8e066
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user