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.