diff --git a/apps/mobile/global.css b/apps/mobile/global.css
index a42afc74d92f..d514e10ec26c 100644
--- a/apps/mobile/global.css
+++ b/apps/mobile/global.css
@@ -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);
@@ -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);
diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx
index 7fe21fb44ff3..5206a4451bcf 100644
--- a/apps/mobile/src/components/AndroidScreenHeader.tsx
+++ b/apps/mobile/src/components/AndroidScreenHeader.tsx
@@ -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";
@@ -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");
@@ -35,16 +40,20 @@ export function AndroidHeaderIconButton(props: {
props.disabled && "opacity-55",
)}
>
-
+
+
+
);
}
+export const AndroidHeaderIconButton = AppHeaderIconButton;
+
export function AndroidScreenHeader(props: {
readonly title: string;
readonly subtitle?: string | null;
@@ -96,12 +105,14 @@ export function AndroidScreenHeader(props: {
{props.actions?.map((action) => (
-
))}
{props.trailing}
diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx
index 32f915e7af5c..3fa5b2941a85 100644
--- a/apps/mobile/src/components/AppSymbol.tsx
+++ b/apps/mobile/src/components/AppSymbol.tsx
@@ -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 (
+
+ );
+ }
+
if (Platform.OS !== "android") {
return ;
}
- 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);
diff --git a/apps/mobile/src/components/StatusPulse.tsx b/apps/mobile/src/components/StatusPulse.tsx
new file mode 100644
index 000000000000..e2a58437d82d
--- /dev/null
+++ b/apps/mobile/src/components/StatusPulse.tsx
@@ -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 | 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 {props.children};
+}
+
+/** A shared, display-rate-independent status pulse for persistent activity indicators. */
+export function StatusPulse(props: { readonly active: boolean; readonly children: ReactNode }) {
+ return props.active ? {props.children} : props.children;
+}
diff --git a/apps/mobile/src/features/terminal/TerminalRunningIndicator.tsx b/apps/mobile/src/features/terminal/TerminalRunningIndicator.tsx
new file mode 100644
index 000000000000..ee9b086f8998
--- /dev/null
+++ b/apps/mobile/src/features/terminal/TerminalRunningIndicator.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/terminal/terminalRunningStatus.test.ts b/apps/mobile/src/features/terminal/terminalRunningStatus.test.ts
new file mode 100644
index 000000000000..939e1b5bccc6
--- /dev/null
+++ b/apps/mobile/src/features/terminal/terminalRunningStatus.test.ts
@@ -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");
+ });
+});
diff --git a/apps/mobile/src/features/terminal/terminalRunningStatus.ts b/apps/mobile/src/features/terminal/terminalRunningStatus.ts
new file mode 100644
index 000000000000..d9e46a0c1a31
--- /dev/null
+++ b/apps/mobile/src/features/terminal/terminalRunningStatus.ts
@@ -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`;
+}
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index 31b65f49353a..30b7fdb783a0 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -10,11 +10,15 @@ import {
requiresDefaultBranchConfirmation,
resolveQuickAction,
} from "@t3tools/client-runtime/state/vcs";
+import type { MenuAction } from "@react-native-menu/menu";
import { useNavigation } from "@react-navigation/native";
+import { AppHeaderIconButton } from "../../components/AndroidScreenHeader";
+import { ControlPillMenu } from "../../components/ControlPill";
import { NativeHeaderToolbar } from "../../native/StackHeader";
import { useCallback, useMemo } from "react";
import { Alert } from "react-native";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
+import { useThemeColor } from "../../lib/useThemeColor";
import {
basename,
getTerminalStatusLabel,
@@ -22,6 +26,10 @@ import {
projectScriptMenuLabel,
type TerminalMenuSession,
} from "../terminal/terminalMenu";
+import {
+ countRunningTerminalSessions,
+ terminalRunningSessionLabel,
+} from "../terminal/terminalRunningStatus";
function truncateMiddle(value: string, maxLength: number): string {
if (value.length <= maxLength) {
@@ -106,6 +114,102 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
readonly onRunProjectScript: (script: ProjectScript) => Promise;
};
+function TerminalHeaderMenuButton(props: ThreadGitControlsProps) {
+ const terminalActiveColor = useThemeColor("--color-terminal-active");
+ const terminalRunningLabel = terminalRunningSessionLabel(
+ countRunningTerminalSessions(props.terminalSessions),
+ );
+ const actions = useMemo(
+ () => [
+ ...(props.projectScripts.length > 0
+ ? props.projectScripts.map((script) => ({
+ id: `project-script:${script.id}`,
+ image: projectScriptMenuIcon(script.icon),
+ subtitle: script.command,
+ title: projectScriptMenuLabel(script),
+ }))
+ : [
+ {
+ id: "project-script:none",
+ image: "play",
+ title: "No project scripts",
+ subtitle: "This project has no saved scripts yet",
+ attributes: { disabled: true } as const,
+ },
+ ]),
+ ...props.terminalSessions.map((session) => ({
+ id: `terminal-session:${session.terminalId}`,
+ image: "terminal",
+ subtitle: [
+ getTerminalStatusLabel({
+ status: session.status,
+ hasRunningSubprocess: session.hasRunningSubprocess,
+ }),
+ basename(session.cwd),
+ ]
+ .filter(Boolean)
+ .join(" · "),
+ title: session.displayLabel,
+ })),
+ {
+ id: "terminal-new",
+ image: "plus",
+ subtitle: "Start another shell for this thread",
+ title: "Open new terminal",
+ },
+ ],
+ [props.projectScripts, props.terminalSessions],
+ );
+ const handleAction = useCallback(
+ (event: { nativeEvent: { event: string } }) => {
+ const id = event.nativeEvent.event;
+ if (id === "terminal-new") {
+ props.onOpenNewTerminal();
+ return;
+ }
+ if (id.startsWith("terminal-session:")) {
+ props.onOpenTerminal(id.slice("terminal-session:".length));
+ return;
+ }
+ if (id.startsWith("project-script:")) {
+ const scriptId = id.slice("project-script:".length);
+ const script = props.projectScripts.find((candidate) => candidate.id === scriptId);
+ if (script) {
+ void props.onRunProjectScript(script);
+ }
+ }
+ },
+ [props.onOpenNewTerminal, props.onOpenTerminal, props.onRunProjectScript, props.projectScripts],
+ );
+
+ const button = (
+
+ );
+
+ if (!props.canOpenTerminal) {
+ return button;
+ }
+
+ return (
+
+ {button}
+
+ );
+}
+
function useThreadGitControlModel(props: ThreadGitMenuProps) {
const navigation = useNavigation();
const environmentId = props.environmentId;
@@ -252,60 +356,9 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
return useMemo(
() => ({
terminal: {
- accessibilityLabel: "Open terminal",
- disabled: !props.canOpenTerminal,
- icon: { name: "terminal", type: "sfSymbol" },
- identifier: "thread-right-terminal",
- label: "Terminal",
- menu: {
- items: [
- ...props.projectScripts.map((script) => ({
- description: script.command,
- icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
- label: projectScriptMenuLabel(script),
- onPress: () => void props.onRunProjectScript(script),
- type: "action" as const,
- })),
- ...(props.projectScripts.length === 0
- ? [
- {
- description: "This project has no saved scripts yet",
- disabled: true,
- icon: { name: "play", type: "sfSymbol" as const },
- label: "No project scripts",
- onPress: () => {},
- type: "action" as const,
- },
- ]
- : []),
- ...props.terminalSessions.map((session) => ({
- description: [
- getTerminalStatusLabel({
- status: session.status,
- hasRunningSubprocess: session.hasRunningSubprocess,
- }),
- basename(session.cwd),
- ]
- .filter(Boolean)
- .join(" · "),
- icon: { name: "terminal", type: "sfSymbol" as const },
- label: session.displayLabel,
- onPress: () => props.onOpenTerminal(session.terminalId),
- type: "action" as const,
- })),
- {
- description: "Start another shell for this thread",
- icon: { name: "plus", type: "sfSymbol" },
- label: "Open new terminal",
- onPress: props.onOpenNewTerminal,
- type: "action",
- },
- ],
- title: "Terminal",
- },
- sharesBackground: true,
- type: "menu",
- variant: "plain",
+ element: ,
+ hidesSharedBackground: true,
+ type: "custom",
},
files: {
accessibilityLabel: "Open files",
@@ -425,60 +478,9 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
/>
) : null}
{showActionControls ? (
-
- {props.projectScripts.length > 0 ? (
- props.projectScripts.map((script) => (
- void props.onRunProjectScript(script)}
- subtitle={script.command}
- >
-
- {projectScriptMenuLabel(script)}
-
-
- ))
- ) : (
- {}}
- subtitle="This project has no saved scripts yet"
- >
- No project scripts
-
- )}
- {props.terminalSessions.map((session) => (
- props.onOpenTerminal(session.terminalId)}
- subtitle={[
- getTerminalStatusLabel({
- status: session.status,
- hasRunningSubprocess: session.hasRunningSubprocess,
- }),
- basename(session.cwd),
- ]
- .filter(Boolean)
- .join(" · ")}
- >
- {session.displayLabel}
-
- ))}
-
- Open new terminal
-
-
+
+
+
) : null}
{showActionControls && props.showDirectFileControl ? (
{
if (!environmentId) {
@@ -422,6 +429,7 @@ function ThreadRouteContent(
// panes bring their own nested native headers (which underlap the status
// bar); elsewhere the pane content pads itself below the top inset.
const safeAreaInsets = useSafeAreaInsets();
+ const terminalActiveColor = useThemeColor("--color-terminal-active");
const inspectorHeaderInset = Platform.OS === "ios" ? 0 : safeAreaInsets.top;
const GitInspector = useCallback(
() => (
@@ -698,9 +706,14 @@ function ThreadRouteContent(
}
if (selectedThreadProject?.workspaceRoot) {
actions.push({
- accessibilityLabel: "Open terminal",
+ accessibilityLabel:
+ terminalRunningLabel === null
+ ? "Open terminal"
+ : `Open terminal, ${terminalRunningLabel}`,
icon: "terminal",
onPress: () => handleOpenTerminal(null),
+ pulse: terminalRunningLabel !== null,
+ tintColor: terminalRunningLabel === null ? undefined : terminalActiveColor,
});
}
actions.push({
@@ -725,6 +738,8 @@ function ThreadRouteContent(
props.onReturnToThread,
selectedThreadCwd,
selectedThreadProject?.workspaceRoot,
+ terminalActiveColor,
+ terminalRunningLabel,
]);
// Deep links / cold starts land with Thread as the ONLY route, where the
@@ -816,6 +831,7 @@ function ThreadRouteContent(
<>
{activeInspectorRenderer ? : null}
Boolean(part))
+ .join(", ");
const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string =>
Boolean(part),
);
@@ -595,6 +605,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
{thread.title}
+
{statusPill}
{timestamp}
+
{statusPill}
) : null}
+
>
);
@@ -791,7 +803,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
variant === "card" ? (
{
@@ -831,7 +843,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
) : (
+
);
diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/src/lib/mobileDefaultTheme.ts
index 66afae46473b..a6304cb943a7 100644
--- a/apps/mobile/src/lib/mobileDefaultTheme.ts
+++ b/apps/mobile/src/lib/mobileDefaultTheme.ts
@@ -41,6 +41,7 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
"--color-icon": "#262626",
"--color-icon-muted": "#525252",
"--color-icon-subtle": "#a3a3a3",
+ "--color-terminal-active": "#0d9488",
"--color-header": "rgba(255, 255, 255, 0.97)",
"--color-header-border": "rgba(0, 0, 0, 0.06)",
"--color-glass-surface": "rgba(255, 255, 255, 0.72)",
@@ -108,6 +109,7 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
"--color-icon": "#f5f5f5",
"--color-icon-muted": "#a3a3a3",
"--color-icon-subtle": "#8e8e93",
+ "--color-terminal-active": "rgba(94, 234, 212, 0.9)",
"--color-header": "rgba(10, 10, 10, 0.97)",
"--color-header-border": "rgba(255, 255, 255, 0.06)",
"--color-glass-surface": "rgba(23, 23, 23, 0.78)",
diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts
index d5744952bba4..caba758d8dab 100644
--- a/apps/mobile/src/lib/mobileTheme.test.ts
+++ b/apps/mobile/src/lib/mobileTheme.test.ts
@@ -163,12 +163,13 @@ describe("mobile themes", () => {
it("maps semantic palette roles onto every mobile color variable", () => {
const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light");
- expect(Object.keys(variables)).toHaveLength(65);
+ expect(Object.keys(variables)).toHaveLength(66);
expect(variables["--color-sheet-solid"]).toBe(
themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome),
);
expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]);
expect(variables["--color-primary-shadow"]).toBe("#000000");
+ expect(variables["--color-terminal-active"]).toBe("#0d9488");
expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)");
expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)");
expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/);
diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts
index 36de7f979da6..5838afc850b2 100644
--- a/apps/mobile/src/lib/mobileTheme.ts
+++ b/apps/mobile/src/lib/mobileTheme.ts
@@ -249,6 +249,7 @@ export function createMobileThemeVariables(
"--color-icon": c.text,
"--color-icon-muted": c.iconMuted,
"--color-icon-subtle": c.secondaryLabel,
+ "--color-terminal-active": appearance === "dark" ? "rgba(94, 234, 212, 0.9)" : "#0d9488",
"--color-header": withAlpha(c.toolbar, 0.97),
"--color-header-border": c.toolbarBorder,
"--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74),
diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx
index a524d5152473..fde19c52bb1d 100644
--- a/apps/mobile/src/native/StackHeader.tsx
+++ b/apps/mobile/src/native/StackHeader.tsx
@@ -336,6 +336,17 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null {
};
}
+ if (typeName === "NativeHeaderToolbarCustom") {
+ const element = Children.toArray(child.props.children).find((entry) => isValidElement(entry));
+ return element
+ ? {
+ type: "custom",
+ element,
+ hidesSharedBackground: Boolean(child.props.hidesSharedBackground),
+ }
+ : null;
+ }
+
if (typeName === "NativeHeaderToolbarSpacer") {
return {
type: "spacing",
@@ -441,6 +452,14 @@ function NativeHeaderToolbarMenuAction(_props: {
}
NativeHeaderToolbarMenuAction.displayName = "NativeHeaderToolbarMenuAction";
+function NativeHeaderToolbarCustom(_props: {
+ readonly children: ReactElement;
+ readonly hidesSharedBackground?: boolean;
+}) {
+ return null;
+}
+NativeHeaderToolbarCustom.displayName = "NativeHeaderToolbarCustom";
+
function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) {
return null;
}
@@ -462,6 +481,7 @@ NativeHeaderToolbarSearchBarSlot.displayName = "NativeHeaderToolbarSearchBarSlot
export const NativeHeaderToolbar = Object.assign(NativeHeaderToolbarRoot, {
Button: NativeHeaderToolbarButton,
+ Custom: NativeHeaderToolbarCustom,
Label: NativeHeaderToolbarLabel,
Menu: Object.assign(NativeHeaderToolbarMenu, {
Action: NativeHeaderToolbarMenuAction,
diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts
index 328557a2005d..2d6753bda336 100644
--- a/apps/mobile/src/state/use-terminal-session.ts
+++ b/apps/mobile/src/state/use-terminal-session.ts
@@ -5,9 +5,13 @@ import {
type KnownTerminalSession,
type TerminalSessionState,
} from "@t3tools/client-runtime/state/terminal";
+import { useAtomValue } from "@effect/atom-react";
import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useMemo } from "react";
+import { countRunningTerminalSessions } from "../features/terminal/terminalRunningStatus";
import { useEnvironmentQuery } from "./query";
import { terminalEnvironment } from "./terminal";
@@ -80,3 +84,32 @@ export function useKnownTerminalSessions(input: {
);
}, [input.environmentId, input.threadId, metadata.data]);
}
+
+const EMPTY_RUNNING_TERMINAL_COUNT_ATOM = Atom.make(0).pipe(
+ Atom.withLabel("mobile-terminal-running-count:empty"),
+);
+
+const threadRunningTerminalCountAtom = Atom.family((key: string) => {
+ const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId];
+ return Atom.make((get) => {
+ const result = get(
+ terminalEnvironment.metadata({
+ environmentId,
+ input: null,
+ }),
+ );
+ const summaries = Option.getOrElse(AsyncResult.value(result), () => []);
+ return countRunningTerminalSessions(summaries, threadId);
+ }).pipe(Atom.withLabel(`mobile-terminal-running-count:${key}`));
+});
+
+export function useThreadRunningTerminalCount(input: {
+ readonly environmentId: EnvironmentId | null;
+ readonly threadId: ThreadId | null;
+}): number {
+ return useAtomValue(
+ input.environmentId === null || input.threadId === null
+ ? EMPTY_RUNNING_TERMINAL_COUNT_ATOM
+ : threadRunningTerminalCountAtom(JSON.stringify([input.environmentId, input.threadId])),
+ );
+}
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 70b3cccc962a..5cb20744d6f6 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -8,6 +8,10 @@ On web and desktop, drag a pinned thread to change its position. On mobile, open
and choose **Move up** or **Move down**. The order is stored by the server and appears on your
other connected devices.
+On mobile, a pulsing terminal icon on a thread or its header means that one or more terminals for
+that thread have a process running. The icon remains still when the device's reduced-motion setting
+is enabled.
+
If reordering is unavailable for one environment, update the T3 Code server running in that
environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
their pinned threads keep the default newest-first order below the ones you have arranged.