Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
--color-icon: #262626;
--color-icon-muted: #525252;
--color-icon-subtle: #a3a3a3;
--color-terminal-active: #0d9488;

/* Header / glass chrome */
--color-header: rgba(255, 255, 255, 0.97);
Expand Down Expand Up @@ -165,6 +166,7 @@
--color-icon: #f5f5f5;
--color-icon-muted: #a3a3a3;
--color-icon-subtle: #8e8e93;
--color-terminal-active: rgba(94, 234, 212, 0.9);

/* Header / glass chrome */
--color-header: rgba(10, 10, 10, 0.97);
Expand Down
29 changes: 20 additions & 9 deletions apps/mobile/src/components/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { Pressable, View, type ColorValue } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView, type AppSymbolName } from "./AppSymbol";
import { StatusPulse } from "./StatusPulse";
import { AppText as Text } from "./AppText";
import { cn } from "../lib/cn";
import { useThemeColor } from "../lib/useThemeColor";
Expand All @@ -12,13 +13,17 @@ export interface AndroidHeaderAction {
readonly icon: AppSymbolName;
readonly onPress: () => void;
readonly disabled?: boolean;
readonly pulse?: boolean;
readonly tintColor?: ColorValue;
}

export function AndroidHeaderIconButton(props: {
export function AppHeaderIconButton(props: {
readonly accessibilityLabel: string;
readonly icon: AppSymbolName;
readonly onPress?: () => void;
readonly disabled?: boolean;
readonly pulse?: boolean;
readonly tintColor?: ColorValue;
}) {
const foregroundColor = useThemeColor("--color-foreground");
const disabledColor = useThemeColor("--color-icon-subtle");
Expand All @@ -35,16 +40,20 @@ export function AndroidHeaderIconButton(props: {
props.disabled && "opacity-55",
)}
>
<SymbolView
name={props.icon}
size={20}
tintColor={props.disabled ? disabledColor : foregroundColor}
type="monochrome"
/>
<StatusPulse active={props.pulse === true && !props.disabled}>
<SymbolView
name={props.icon}
size={20}
tintColor={props.disabled ? disabledColor : (props.tintColor ?? foregroundColor)}
type="monochrome"
/>
</StatusPulse>
</Pressable>
);
}

export const AndroidHeaderIconButton = AppHeaderIconButton;

export function AndroidScreenHeader(props: {
readonly title: string;
readonly subtitle?: string | null;
Expand Down Expand Up @@ -96,12 +105,14 @@ export function AndroidScreenHeader(props: {
</View>

{props.actions?.map((action) => (
<AndroidHeaderIconButton
<AppHeaderIconButton
key={action.accessibilityLabel}
accessibilityLabel={action.accessibilityLabel}
disabled={action.disabled}
icon={action.icon}
onPress={action.onPress}
pulse={action.pulse}
tintColor={action.tintColor}
/>
))}
{props.trailing}
Expand Down
20 changes: 18 additions & 2 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,28 @@ export type { SFSymbol } from "expo-symbols";
export type AppSymbolName = SymbolViewProps["name"];

export function SymbolView(props: SymbolViewProps) {
const materialName = typeof props.name === "string" ? undefined : props.name.android;
const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios;

// The terminal status glyph is a product status indicator, so it must not
// switch visual language between SF Symbols and Tabler across platforms.
if (sfSymbol === "terminal" || materialName === "terminal") {
return (
<IconTerminal2
accessibilityLabel={props.accessibilityLabel}
color={props.tintColor}
size={props.size}
strokeWidth={2}
style={props.style}
testID={props.testID}
/>
);
}

if (Platform.OS !== "android") {
return <ExpoSymbolView {...props} />;
}

const materialName = typeof props.name === "string" ? undefined : props.name.android;
const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios;
const AndroidIcon =
(materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ??
(sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined);
Expand Down
79 changes: 79 additions & 0 deletions apps/mobile/src/components/StatusPulse.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect, type ReactNode } from "react";
import Animated, { makeMutable, useAnimatedStyle, useReducedMotion } from "react-native-reanimated";

const STATUS_PULSE_STEPS = [
{ delayMs: 800, opacity: 0.875 },
{ delayMs: 50, opacity: 0.75 },
{ delayMs: 50, opacity: 0.625 },
{ delayMs: 50, opacity: 0.5 },
{ delayMs: 800, opacity: 0.625 },
{ delayMs: 50, opacity: 0.75 },
{ delayMs: 50, opacity: 0.875 },
{ delayMs: 50, opacity: 1 },
] as const;

const sharedStatusPulseOpacity = makeMutable(1);
let activeStatusPulseCount = 0;
let statusPulseStep = 0;
let statusPulseTimer: ReturnType<typeof setTimeout> | null = null;

function stopSharedStatusPulse() {
if (statusPulseTimer !== null) {
clearTimeout(statusPulseTimer);
statusPulseTimer = null;
}
statusPulseStep = 0;
sharedStatusPulseOpacity.value = 1;
}

function scheduleSharedStatusPulse() {
if (activeStatusPulseCount === 0 || statusPulseTimer !== null) {
return;
}

const step = STATUS_PULSE_STEPS[statusPulseStep];
statusPulseTimer = setTimeout(() => {
statusPulseTimer = null;
if (activeStatusPulseCount === 0) {
stopSharedStatusPulse();
return;
}
sharedStatusPulseOpacity.value = step.opacity;
statusPulseStep = (statusPulseStep + 1) % STATUS_PULSE_STEPS.length;
scheduleSharedStatusPulse();
}, step.delayMs);
}

function subscribeToSharedStatusPulse() {
activeStatusPulseCount += 1;
scheduleSharedStatusPulse();
return () => {
activeStatusPulseCount = Math.max(0, activeStatusPulseCount - 1);
if (activeStatusPulseCount === 0) {
stopSharedStatusPulse();
}
};
}

function ActiveStatusPulse(props: { readonly children: ReactNode }) {
const reduceMotion = useReducedMotion();

useEffect(() => {
if (reduceMotion) {
return;
}
return subscribeToSharedStatusPulse();
}, [reduceMotion]);

const animatedStyle = useAnimatedStyle(
() => ({ opacity: reduceMotion ? 1 : sharedStatusPulseOpacity.value }),
[reduceMotion],
);

return <Animated.View style={animatedStyle}>{props.children}</Animated.View>;
}

/** A shared, display-rate-independent status pulse for persistent activity indicators. */
export function StatusPulse(props: { readonly active: boolean; readonly children: ReactNode }) {
return props.active ? <ActiveStatusPulse>{props.children}</ActiveStatusPulse> : props.children;
}
31 changes: 31 additions & 0 deletions apps/mobile/src/features/terminal/TerminalRunningIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { View } from "react-native";

import { SymbolView } from "../../components/AppSymbol";
import { StatusPulse } from "../../components/StatusPulse";
import { useThemeColor } from "../../lib/useThemeColor";
import { terminalRunningSessionLabel } from "./terminalRunningStatus";

export function TerminalRunningIndicator(props: {
readonly sessionCount: number;
readonly size?: number;
}) {
const activeColor = useThemeColor("--color-terminal-active");
const accessibilityLabel = terminalRunningSessionLabel(props.sessionCount);

if (accessibilityLabel === null) {
return null;
}

return (
<View accessibilityLabel={accessibilityLabel} accessibilityRole="image">
<StatusPulse active>
<SymbolView
name="terminal"
size={props.size ?? 13}
tintColor={activeColor}
type="monochrome"
/>
</StatusPulse>
</View>
);
}
32 changes: 32 additions & 0 deletions apps/mobile/src/features/terminal/terminalRunningStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vite-plus/test";

import { countRunningTerminalSessions, terminalRunningSessionLabel } from "./terminalRunningStatus";

describe("countRunningTerminalSessions", () => {
it("counts only running terminals for the requested thread", () => {
expect(
countRunningTerminalSessions(
[
{ threadId: "thread-1", hasRunningSubprocess: true },
{ threadId: "thread-1", hasRunningSubprocess: false },
{ threadId: "thread-2", hasRunningSubprocess: true },
],
"thread-1",
),
).toBe(1);
});
});

describe("terminalRunningSessionLabel", () => {
it("hides idle terminal state", () => {
expect(terminalRunningSessionLabel(0)).toBeNull();
});

it("describes one terminal with a running process", () => {
expect(terminalRunningSessionLabel(1)).toBe("1 terminal has a running process");
});

it("describes multiple terminals with running processes", () => {
expect(terminalRunningSessionLabel(2)).toBe("2 terminals have running processes");
});
});
24 changes: 24 additions & 0 deletions apps/mobile/src/features/terminal/terminalRunningStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export function countRunningTerminalSessions(
summaries: ReadonlyArray<{
readonly hasRunningSubprocess: boolean;
readonly threadId?: string;
}>,
threadId?: string,
): number {
let count = 0;
for (const summary of summaries) {
if ((threadId === undefined || summary.threadId === threadId) && summary.hasRunningSubprocess) {
count += 1;
}
}
return count;
}

export function terminalRunningSessionLabel(sessionCount: number): string | null {
if (sessionCount <= 0) {
return null;
}
return sessionCount === 1
? "1 terminal has a running process"
: `${sessionCount} terminals have running processes`;
}
Loading
Loading