Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public final class T3KeyboardCommandsView: ExpoView {
enabledCommand("focusSearch", input: "f", modifiers: .command, action: #selector(focusSearch), title: "Find"),
enabledCommand("focusSearch", input: "k", modifiers: .command, action: #selector(focusSearch), title: "Focus Search"),
enabledCommand("back", input: "[", modifiers: .command, action: #selector(goBack), title: "Back"),
enabledCommand("forward", input: "]", modifiers: .command, action: #selector(goForward), title: "Forward"),
enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"),
enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"),
enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"),
Expand Down Expand Up @@ -110,6 +111,7 @@ public final class T3KeyboardCommandsView: ExpoView {
@objc private func newTask() { emit("newTask") }
@objc private func focusSearch() { emit("focusSearch") }
@objc private func goBack() { emit("back") }
@objc private func goForward() { emit("forward") }
@objc private func openFiles() { emit("files") }
@objc private func openTerminal() { emit("terminal") }
@objc private func openReview() { emit("review") }
Expand Down
40 changes: 29 additions & 11 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { useResolveClassNames } from "uniwind";

Expand All @@ -24,6 +24,8 @@ import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen";
import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout";
import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider";
import { MobileNavigationHistoryProvider } from "./features/navigation/MobileNavigationHistoryProvider";
import { normalizeMobileNavigationPath } from "./features/navigation/mobile-navigation-history";
import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet";
import { ReviewSheet } from "./features/review/ReviewSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
Expand Down Expand Up @@ -394,6 +396,15 @@ function workspacePathFromState(state: NavigationState): string {
return path.startsWith("/") ? path : `/${path}`;
}

function activeNavigationTransitionKey(state: NavigationState): string {
const route = state.routes[state.index];
if (!route) {
return "empty";
}
const nestedState = route.state as NavigationState | undefined;
return nestedState ? `${route.key}/${activeNavigationTransitionKey(nestedState)}` : route.key;
}

// The drain hook subscribes to the outbox, all thread shells, projects, and
// connection statuses. Hosting it in a null-rendering leaf keeps those
// updates from re-rendering RootStackLayout (and with it every screen) on
Expand Down Expand Up @@ -433,20 +444,27 @@ function RootStackLayout(props: {
}, [navigation, pendingShare, props.state]);
// Full pathname (sheets included) for keyboard-command scoping; the
// workspace layout only reacts to the underlying non-overlay route.
const path = getPathFromState(props.state, navigationPathConfig);
const path = normalizeMobileNavigationPath(getPathFromState(props.state, navigationPathConfig));
const pathname = path.startsWith("/") ? path : `/${path}`;
const workspacePathname = workspacePathFromState(props.state);
const transitionKey = activeNavigationTransitionKey(props.state);
const navigationLocation = useMemo(
() => ({ pathname, transitionKey }),
[pathname, transitionKey],
);

return (
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
{props.children}
</AdaptiveWorkspaceLayout>
</ExistingThreadSettingsRouteProvider>
</HardwareKeyboardCommandProvider>
<MobileNavigationHistoryProvider location={navigationLocation}>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
<HardwareKeyboardCommandProvider pathname={pathname}>
<ThreadOutboxDrainWorker />
<ShowcaseCaptureCoordinator pathname={pathname} />
<ExistingThreadSettingsRouteProvider>
<AdaptiveWorkspaceLayout pathname={workspacePathname}>
{props.children}
</AdaptiveWorkspaceLayout>
</ExistingThreadSettingsRouteProvider>
</HardwareKeyboardCommandProvider>
</MobileNavigationHistoryProvider>
);
}

Expand Down
33 changes: 31 additions & 2 deletions apps/mobile/src/components/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { MenuAction } from "@react-native-menu/menu";
import type { ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { SymbolView, type AppSymbolName } from "./AppSymbol";
import { AppText as Text } from "./AppText";
import { ControlPillMenu } from "./ControlPill";
import { cn } from "../lib/cn";
import { MobileNavigationHistoryButtons } from "../features/navigation/MobileNavigationHistoryButtons";

export interface AndroidHeaderAction {
readonly accessibilityLabel: string;
Expand Down Expand Up @@ -48,9 +51,21 @@ export function AndroidScreenHeader(props: {
readonly trailing?: ReactNode;
readonly onBack?: () => void;
readonly embedded?: boolean;
readonly showNavigationHistory?: boolean;
readonly hideBottomBorder?: boolean;
}) {
const insets = useSafeAreaInsets();
const navigationHistoryVisible = !props.embedded && props.showNavigationHistory === true;
const actions = props.actions ?? [];
const collapseActions = navigationHistoryVisible && actions.length > 2;
const visibleActions = collapseActions ? actions.slice(0, 1) : actions;
const overflowActions = collapseActions ? actions.slice(1) : [];
const overflowMenuActions: MenuAction[] = overflowActions.map((action) => ({
id: action.accessibilityLabel,
title: action.accessibilityLabel,
...(typeof action.icon === "string" ? { image: action.icon } : {}),
...(action.disabled ? { attributes: { disabled: true } } : {}),
}));

return (
<View
Expand All @@ -63,7 +78,7 @@ export function AndroidScreenHeader(props: {
<View className="min-h-12 flex-row items-center gap-2">
{props.onBack ? (
<Pressable
accessibilityLabel="Navigate up"
accessibilityLabel="Back"
accessibilityRole="button"
hitSlop={8}
onPress={props.onBack}
Expand Down Expand Up @@ -92,7 +107,7 @@ export function AndroidScreenHeader(props: {
) : null}
</View>

{props.actions?.map((action) => (
{visibleActions.map((action) => (
<AndroidHeaderIconButton
key={action.accessibilityLabel}
accessibilityLabel={action.accessibilityLabel}
Expand All @@ -101,6 +116,20 @@ export function AndroidScreenHeader(props: {
onPress={action.onPress}
/>
))}
{overflowMenuActions.length > 0 ? (
<ControlPillMenu
actions={overflowMenuActions}
isAnchoredToRight
onPressAction={({ nativeEvent }) => {
overflowActions
.find((action) => action.accessibilityLabel === nativeEvent.event)
?.onPress();
}}
>
<AndroidHeaderIconButton accessibilityLabel="More actions" icon="ellipsis" />
</ControlPillMenu>
) : null}
{navigationHistoryVisible ? <MobileNavigationHistoryButtons grouped /> : null}
{props.trailing}
</View>
</View>
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/components/ControlPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ export function ControlPill(props: {
? props.disabled
? "accent-icon-subtle"
: "accent-primary-foreground"
: variant === "danger"
? "accent-danger-foreground"
: "accent-icon";
: props.disabled
? "accent-icon-subtle"
: variant === "danger"
? "accent-danger-foreground"
: "accent-icon";

const isCircle =
variant === "circle" || variant === "danger" || (variant === "primary" && !props.label);
Expand All @@ -110,6 +112,7 @@ export function ControlPill(props: {
: variant === "danger"
? "bg-danger"
: "bg-subtle",
props.disabled && variant !== "primary" && "opacity-55",
props.className,
);
const labelClassName = cn(
Expand Down
24 changes: 23 additions & 1 deletion apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { resolveMobileStageLabel } from "../../lib/mobileBranding";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { MobileNavigationHistoryButtons } from "../navigation/MobileNavigationHistoryButtons";
import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider";
import { createNativeNavigationHistoryItems } from "../navigation/native-navigation-history-items";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import {
createNativeMailSearchToolbarItem,
Expand Down Expand Up @@ -235,6 +238,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
}
/>

<MobileNavigationHistoryButtons />

<ControlPillMenu
actions={menuActions}
isAnchoredToRight
Expand Down Expand Up @@ -319,6 +324,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
}

function IosHomeHeader(props: HomeHeaderProps) {
const navigationHistory = useMobileNavigationHistory();
const searchBarRef = useRef<SearchBarCommands>(null);
const iconColor = useUniwindTheme()["--color-icon"];
// Thread List v2 lays the list out in fixed creation order, so the
Expand All @@ -337,18 +343,34 @@ function IosHomeHeader(props: HomeHeaderProps) {
...props,
listOrganization: !threadListV2Enabled,
});
const navigationHeaderItems = useMemo(
() =>
createNativeNavigationHistoryItems({
canGoBack: navigationHistory.canGoBack,
canGoForward: navigationHistory.canGoForward,
identifierPrefix: "home-navigation",
onBack: navigationHistory.back,
onForward: navigationHistory.forward,
}),
[navigationHistory],
);

return (
<>
<NativeStackScreenOptions
optionsVersion={filterMenu.items}
optionsVersion={{
canGoBack: navigationHistory.canGoBack,
canGoForward: navigationHistory.canGoForward,
filterMenuItems: filterMenu.items,
}}
options={{
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems:
Platform.OS === "ios"
? () => [
...navigationHeaderItems,
Comment thread
cursor[bot] marked this conversation as resolved.
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function HomeRouteScreen() {
options={{
...getConnectionAwareBrandHeaderOptions({
headerWidth: windowWidth,
trailingItemCount: Platform.OS === "ios" ? 3 : 1,
onOpenEnvironments: () =>
navigation.navigate("SettingsSheet", {
screen: "SettingsContent",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StackActions, useNavigation } from "@react-navigation/native";
import { useNavigation } from "@react-navigation/native";
import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
import {
useCallback,
Expand All @@ -17,12 +17,15 @@ import type { GitActionProgress } from "../../state/use-vcs-action-state";
import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay";
import {
dispatchHardwareKeyboardCommand,
getHardwareBackFallbackPath,
getHardwareKeyboardCommandRegistrationVersion,
getRegisteredHardwareKeyboardCommands,
hasHardwareBackTarget,
parseActiveThreadPath,
subscribeToHardwareKeyboardCommandRegistrations,
type HardwareKeyboardCommand,
} from "./hardwareKeyboardCommands";
import { useMobileNavigationHistory } from "../navigation/MobileNavigationHistoryProvider";

const EMPTY_COPY_FEEDBACK: GitActionProgress = {
phase: "idle",
Expand All @@ -36,6 +39,7 @@ export function HardwareKeyboardCommandProvider({
pathname,
}: PropsWithChildren<{ readonly pathname: string }>) {
const navigation = useNavigation();
const navigationHistory = useMobileNavigationHistory();
const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]);
const activeThread = useThreadShell(activeThreadRef);
const copyTarget = useMemo(
Expand Down Expand Up @@ -86,15 +90,22 @@ export function HardwareKeyboardCommandProvider({
const enabledCommands = useMemo(() => {
const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands());
commands.add("newTask");
if (pathname !== "/" || navigation.canGoBack()) commands.add("back");
if (hasHardwareBackTarget(pathname, navigationHistory.canGoBack)) commands.add("back");
if (navigationHistory.canGoForward) commands.add("forward");
if (activeThreadRef !== null) {
commands.add("files");
commands.add("terminal");
commands.add("review");
if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference");
}
return [...commands];
}, [pathname, registrationVersion, navigation]);
}, [
activeThreadRef,
navigationHistory.canGoBack,
navigationHistory.canGoForward,
pathname,
registrationVersion,
]);

const onCommand = useCallback(
(command: HardwareKeyboardCommand) => {
Expand Down Expand Up @@ -129,13 +140,18 @@ export function HardwareKeyboardCommandProvider({
return;
}
if (command === "back") {
if (navigation.canGoBack()) {
navigation.goBack();
if (navigationHistory.canGoBack) {
navigationHistory.back();
} else {
navigation.dispatch(StackActions.replace("Home"));
const fallbackPath = getHardwareBackFallbackPath(pathname);
if (fallbackPath) navigationHistory.replace(fallbackPath);
}
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}
if (command === "forward") {
navigationHistory.forward();
return;
}

const thread = parseActiveThreadPath(pathname);
if (!thread) return;
Expand All @@ -149,7 +165,7 @@ export function HardwareKeyboardCommandProvider({
navigation.navigate("ThreadReview", thread);
}
},
[copyTarget, navigation, pathname, showCopyFeedback],
[copyTarget, navigation, navigationHistory, pathname, showCopyFeedback],
);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vite-plus/test";

import { parseActiveThreadPath } from "./hardwareKeyboardCommands";
import {
getHardwareBackFallbackPath,
hasHardwareBackTarget,
parseActiveThreadPath,
} from "./hardwareKeyboardCommands";

describe("parseActiveThreadPath", () => {
it("extracts the active thread from thread subroutes", () => {
Expand All @@ -25,4 +29,39 @@ describe("parseActiveThreadPath", () => {
it("ignores malformed encoded route components", () => {
expect(parseActiveThreadPath("/threads/%E0%A4%A/thread-1")).toBeNull();
});

it("ignores search and hash suffixes", () => {
expect(parseActiveThreadPath("/threads/environment-1/thread-1?panel=details#turn-2")).toEqual({
environmentId: "environment-1",
threadId: "thread-1",
});
});
});

describe("hasHardwareBackTarget", () => {
it("keeps hardware Back available for a cold-start thread", () => {
expect(hasHardwareBackTarget("/threads/environment-1/thread-1", false)).toBe(true);
expect(hasHardwareBackTarget("/settings", false)).toBe(false);
expect(hasHardwareBackTarget("/settings", true)).toBe(true);
});
});

describe("getHardwareBackFallbackPath", () => {
it("returns Home from a cold-start thread root", () => {
expect(getHardwareBackFallbackPath("/threads/environment-1/thread-1")).toBe("/");
expect(getHardwareBackFallbackPath("/threads/environment-1/thread-1?panel=details")).toBe("/");
});

it("returns the thread from a cold-start thread subroute", () => {
expect(getHardwareBackFallbackPath("/threads/environment-1/thread-1/files/src/index.ts")).toBe(
"/threads/environment-1/thread-1",
);
expect(
getHardwareBackFallbackPath("/threads/environment-1/thread-1/review?file=README.md"),
).toBe("/threads/environment-1/thread-1");
});

it("returns no fallback outside a thread", () => {
expect(getHardwareBackFallbackPath("/settings")).toBeNull();
});
});
Loading
Loading