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
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import { formatComposerContextReference } from "@t3tools/shared/composerContextR
import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
import { useRef, useState } from "react";
import { Platform } from "react-native";
import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
import { pullRequestComposerContext } from "../../lib/composerContext";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { uuidv4 } from "../../lib/uuid";
Expand Down Expand Up @@ -72,13 +70,11 @@ export function usePullRequestThreads(
const canAsk = !connected || canOperate;
const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
const navigation = useNavigation();
const selectAndroidWorkspace = useAndroidWorkspaceSelection();
const pending = useRef(false);
const previousPrompts = useRef(new Map<string, string>());
const openThread = (thread: LinkedThread) => {
const destination = { environmentId: params.environmentId, threadId: thread.id };
if (Platform.OS === "android") selectAndroidWorkspace({ name: "Thread", params: destination });
else navigation.navigate("Thread", destination);
navigation.navigate("Thread", destination);
};
const [busy, setBusy] = useState(false);
const ask = async (thread?: LinkedThread, task: PullRequestAgentTask = "ask") => {
Expand Down
25 changes: 17 additions & 8 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import {
} from "@t3tools/client-runtime/text-paste";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import {
CommonActions,
StackActions,
useFocusEffect,
useNavigation,
usePreventRemove,
type NavigationAction,
type NavigationProp,
} from "@react-navigation/native";
import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react";
import { Platform, Pressable, ScrollView, View } from "react-native";
Expand Down Expand Up @@ -143,6 +143,9 @@ import { appAtomRegistry } from "../../state/atom-registry";
import { serverEnvironment } from "../../state/server";
import { fileRoutePathSegments } from "../files/filePath";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
import { androidWorkspaceRootState } from "../../lib/android-workspace-navigation";
import { newTaskSubmitNavigationAction } from "./new-task-submit-navigation";

const COMPOSER_POPOVER_ENTERING = FadeInDown.duration(180).reduceMotion(ReduceMotion.System);

Expand Down Expand Up @@ -205,7 +208,8 @@ export function NewTaskDraftScreen(props: {
}) {
const projects = useProjects();
const flow = useNewTaskFlow();
const navigation = useNavigation();
const navigation = useNavigation<NavigationProp<ReactNavigation.RootParamList>>();
const workspaceDispatch = useWorkspaceTabs()?.dispatch;
const {
consumeShare,
getShare,
Expand Down Expand Up @@ -1334,13 +1338,18 @@ export function NewTaskDraftScreen(props: {
deferAttachmentCleanup: true,
});
}
if (Platform.OS === "android" && !queuesInsteadOfStarting)
workspaceDispatch?.({ type: "select", tab: "threads" });
setSubmitNavigationAction(
queuesInsteadOfStarting
? CommonActions.goBack()
: StackActions.replace("Thread", {
environmentId: String(message.environmentId),
threadId: String(message.threadId),
}),
newTaskSubmitNavigationAction({
state: androidWorkspaceRootState(navigation) ?? navigation.getState(),
params: {
environmentId: String(message.environmentId),
threadId: String(message.threadId),
},
queued: queuesInsteadOfStarting,
android: Platform.OS === "android",
}),
);
scheduleUnusedComposerAttachmentCleanup(draftSnapshot.attachments);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function ThreadPullRequestsSheet(
);
const openPullRequest = useOpenPullRequest({
replace: true,
mode: "workspace",
context: selectedThread ?? undefined,
});
const actions = useLinkedPullRequestActions({
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/new-task-submit-navigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { CommonActions, StackActions, type NavigationState } from "@react-navigation/native";
import { androidWorkspaceNavigationState } from "../../lib/android-workspace-navigation";

/** Called after the task is saved, against the stack that owns the draft sheet. */
export function newTaskSubmitNavigationAction(input: {
readonly state: NavigationState;
readonly params: ReactNavigation.RootParamList["Thread"];
readonly queued: boolean;
readonly android: boolean;
}) {
if (input.queued) return CommonActions.goBack();
if (input.android)
return {
...CommonActions.reset(
androidWorkspaceNavigationState(input.state, { name: "Thread", params: input.params }),
),
target: input.state.key,
};
return StackActions.replace("Thread", input.params);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,21 @@ export function useWorkspaceBackNavigation(tab: WorkspaceTab) {
}

export function useAndroidWorkspaceBack(tab: WorkspaceTab) {
const navigation = useNavigation();
const dispatch = useWorkspaceTabs()?.dispatch;
const goBack = useWorkspaceBackNavigation(tab);
useFocusEffect(
useCallback(() => {
if (Platform.OS !== "android") return;
dispatch?.({ type: "select", tab });
// A chat opened from a PR, or a PR from a chat, keeps the tab it was opened from.
if ((navigation.getState()?.index ?? 0) <= 1) dispatch?.({ type: "select", tab });
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
if (Keyboard.isVisible()) Keyboard.dismiss();
else goBack();
return true;
});
return () => subscription.remove();
}, [goBack, dispatch, tab]),
}, [navigation, goBack, dispatch, tab]),
);
return goBack;
}
181 changes: 168 additions & 13 deletions apps/mobile/src/lib/android-workspace-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as NodeModule from "node:module";
import { describe, expect, it } from "vite-plus/test";
import type { NavigationState } from "@react-navigation/native";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import {
androidWorkspaceTabFromNavigationState,
androidWorkspaceTabNavigationState,
Expand All @@ -14,18 +15,50 @@ import {
type AndroidWorkspaceResetAction,
} from "./android-workspace-navigation";

const require = NodeModule.createRequire(import.meta.url);
const nativePackage = require.resolve("@react-navigation/native/package.json");
const requireFromNative = NodeModule.createRequire(nativePackage);
const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
const requireFromCore = NodeModule.createRequire(corePackage);
const { CommonActions, StackActions, StackRouter } = requireFromCore(
"@react-navigation/routers",
) as {
readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
readonly StackActions: typeof import("@react-navigation/native").StackActions;
readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
};
function loadRouters() {
const require = NodeModule.createRequire(import.meta.url);
const nativePackage = require.resolve("@react-navigation/native/package.json");
const corePackage = NodeModule.createRequire(nativePackage).resolve(
"@react-navigation/core/package.json",
);
return NodeModule.createRequire(corePackage)("@react-navigation/routers") as {
readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
readonly StackActions: typeof import("@react-navigation/native").StackActions;
readonly StackRouter: typeof import("@react-navigation/native").StackRouter;
};
}

vi.mock("@react-navigation/native", () => ({
...loadRouters(),
useNavigation: () => navigation,
useRoute: () => current.routes[current.index],
useFocusEffect: (callback: () => unknown) => callback(),
}));
vi.mock("react", () => ({ useCallback: (callback: unknown) => callback }));
vi.mock("react-native", () => ({
Platform: { OS: "android" },
Keyboard: { isVisible: () => false },
BackHandler: {
addEventListener: (_event: string, callback: () => boolean) => {
hardwareBack = callback;
return { remove: () => {} };
},
},
}));
vi.mock("../features/workspace/WorkspaceTabsContext", () => ({
useWorkspaceTabs: () => ({ dispatch: selectTab }),
}));
vi.mock("../state/entities", () => ({ useThreadShell: () => ({ projectId: "project-a" }) }));
vi.mock("./openExternalUrl", () => ({ tryOpenExternalUrl: vi.fn() }));

import { useOpenPullRequest } from "../native/useOpenPullRequest";
import {
useAndroidWorkspaceBack,
useWorkspaceListNavigation,
} from "../features/workspace/useAndroidWorkspaceNavigation";
import { newTaskSubmitNavigationAction } from "../features/threads/new-task-submit-navigation";

const { CommonActions, StackActions, StackRouter } = loadRouters();
const router = StackRouter({});
const options = {
routeNames: [
Expand All @@ -34,10 +67,12 @@ const options = {
"ThreadFiles",
"ThreadTerminal",
"GitOverview",
"ThreadPullRequests",
"PullRequest",
"PullRequestFile",
"PullRequestReviewers",
"SettingsSheet",
"NewTaskSheet",
],
routeParamList: {},
routeGetIdList: {},
Expand Down Expand Up @@ -88,7 +123,54 @@ function names(state: ReturnType<typeof router.getInitialState>) {
return state.routes.map((route) => route.name);
}

let current = router.getInitialState(options);
let hardwareBack: (() => boolean) | undefined;
const selectTab = vi.fn();
type RouterAction = Parameters<typeof router.getStateForAction>[1];
const navigation = {
getState: () => current,
getParent: () => undefined,
canGoBack: () => router.getStateForAction(current, CommonActions.goBack(), options) !== null,
goBack: () => {
current = apply(current, CommonActions.goBack());
},
navigate: (name: string, params: object) => {
current = apply(current, CommonActions.navigate(name, params));
},
dispatch: (action: RouterAction | ((state: NavigationState) => RouterAction)) => {
current = apply(current, typeof action === "function" ? action(current) : action);
},
};

beforeEach(() => {
current = router.getInitialState(options);
hardwareBack = undefined;
selectTab.mockClear();
});

describe("Android workspace navigation", () => {
it("clears unrelated detail history when selecting a PR from the sidebar badge sheet", async () => {
current = apply(current, StackActions.push(thread.name, thread.params));
current = apply(
current,
StackActions.push("ThreadPullRequests", {
environmentId: "environment-b",
threadId: "thread-b",
}),
);
const home = current.routes[0];
const context = { environmentId: "environment-b", projectId: "project-b" };
await useOpenPullRequest({ replace: true, mode: "workspace", context })(
"https://github.com/owner/repository/pull/42",
);
expect(names(current)).toEqual(["Home", "PullRequest"]);
expect(current.routes[0]).toEqual(home);
expect(current.routes[1]?.params).toEqual({ ...pullRequest.params, ...context });
navigation.goBack();
expect(current.routes).toEqual([home]);
expect(navigation.canGoBack()).toBe(false);
});

it("replaces cross-tab history with one detail and returns directly to its list", () => {
const previous = legacyHistory();
const state = select(previous, pullRequest);
Expand Down Expand Up @@ -316,3 +398,76 @@ describe("Android Settings tab", () => {
expect(selectedTab(list, "pullRequests")).toBe("pullRequests");
});
});

describe("Android contextual navigation", () => {
it.each([false, true])(
"restores the original chat history after a PR link (replace sheet: %s)",
async (replace) => {
current = apply(
current,
StackActions.push("Thread", { ...thread.params, environmentId: "environment-b" }),
);
current = apply(current, StackActions.push(thread.name, thread.params));
const previous = current.routes;
if (replace) current = apply(current, StackActions.push("GitOverview", thread.params));
await useOpenPullRequest({ replace })("https://github.com/owner/repository/pull/42");
expect(current.routes.slice(0, -1)).toEqual(previous);
expect(current.routes.at(-1)).toMatchObject(pullRequest);
const back = useAndroidWorkspaceBack("pullRequests");
if (replace) {
if (!hardwareBack?.()) navigation.goBack();
} else back();
expect(current.routes).toEqual(previous);
expect(selectTab).not.toHaveBeenCalled();
},
);

it("returns through detail history on Back but clears it on an explicit list switch", () => {
const previous = legacyHistory();
current = apply(
previous,
StackActions.push("Thread", { ...thread.params, environmentId: "environment-b" }),
);
useAndroidWorkspaceBack("threads")();
expect(current.routes).toEqual(previous.routes);
expect(selectTab).not.toHaveBeenCalled();
useWorkspaceListNavigation("threads")();
expect(current.routes).toEqual([previous.routes[0]]);
expect(selectTab).toHaveBeenLastCalledWith({ type: "select", tab: "threads" });
expect(navigation.canGoBack()).toBe(false);
});

it.each([
{ android: true, queued: false },
{ android: true, queued: true },
{ android: false, queued: false },
])(
"preserves the appropriate return destination after saving a PR draft: %o",
({ android, queued }) => {
const previous = legacyHistory();
const draft = apply(
previous,
StackActions.push("NewTaskSheet", {
screen: "NewTaskDraft",
params: { draftId: "draft-pr" },
}),
);
const params = { ...thread.params, threadId: "new-thread" };
const saved = apply(
draft,
newTaskSubmitNavigationAction({ state: draft, params, android, queued }),
);
if (queued) {
expect(saved.routes).toEqual(previous.routes);
expect(apply(saved, CommonActions.goBack()).routes).toEqual(previous.routes.slice(0, -1));
} else {
expect(saved.routes.at(-1)).toMatchObject({ name: "Thread", params });
expect(saved.routes.at(-1)?.key).not.toBe(previous.routes[1]?.key);
const back = apply(saved, CommonActions.goBack());
expect(back.routes).toEqual(android ? [previous.routes[0]] : previous.routes);
if (android)
expect(router.getStateForAction(back, CommonActions.goBack(), options)).toBeNull();
}
},
);
});
Loading
Loading