Skip to content
Closed
78 changes: 52 additions & 26 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ import {
isWorkspaceListPath,
type WorkspaceAuxiliaryPaneRole,
} from "../../lib/layout";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
import type { ThreadListFilters } from "../../persistence/mobile-preferences";
import { appAtomRegistry } from "../../state/atom-registry";
import { activeNewTaskProjectKeyAtom } from "../../state/active-new-task";
import { useProjects } from "../../state/entities";
import { environmentServerConfigsAtom } from "../../state/server";
import { environmentThreadShells } from "../../state/threads";
import { mobilePreferencesAtom } from "../../state/preferences";
import {
Expand All @@ -69,7 +71,12 @@ import {
import { startOpeningDetail } from "../workspace/useSelectWorkspaceDetail";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
import { HomeListOptionsProvider } from "../home/home-list-options";
import { newTaskDestination } from "../threads/new-task-project-selection";
import { buildHomeProjectScopes } from "../home/homeThreadList";
import {
listedProjectKey,
newTaskDestination,
pickerProjectScopes,
} from "../threads/new-task-project-selection";
import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-requests";
Expand Down Expand Up @@ -515,34 +522,53 @@ function AdaptiveWorkspaceLayoutContent(
// Reads the open page when pressed, so the layout context doesn't change on every navigation.
const handleStartNewTask = () => {
const state = rootStackState(navigation);
const currentRoute = workspaceRoutesOf(
const { workspace, overlays } = workspaceRoutesOf(
state?.routes.slice(0, state.index + 1) ?? [],
).workspace.at(-1);
const params =
currentRoute?.name === "Thread"
? (currentRoute.params as ReactNavigation.RootParamList["Thread"])
: undefined;
);
// Every thread page (chat, terminal, files, review, ...) names its thread in its params.
const params = workspace.at(-1)?.params as ReactNavigation.RootParamList["Thread"];
const first = (value: string | string[] | undefined) =>
Array.isArray(value) ? value[0] : value;
const thread = appAtomRegistry
.get(environmentThreadShells.navigationThreadShellsAtom)
.find(
(candidate) =>
candidate.environmentId === first(params?.environmentId) &&
candidate.id === first(params?.threadId),
);
const draft = params?.draft;
const draftEnvironmentId = first(draft?.environmentId);
const draftProjectId = first(draft?.projectId);
const currentProject = thread
? { environmentId: thread.environmentId, id: thread.projectId }
: projects.find(
(project) =>
project.environmentId === draftEnvironmentId && project.id === draftProjectId,
);
handleSelectThreadRoute(
newTaskDestination(layout.usesSplitView, projects, currentProject, uuidv4()),
const environmentId = first(params?.environmentId);
const threadId = first(params?.threadId);
// The index includes subagent threads, which the navigation list leaves out.
const threadProjectId =
environmentId && threadId
? appAtomRegistry
.get(
environmentThreadShells.environmentThreadIndexAtom(EnvironmentId.make(environmentId)),
)
.get(ThreadId.make(threadId))?.projectId
: undefined;
// An open draft's route may not name the project it shows, so read the draft's selection.
const currentProjectKey =
environmentId && threadProjectId
? scopedProjectKey(EnvironmentId.make(environmentId), threadProjectId)
: params?.draft
? (appAtomRegistry.get(activeNewTaskProjectKeyAtom) ?? undefined)
: undefined;
const projectScopes = pickerProjectScopes(
buildHomeProjectScopes({ projects, environmentId: null, projectGroupingMode }),
appAtomRegistry.get(environmentServerConfigsAtom),
);
const destination = newTaskDestination(
projectScopes,
listedProjectKey(projectScopes, projects, currentProjectKey),
uuidv4(),
);
if (destination.kind === "draft") {
handleSelectThreadRoute({ draft: destination.draft });
return;
}
// The picker opens over the current page, so backing out of it returns there.
if (layout.primarySidebarOverlaysContent) setPrimarySidebarVisible(false);
if (!state || overlays.length === 0) {
navigation.navigate("NewTaskProject", destination.picker);
return;
}
// Close open sheets first, or the picker would open inside them.
const routes = [...workspace, { name: "NewTaskProject", params: destination.picker }];
dispatchRootStackReset(navigation, state, { ...state, index: routes.length - 1, routes });
};

useHardwareKeyboardCommand("newTask", () => {
Expand Down
37 changes: 29 additions & 8 deletions apps/mobile/src/features/threads/NewTaskRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import {
StackActions,
useIsFocused,
useNavigation,
useNavigationState,
useRoute,
type StaticScreenProps,
} from "@react-navigation/native";
import { SymbolView } from "../../components/ui/AppSymbol";
import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
Expand All @@ -36,7 +37,12 @@ import { useWorkspaceState } from "../../state/workspace";
import { usePromptLinkResolver } from "../prompt-links/usePromptLinkResolver";
import { useIncomingShare } from "../sharing/IncomingShareProvider";
import { useNewTaskFlow } from "./new-task-flow-provider";
import { filterProjectScopes, getProjectScopeSelectionTarget } from "./new-task-project-selection";
import {
filterProjectScopes,
getProjectScopeSelectionTarget,
orderSuggestedProjectFirst,
pickerProjectScopes,
} from "./new-task-project-selection";
import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail";

type NewTaskRouteParams = {
Expand All @@ -46,6 +52,9 @@ type NewTaskRouteParams = {
readonly pr?: string | string[];
readonly provider?: string | string[];
readonly model?: string | string[];
/** Set by a global "New thread": the chosen project opens a new draft with this request. */
readonly requestId?: string;
readonly suggestedProjectKey?: string;
};

function deriveProjectEmptyState(catalogState: WorkspaceState): {
Expand Down Expand Up @@ -115,6 +124,13 @@ function NewTaskHeader(props: {
readonly onSearchTextChange: (text: string) => void;
}) {
const navigation = useNavigation();
const routeKey = useRoute().key;
// Split view's detail column leaves out Home and Settings, so a picker opened over either is the
// column's first page, with no native back button.
const firstDetailPage = useNavigationState((state) => {
const below = state.routes[state.routes.findIndex((route) => route.key === routeKey) - 1];
return below === undefined || below.name === "Home" || below.name === "Settings";
});
const goBack = () => {
if (navigation.canGoBack()) navigation.goBack();
else navigation.getParent()?.goBack();
Expand All @@ -128,6 +144,9 @@ function NewTaskHeader(props: {
options={{ headerBackVisible: true }}
hideBottomBorder
onBack={goBack}
backInSplitView={
firstDetailPage ? { accessibilityLabel: "Cancel", icon: "xmark" } : undefined
}
actions={
props.canAddProject
? [
Expand Down Expand Up @@ -174,11 +193,10 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
const screenTitle = incomingShare ? "Start a task" : "Choose project";
const projectEmptyState = deriveProjectEmptyState(catalogState);
const serverConfigs = useServerConfigs();
const listScopes = projectScopes.filter(
(scope) =>
!scope.projects.every((project) =>
isScratchProject(project, serverConfigs.get(project.environmentId)?.scratchWorkspaceRoot),
),
const suggestedProjectKey = route.params?.suggestedProjectKey;
const listScopes = orderSuggestedProjectFirst(
pickerProjectScopes(projectScopes, serverConfigs),
suggestedProjectKey,
);
const visibleScopes = filterProjectScopes(listScopes, searchText);
const resumedDestinationKeyRef = useRef<string | null>(null);
Expand Down Expand Up @@ -228,7 +246,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
: navigation.getParent();
const rootState = root?.getState();
const threadRoute = rootState?.routes[rootState.index - 1];
if (isDraftThreadRoute(threadRoute)) {
const requestId = route.params?.requestId;
if (!requestId && isDraftThreadRoute(threadRoute)) {
setProject(project);
root?.goBack();
return;
Expand All @@ -237,6 +256,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
name: "Thread",
params: {
draft: {
requestId,
environmentId: String(project.environmentId),
projectId: String(project.id),
incomingShareId: incomingShare?.id,
Expand Down Expand Up @@ -463,6 +483,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
const selectionTarget = getProjectScopeSelectionTarget(
scope,
selectedEnvironmentId,
suggestedProjectKey,
);
if (Platform.OS === "android") {
return (
Expand Down
23 changes: 16 additions & 7 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ActiveNewTaskContext } from "../../state/active-new-task";
import { ActiveNewTaskContext, activeNewTaskProjectKeyAtom } from "../../state/active-new-task";
import * as Dialog from "../../components/ui/Dialog";
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { useEnvironmentPresentation } from "../../state/presentation";
Expand Down Expand Up @@ -418,13 +418,14 @@ export function NewTaskFlowProvider(
) {
return;
}
const draftKey = createNewTaskDraft({
environmentId: selectedProject.environmentId,
projectId: selectedProject.id,
});
// The draft screen's setProject runs earlier in this commit and may already have created the
// route project's draft; this effect still sees the selection from before it, so keep that key.
// oxlint-disable-next-line react/set-state-in-effect -- Creates the persistent draft once the project selection is ready; the key must exist before the composer mounts.
setActiveDraftKey(
createNewTaskDraft({
environmentId: selectedProject.environmentId,
projectId: selectedProject.id,
}),
);
setActiveDraftKey((current) => current ?? draftKey);
}, [activeDraftKey, editingPendingTask, selectedProject, props.active]);
const activePendingKey =
props.active === false
Expand All @@ -442,6 +443,14 @@ export function NewTaskFlowProvider(
() => (props.active === false ? null : { pendingKey: activePendingKey }),
[props.active, activePendingKey],
);
const activeProjectKey =
props.active !== false && selectedProject
? scopedProjectKey(selectedProject.environmentId, selectedProject.id)
: null;
// oxlint-disable-next-line t3code/react-effect-budget -- Publishes the derived selection to an atom read at press time, so readers don't re-render on every draft change.
useEffect(() => {
appAtomRegistry.set(activeNewTaskProjectKeyAtom, activeProjectKey);
}, [activeProjectKey]);
const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey);
const prompt = selectedProjectDraft.text;
const attachments = selectedProjectDraft.attachments;
Expand Down
Loading
Loading