Fix optimistic workspace creation on Android
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user