Fix optimistic workspace creation on Android

This commit is contained in:
Mohamed Boudra
2026-04-22 11:03:35 +07:00
parent 7b9cc3d423
commit 42cdabdf57
30 changed files with 1533 additions and 165 deletions
@@ -69,9 +69,11 @@ function clearConsumedOpenIntent(input: {
serverId: string;
workspaceId: string;
}) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
input.navigation.setParams({ open: undefined });
stripOpenSearchParamFromBrowserUrl();
if (isWeb) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
stripOpenSearchParamFromBrowserUrl();
}
}
export default function HostWorkspaceLayout() {
@@ -13,8 +13,9 @@ const {
theme,
mockClient,
mergeWorkspacesMock,
setAgentsMock,
navigateMock,
saveDraftInputMock,
queueDraftSubmissionMock,
createdAgent,
createdWorkspace,
prItem,
@@ -113,8 +114,9 @@ const {
theme,
mockClient,
mergeWorkspacesMock: vi.fn(),
setAgentsMock: vi.fn(),
navigateMock: vi.fn(),
saveDraftInputMock: vi.fn(),
queueDraftSubmissionMock: vi.fn(),
createdAgent,
createdWorkspace,
prItem,
@@ -159,6 +161,21 @@ vi.mock("lucide-react-native", () => {
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({
testID,
style,
...props
}: React.HTMLAttributes<HTMLDivElement> & { testID?: string; style?: unknown }) => {
const flattenedStyle = Array.isArray(style)
? Object.assign({}, ...style.filter(Boolean))
: style;
return <div {...props} data-testid={testID} style={flattenedStyle as React.CSSProperties} />;
},
},
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
@@ -168,7 +185,6 @@ vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: unknown) => unknown) =>
selector({
mergeWorkspaces: mergeWorkspacesMock,
setAgents: setAgentsMock,
}),
normalizeWorkspaceDescriptor: (workspace: unknown) => workspace,
}));
@@ -195,6 +211,35 @@ vi.mock("@/utils/workspace-navigation", () => ({
navigateToPreparedWorkspaceTab: navigateMock,
}));
vi.mock("@/stores/draft-keys", () => ({
buildDraftStoreKey: ({
serverId,
agentId,
draftId,
}: {
serverId: string;
agentId: string;
draftId?: string | null;
}) => (draftId ? `draft:${serverId}:${draftId}` : `agent:${serverId}:${agentId}`),
generateDraftId: () => "draft-new-workspace",
}));
vi.mock("@/stores/draft-store", () => ({
useDraftStore: {
getState: () => ({
saveDraftInput: saveDraftInputMock,
}),
},
}));
vi.mock("@/stores/workspace-draft-submission-store", () => ({
useWorkspaceDraftSubmissionStore: {
getState: () => ({
setPending: queueDraftSubmissionMock,
}),
},
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
@@ -222,6 +267,10 @@ vi.mock("@/hooks/use-agent-input-draft", () => ({
},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: { transform: "translateY(-216px)" } }),
}));
vi.mock("@/components/composer", () => ({
Composer: ({
onSubmitMessage,
@@ -444,6 +493,9 @@ beforeEach(() => {
});
mockClient.createPaseoWorktree.mockClear();
mockClient.createAgent.mockClear();
saveDraftInputMock.mockClear();
queueDraftSubmissionMock.mockClear();
navigateMock.mockClear();
initialAttachments.length = 0;
initialDraftState.text = "";
});
@@ -522,6 +574,14 @@ function firstCreateWorktreeCall(): CreatePaseoWorktreeArg {
}
describe("NewWorkspaceScreen picker payload", () => {
it("moves the ref picker row with the mobile keyboard shift", async () => {
renderScreen();
await flush();
const pickerRow = await findByTestId("new-workspace-ref-picker-row");
expect(pickerRow.style.transform).toBe("translateY(-216px)");
});
it("searches only GitHub PRs for the picker", async () => {
renderScreen();
await flush();
@@ -552,6 +612,43 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(call).not.toHaveProperty("githubPrNumber");
});
it("opens the new workspace draft tab as soon as the worktree is ready", async () => {
initialDraftState.text = "please review this change";
renderScreen();
await flush();
click(await findByTestId("test-composer-submit"));
await flush();
expect(mockClient.createPaseoWorktree).toHaveBeenCalledTimes(1);
expect(mockClient.createAgent).not.toHaveBeenCalled();
expect(saveDraftInputMock).toHaveBeenCalledWith({
draftKey: "draft:server:draft-new-workspace",
draft: {
text: "please review this change",
attachments: [],
cwd: createdWorkspace.workspaceDirectory,
},
});
expect(queueDraftSubmissionMock).toHaveBeenCalledWith({
serverId: "server",
workspaceId: createdWorkspace.id,
draftId: "draft-new-workspace",
text: "please review this change",
attachments: [],
cwd: createdWorkspace.workspaceDirectory,
provider: "claude-code",
allowEmptyText: true,
});
expect(navigateMock).toHaveBeenCalledWith({
serverId: "server",
workspaceId: createdWorkspace.id,
target: { kind: "draft", draftId: "draft-new-workspace" },
navigationMethod: "replace",
});
expect(document.querySelector("textarea")).toHaveProperty("disabled", true);
});
it("shows the selected PR number, title, and PR icon in the picker trigger", async () => {
renderScreen();
await flush();
@@ -747,10 +844,13 @@ describe("NewWorkspaceScreen picker payload", () => {
);
});
it("preserves and locks the composer and picker while chat creation is pending, then unlocks on error", async () => {
it("preserves and locks the composer and picker while worktree creation is pending, then unlocks on error", async () => {
initialDraftState.text = "please review this change";
const createAgent = createDeferredPromise<typeof createdAgent>();
mockClient.createAgent.mockImplementationOnce(async () => await createAgent.promise);
const createWorktree = createDeferredPromise<{
workspace: typeof createdWorkspace;
error: null;
}>();
mockClient.createPaseoWorktree.mockImplementationOnce(async () => await createWorktree.promise);
renderScreen();
await flush();
@@ -778,7 +878,7 @@ describe("NewWorkspaceScreen picker payload", () => {
expect(queryByTestId("new-workspace-ref-picker-trigger")).toHaveProperty("disabled", true);
expect(queryByTestId("new-workspace-ref-picker-branch-dev")).toHaveProperty("disabled", true);
createAgent.reject(new Error("Create agent failed"));
createWorktree.reject(new Error("Create worktree failed"));
await flush();
expect(textInput).toHaveProperty("value", "please review this change");
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
@@ -16,12 +17,13 @@ import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { toErrorMessage } from "@/utils/error-messages";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
@@ -105,9 +107,11 @@ export function NewWorkspaceScreen({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
@@ -319,10 +323,6 @@ export function NewWorkspaceScreen({
try {
setPendingAction("chat");
setErrorMessage(null);
const { images, attachments: reviewAttachments } =
splitComposerAttachmentsForSubmit(attachments);
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Composer state is required");
}
@@ -330,15 +330,30 @@ export function NewWorkspaceScreen({
throw new Error("Select a model");
}
const initialPrompt = text.trim();
const encodedImages = await encodeImages(images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
}).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
const { attachments: reviewAttachments } = splitComposerAttachmentsForSubmit(attachments);
const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments });
const draftId = generateDraftId();
const workspaceDirectory = workspace.workspaceDirectory;
useDraftStore.getState().saveDraftInput({
draftKey: buildDraftStoreKey({
serverId,
agentId: draftId,
draftId,
}),
draft: {
text,
attachments,
cwd: workspaceDirectory,
},
});
useWorkspaceDraftSubmissionStore.getState().setPending({
serverId,
workspaceId: workspace.id,
draftId,
text,
attachments,
cwd: workspaceDirectory,
provider: composerState.selectedProvider,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
@@ -346,31 +361,23 @@ export function NewWorkspaceScreen({
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(initialPrompt ? { initialPrompt } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}),
});
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
...(composerState.featureValues ? { featureValues: composerState.featureValues } : {}),
allowEmptyText: true,
});
navigateToPreparedWorkspaceTab({
serverId,
workspaceId: workspace.id,
target: { kind: "agent", agentId: agent.id },
target: { kind: "draft", draftId },
navigationMethod: "replace",
});
} catch (error) {
const message = toErrorMessage(error);
setPendingAction(null);
setErrorMessage(message);
toast.error(message);
} finally {
setPendingAction(null);
}
},
[composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient],
[composerState, ensureWorkspace, serverId, toast],
);
const workspaceTitle =
@@ -494,7 +501,10 @@ export function NewWorkspaceScreen({
}
onAddImages={handleAddImagesCallback}
/>
<View style={styles.optionsRow}>
<Animated.View
testID="new-workspace-ref-picker-row"
style={[styles.optionsRow, keyboardAnimatedStyle]}
>
<View>
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
@@ -557,7 +567,7 @@ export function NewWorkspaceScreen({
renderOption={renderPickerOption}
/>
</View>
</View>
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</View>
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -14,6 +14,7 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { encodeImages } from "@/utils/encode-images";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
@@ -80,6 +81,23 @@ export function WorkspaceDraftAgentTab({
if (!composerState) {
throw new Error("Workspace draft composer state is required");
}
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
});
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
(state) => state.consumePending,
);
const autoSubmitConfig = pendingAutoSubmit
? {
provider: pendingAutoSubmit.provider,
modeId: pendingAutoSubmit.modeId ?? null,
model: pendingAutoSubmit.model ?? null,
thinkingOptionId: pendingAutoSubmit.thinkingOptionId ?? null,
featureValues: pendingAutoSubmit.featureValues,
}
: null;
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const {
formErrorMessage,
@@ -90,20 +108,21 @@ export function WorkspaceDraftAgentTab({
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text }) => {
if (!text.trim()) {
if (!allowsEmptyAutoSubmit && !text.trim()) {
return "Initial prompt is required";
}
if (composerState.providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (!composerState.selectedProvider) {
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
return "Select a model";
}
if (composerState.isModelLoading) {
return "Model defaults are still loading";
}
if (!composerState.effectiveModelId) {
if (!(autoSubmitConfig?.model ?? composerState.effectiveModelId)) {
return "No model is available for the selected provider";
}
if (!workspaceDirectory) {
@@ -124,13 +143,15 @@ export function WorkspaceDraftAgentTab({
buildDraftAgent: (attempt) => {
invariant(workspaceDirectory, "Workspace directory is required");
const now = attempt.timestamp;
const model = composerState.effectiveModelId || null;
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
const model = autoSubmitConfig?.model ?? (composerState.effectiveModelId || null);
const thinkingOptionId =
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
const modeId =
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
autoSubmitConfig?.modeId ??
(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? composerState.selectedMode
: null;
const provider = composerState.selectedProvider;
: null);
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
@@ -164,19 +185,23 @@ export function WorkspaceDraftAgentTab({
throw new Error("Host is not connected");
}
const provider = composerState.selectedProvider;
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const config = buildWorkspaceDraftAgentConfig({
provider,
cwd: workspaceDirectory,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
...(autoSubmitConfig?.modeId
? { modeId: autoSubmitConfig.modeId }
: composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ??
(composerState.effectiveThinkingOptionId || undefined),
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
@@ -199,6 +224,44 @@ export function WorkspaceDraftAgentTab({
},
});
const isReadyForPendingAutoSubmit = Boolean(
pendingAutoSubmit &&
draftInput.isHydrated &&
workspaceDirectory &&
client &&
!isSubmitting &&
!composerState.isModelLoading,
);
const autoSubmitKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isReadyForPendingAutoSubmit) {
return;
}
const submitKey = `${serverId}:${workspaceId}:${draftId}`;
if (autoSubmitKeyRef.current === submitKey) {
return;
}
const submission = consumePendingAutoSubmit({ serverId, workspaceId, draftId });
if (!submission) {
return;
}
autoSubmitKeyRef.current = submitKey;
void handleCreateFromInput({
text: submission.text,
attachments: submission.attachments,
cwd: submission.cwd,
}).catch(() => {
autoSubmitKeyRef.current = null;
});
}, [
consumePendingAutoSubmit,
draftId,
handleCreateFromInput,
isReadyForPendingAutoSubmit,
serverId,
workspaceId,
]);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
@@ -112,10 +112,18 @@ describe("navigation active workspace store", () => {
serverId: "server-1",
workspaceId: "workspace-a",
});
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
store.syncNavigationActiveWorkspace(createNavigationPathRef("/h/server-1/sessions"));
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
expect(store.getLastNavigationWorkspaceRouteSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("clears stale workspace params when navigation sync reports a non-workspace path", async () => {
@@ -134,4 +142,35 @@ describe("navigation active workspace store", () => {
expect(store.getNavigationActiveWorkspaceSelection()).toBeNull();
});
it("uses a one-shot workspace route override when returning to a retained shell", async () => {
installWindowStub("/h/server-1/workspace/workspace-a");
const store = await import("@/stores/navigation-active-workspace-store");
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
store.overrideNextNavigationWorkspaceRouteSelection({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
store.syncNavigationActiveWorkspace(
createNavigationPathRef("/h/server-1/workspace/workspace-a"),
);
expect(store.getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
});
@@ -39,6 +39,8 @@ interface NavigationObserverRef {
}
let snapshot: ActiveWorkspaceSelection | null = null;
let lastWorkspaceRouteSelection: ActiveWorkspaceSelection | null = null;
let nextWorkspaceRouteSelectionOverride: ActiveWorkspaceSelection | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
@@ -165,7 +167,18 @@ function getActiveWorkspaceForNavigationSync(
}
export function syncNavigationActiveWorkspace(navigationRef: NavigationObserverRef) {
emitIfChanged(getActiveWorkspaceForNavigationSync(navigationRef.current?.getCurrentRoute()));
const route = navigationRef.current?.getCurrentRoute();
const routeState = classifyNavigationWorkspaceRoute(route);
if (routeState.kind === "workspace") {
lastWorkspaceRouteSelection = routeState.selection;
if (nextWorkspaceRouteSelectionOverride) {
const overrideSelection = nextWorkspaceRouteSelectionOverride;
nextWorkspaceRouteSelectionOverride = null;
emitIfChanged(overrideSelection);
return;
}
}
emitIfChanged(getActiveWorkspaceForNavigationSync(route));
}
export function activateNavigationWorkspaceSelection(
@@ -180,6 +193,14 @@ export function getNavigationActiveWorkspaceSelection(): ActiveWorkspaceSelectio
return getSnapshot();
}
export function getLastNavigationWorkspaceRouteSelection(): ActiveWorkspaceSelection | null {
return lastWorkspaceRouteSelection;
}
export function overrideNextNavigationWorkspaceRouteSelection(next: ActiveWorkspaceSelection) {
nextWorkspaceRouteSelectionOverride = next;
}
export function syncBrowserActiveWorkspaceFromLocation() {
emitIfChanged(getBrowserLocationWorkspace());
}
@@ -0,0 +1,66 @@
import { create } from "zustand";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
export interface PendingWorkspaceDraftSubmission {
serverId: string;
workspaceId: string;
draftId: string;
text: string;
attachments: ComposerAttachment[];
cwd: string;
provider: AgentProvider;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
allowEmptyText?: boolean;
}
type WorkspaceDraftSubmissionState = {
pendingByDraftId: Record<string, PendingWorkspaceDraftSubmission>;
setPending: (submission: PendingWorkspaceDraftSubmission) => void;
consumePending: (input: {
serverId: string;
workspaceId: string;
draftId: string;
}) => PendingWorkspaceDraftSubmission | null;
};
function matchesPendingSubmission(
pending: PendingWorkspaceDraftSubmission | null | undefined,
input: { serverId: string; workspaceId: string; draftId: string },
): pending is PendingWorkspaceDraftSubmission {
return (
pending?.serverId === input.serverId &&
pending.workspaceId === input.workspaceId &&
pending.draftId === input.draftId
);
}
export const useWorkspaceDraftSubmissionStore = create<WorkspaceDraftSubmissionState>(
(set, get) => ({
pendingByDraftId: {},
setPending: (submission) =>
set((state) => ({
pendingByDraftId: {
...state.pendingByDraftId,
[submission.draftId]: submission,
},
})),
consumePending: (input) => {
const pending = get().pendingByDraftId[input.draftId];
if (!matchesPendingSubmission(pending, input)) {
return null;
}
set((state) => {
if (!matchesPendingSubmission(state.pendingByDraftId[input.draftId], input)) {
return state;
}
const { [input.draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
});
return pending;
},
}),
);
@@ -1,12 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("expo-router", () => ({
router: {
const { platformState, routerMock } = vi.hoisted(() => ({
platformState: {
isNative: false,
isWeb: true,
},
routerMock: {
back: vi.fn(),
canGoBack: vi.fn(() => false),
navigate: vi.fn(),
replace: vi.fn(),
},
}));
vi.mock("expo-router", () => ({
router: routerMock,
}));
vi.mock("@/constants/platform", () => ({
get isNative() {
return platformState.isNative;
},
get isWeb() {
return platformState.isWeb;
},
}));
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
@@ -23,7 +42,11 @@ vi.mock("@react-native-async-storage/async-storage", () => {
});
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation";
const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree";
@@ -31,6 +54,15 @@ const AGENT_ID = "agent-1";
describe("prepareWorkspaceTab", () => {
beforeEach(() => {
vi.useRealTimers();
platformState.isNative = false;
platformState.isWeb = true;
routerMock.back.mockReset();
routerMock.canGoBack.mockReset();
routerMock.canGoBack.mockReturnValue(false);
routerMock.navigate.mockReset();
routerMock.replace.mockReset();
syncNavigationActiveWorkspace({ current: null });
useWorkspaceLayoutStore.setState({
layoutByWorkspace: {},
splitSizesByWorkspace: {},
@@ -50,4 +82,37 @@ describe("prepareWorkspaceTab", () => {
const key = "server-1:/repo/worktree";
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
});
it("pops back to the retained workspace shell for native replace navigation", () => {
vi.useFakeTimers();
platformState.isNative = true;
platformState.isWeb = false;
routerMock.canGoBack.mockReturnValue(true);
syncNavigationActiveWorkspace({
current: {
getCurrentRoute: () => ({
path: "/h/server-1/workspace/source-workspace",
}),
},
});
const route = navigateToPreparedWorkspaceTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID },
navigationMethod: "replace",
});
expect(route).toBe("/h/server-1/workspace/b64_L3JlcG8vd29ya3RyZWU");
expect(routerMock.back).toHaveBeenCalledOnce();
expect(routerMock.replace).not.toHaveBeenCalled();
vi.runAllTimers();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
});
});
@@ -1,4 +1,10 @@
import { router } from "expo-router";
import { isNative } from "@/constants/platform";
import {
activateNavigationWorkspaceSelection,
getLastNavigationWorkspaceRouteSelection,
overrideNextNavigationWorkspaceRouteSelection,
} from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import {
@@ -45,6 +51,20 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
const route = prepareWorkspaceTab(input);
if (input.navigationMethod === "replace") {
const canReturnToWorkspaceShell =
isNative && getLastNavigationWorkspaceRouteSelection() !== null && router.canGoBack();
if (canReturnToWorkspaceShell) {
const nextSelection = {
serverId: input.serverId,
workspaceId: input.workspaceId,
};
overrideNextNavigationWorkspaceRouteSelection(nextSelection);
router.back();
setTimeout(() => {
activateNavigationWorkspaceSelection(nextSelection);
}, 0);
return route;
}
router.replace(route as any);
} else {
router.navigate(route as any);