diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index e2d758ee9606e..d768df8c58c28 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -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 { @@ -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"; @@ -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", () => { diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 01b3ed0c0f49f..7199c1b9cd8c3 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -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, @@ -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 = { @@ -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): { @@ -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(); @@ -128,6 +144,9 @@ function NewTaskHeader(props: { options={{ headerBackVisible: true }} hideBottomBorder onBack={goBack} + backInSplitView={ + firstDetailPage ? { accessibilityLabel: "Cancel", icon: "xmark" } : undefined + } actions={ props.canAddProject ? [ @@ -174,11 +193,10 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - !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(null); @@ -228,7 +246,8 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps current ?? draftKey); }, [activeDraftKey, editingPendingTask, selectedProject, props.active]); const activePendingKey = props.active === false @@ -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; diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 3d0c2658fe3e8..3b7151c5ae00a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -2,9 +2,13 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { HomeProjectScope } from "../home/homeThreadList"; +import { buildHomeProjectScopes, type HomeProjectScope } from "../home/homeThreadList"; import { + getProjectScopeSelectionTarget, + listedProjectKey, newTaskDestination, + orderSuggestedProjectFirst, + pickerProjectScopes, resolveDraftProjectSelection, resolveEnvironmentProjectMatch, } from "./new-task-project-selection"; @@ -40,10 +44,13 @@ function makeProject( }; } -function makeScope(projects: ReadonlyArray): HomeProjectScope { +function makeScope( + projects: ReadonlyArray, + key = "github.com/t3tools/t3code", +): HomeProjectScope { return { - key: "github.com/t3tools/t3code", - title: "T3 Code", + key, + title: key, representative: projects[0]!, projects, projectRefs: projects.map((project) => ({ @@ -115,26 +122,104 @@ describe("resolveDraftProjectSelection", () => { }); describe("newTaskDestination", () => { - it("opens the current thread's project directly in split view", () => { - const projects = [makeProject("first"), makeProject("current", "other-environment")]; - expect(newTaskDestination(true, projects, projects[1], "request")).toEqual({ - draft: { requestId: "request", environmentId: "other-environment", projectId: "current" }, + const projects = [makeProject("first"), makeProject("current", "other-environment")]; + const scopes = projects.map((project) => makeScope([project], project.id)); + + it("asks for a project before opening a draft, listing the current project first", () => { + const destination = newTaskDestination(scopes, "other-environment:current", "request"); + expect(destination).toEqual({ + kind: "pick", + picker: { requestId: "request", suggestedProjectKey: "other-environment:current" }, + }); + expect( + orderSuggestedProjectFirst(scopes, "other-environment:current").map((scope) => scope.key), + ).toEqual(["current", "first"]); + }); + + it("asks for a project in the existing order with nothing open", () => { + expect(newTaskDestination(scopes, undefined, "request")).toEqual({ + kind: "pick", + picker: { requestId: "request" }, }); + expect(orderSuggestedProjectFirst(scopes, undefined)).toEqual(scopes); }); - it("uses an available project when split view has no current project", () => { - expect(newTaskDestination(true, [makeProject("first")], undefined, "request")).toEqual({ - draft: { requestId: "request", environmentId: "environment", projectId: "first" }, + + it("opens the draft directly in the only selectable project", () => { + const only = makeProject("only"); + expect(newTaskDestination([makeScope([only])], undefined, "request")).toEqual({ + kind: "draft", + draft: { requestId: "request", environmentId: "environment", projectId: "only" }, + }); + }); + + it("counts only the projects the picker lists, not projectless threads", () => { + const real = makeScope([makeProject("only")], "only"); + const scratch = makeScope([ + makeProject("scratch", "environment", { workspaceRoot: "/scratch" }), + ]); + const serverConfigs = new Map([ + [EnvironmentId.make("environment"), { scratchWorkspaceRoot: "/scratch" }], + ]); + expect( + newTaskDestination(pickerProjectScopes([real, scratch], serverConfigs), undefined, "request"), + ).toEqual({ + kind: "draft", + draft: { requestId: "request", environmentId: "environment", projectId: "only" }, }); + // With no real project the picker still opens, offering "No project" and Add project. + expect( + newTaskDestination(pickerProjectScopes([scratch], serverConfigs), undefined, "request"), + ).toEqual({ kind: "pick", picker: { requestId: "request" } }); }); - it("keeps the picker when there are no projects", () => { - expect(newTaskDestination(true, [], undefined, "request")).toEqual({ - draft: { requestId: "request" }, + + it("keeps the current workspace when the only project spans environments", () => { + const scope = makeScope([makeProject("t3code", "mac"), makeProject("t3code", "server")]); + expect(newTaskDestination([scope], "server:t3code", "request")).toEqual({ + kind: "draft", + draft: { requestId: "request", environmentId: "server", projectId: "t3code" }, }); + expect(newTaskDestination([scope], undefined, "request")).toEqual({ + kind: "draft", + draft: { requestId: "request", environmentId: "mac", projectId: "t3code" }, + }); + }); + + it("keeps the current project's workspace when its scope is picked", () => { + const mac = makeProject("t3code", "mac"); + const server = makeProject("t3code", "server"); + const scope = makeScope([mac, server]); + expect(orderSuggestedProjectFirst([scopes[0]!, scope], "server:t3code")[0]).toBe(scope); + expect(getProjectScopeSelectionTarget(scope, EnvironmentId.make("mac"), "server:t3code")).toBe( + server, + ); }); - it("opens the same draft route in compact view", () => { - const project = makeProject("current"); - expect(newTaskDestination(false, [project], project, "request")).toEqual({ - draft: { requestId: "request", environmentId: "environment", projectId: "current" }, + + it("resolves a thread's older registration to the one listed at the same path", () => { + const repository = { repositoryKey: "github.com/t3tools/t3code" }; + const mac = makeProject("mac-t3code", "mac", { ...repository, workspaceRoot: "/mac/t3code" }); + const older = { + ...makeProject("server-old", "server", { ...repository, workspaceRoot: "/srv/t3code" }), + updatedAt: "2026-06-01T00:00:00.000Z", + }; + const newer = makeProject("server-new", "server", { + ...repository, + workspaceRoot: "/srv/t3code", + }); + const projects = [mac, older, newer]; + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + expect(scopes.map((scope) => scope.representative)).toEqual([mac]); + + const currentProjectKey = listedProjectKey(scopes, projects, "server:server-old"); + expect(currentProjectKey).toBe("server:server-new"); + expect(newTaskDestination(scopes, currentProjectKey, "request")).toEqual({ + kind: "draft", + draft: { requestId: "request", environmentId: "server", projectId: "server-new" }, }); + expect(listedProjectKey(scopes, projects, "mac:mac-t3code")).toBe("mac:mac-t3code"); + expect(listedProjectKey(scopes, projects, undefined)).toBeUndefined(); }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 1d61b3a6346c7..c8c248808521c 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,3 +1,5 @@ +import { derivePhysicalProjectKey } from "@t3tools/client-runtime/state/project-grouping"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { repositoryGroupingKeyOf, type EnvironmentId } from "@t3tools/contracts"; @@ -12,8 +14,12 @@ type DraftProjectSelectionResolution = export function getProjectScopeSelectionTarget( scope: HomeProjectScope, preferredEnvironmentId: EnvironmentId | null, + suggestedProjectKey?: string, ): EnvironmentProject { return ( + scope.projects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === suggestedProjectKey, + ) ?? scope.projects.find((project) => project.environmentId === preferredEnvironmentId) ?? scope.representative ); @@ -103,25 +109,75 @@ export function resolveDraftProjectSelection( return onlyProject ? { kind: "select", project: onlyProject } : { kind: "pick" }; } -export function newTaskDestination( - _usesSplitView: boolean, +/** The scopes the project picker lists; it offers scratch projects as "No project" instead. */ +export function pickerProjectScopes( + scopes: ReadonlyArray, + serverConfigs: ReadonlyMap, +): ReadonlyArray { + return scopes.filter( + (scope) => + !scope.projects.every((project) => + isScratchProject(project, serverConfigs.get(project.environmentId)?.scratchWorkspaceRoot), + ), + ); +} + +/** + * The key of the listed project registered at `projectKey`'s path. Grouping lists one registration + * per path, so a thread can belong to an older registration the picker doesn't show. + */ +export function listedProjectKey( + scopes: ReadonlyArray, projects: ReadonlyArray, - currentProject: Pick | undefined, + projectKey: string | undefined, +): string | undefined { + const project = projects.find( + (candidate) => scopedProjectKey(candidate.environmentId, candidate.id) === projectKey, + ); + if (!project) return projectKey; + const physicalKey = derivePhysicalProjectKey(project); + const listed = scopes + .flatMap((scope) => scope.projects) + .find((candidate) => derivePhysicalProjectKey(candidate) === physicalKey); + return listed ? scopedProjectKey(listed.environmentId, listed.id) : projectKey; +} + +/** + * Where a global "New thread" goes, given the picker's scopes: a draft in the only selectable + * project, keeping the current project's workspace, or else the picker, which lists the current + * project first and opens the draft only once a project is chosen. + */ +export function newTaskDestination( + projectScopes: ReadonlyArray, + currentProjectKey: string | undefined, requestId: string, ) { - const project = - projects.find( - (candidate) => - candidate.environmentId === currentProject?.environmentId && - candidate.id === currentProject.id, - ) ?? projects[0]; - return project - ? { - draft: { - requestId, - environmentId: String(project.environmentId), - projectId: String(project.id), - }, - } - : { draft: { requestId } }; + const onlyScope = projectScopes.length === 1 ? projectScopes[0] : undefined; + if (onlyScope) { + const project = getProjectScopeSelectionTarget(onlyScope, null, currentProjectKey); + return { + kind: "draft" as const, + draft: { + requestId, + environmentId: String(project.environmentId), + projectId: String(project.id), + }, + }; + } + return { + kind: "pick" as const, + picker: { requestId, ...(currentProjectKey ? { suggestedProjectKey: currentProjectKey } : {}) }, + }; +} + +export function orderSuggestedProjectFirst( + scopes: ReadonlyArray, + suggestedProjectKey: string | undefined, +): ReadonlyArray { + const suggested = scopes.find((scope) => + scope.projectRefs.some( + (ref) => scopedProjectKey(ref.environmentId, ref.projectId) === suggestedProjectKey, + ), + ); + return suggested ? [suggested, ...scopes.filter((scope) => scope !== suggested)] : scopes; } diff --git a/apps/mobile/src/state/active-new-task.ts b/apps/mobile/src/state/active-new-task.ts index c29e618ea553a..d4378f7b27d72 100644 --- a/apps/mobile/src/state/active-new-task.ts +++ b/apps/mobile/src/state/active-new-task.ts @@ -1,3 +1,4 @@ +import { Atom } from "effect/reactivity"; import { createContext, use } from "react"; export const ActiveNewTaskContext = createContext<{ readonly pendingKey: string | null } | null>( @@ -7,3 +8,9 @@ export const ActiveNewTaskContext = createContext<{ readonly pendingKey: string export function useActiveNewTask() { return use(ActiveNewTaskContext); } + +/** + * The scoped key of the project the open draft shows, which its route may not name. Read it when + * needed rather than subscribing, so draft changes don't re-render readers. + */ +export const activeNewTaskProjectKeyAtom = Atom.make(null).pipe(Atom.keepAlive); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 85158e687325d..c155d7276e69a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -9,6 +9,8 @@ On web and desktop, a new thread keeps the current project and carries your mode and mode selections, unless the destination project has its own model default. Its branch and workspace mode come from your configured defaults. To continue in an existing worktree, use **New thread in this worktree** from the branch toolbar. +On mobile, when you have more than one project, **New thread** asks which project +to use and lists the current project first. When you change a new thread's project, T3 Code stays in the current environment if that project exists there. Otherwise it selects an environment that has it.