From 7401507f32c64d6aea5ccffc539da89a002fcec7 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:50:17 +0800 Subject: [PATCH 1/2] fix(clients): distinguish projects across environments --- .../features/threads/NewTaskRouteScreen.tsx | 23 ++++++- .../components/BranchToolbar.logic.test.ts | 36 +++++++++++ .../web/src/components/BranchToolbar.logic.ts | 11 ++++ apps/web/src/components/BranchToolbar.tsx | 49 ++++++++++----- apps/web/src/components/ChatView.tsx | 19 +++++- .../components/CommandPalette.logic.test.ts | 27 ++++++++ .../src/components/CommandPalette.logic.ts | 13 ++-- apps/web/src/components/CommandPalette.tsx | 62 ++++++++++++++++--- .../src/components/chat/DraftHeroHeadline.tsx | 32 ++++++++-- docs/user/remote-access.md | 3 + .../src/state/projectGrouping.test.ts | 25 ++++++++ .../src/state/projectGrouping.ts | 10 +++ 12 files changed, 274 insertions(+), 36 deletions(-) create mode 100644 packages/client-runtime/src/state/projectGrouping.test.ts diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 2005196f6bb..5ceb7b1a0f8 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,6 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; +import { buildProjectPickerDescription } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { useEffect, useMemo, useRef } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; @@ -14,6 +15,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects, useThreadShells } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; +import { useEnvironments } from "../../state/environments"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; @@ -81,6 +83,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps groupProjectsByRepository({ projects, threads }), [projects, threads], ); + const environmentLabelById = useMemo( + () => + new Map(environments.map((environment) => [environment.environmentId, environment.label])), + [environments], + ); + const showProjectEnvironmentLabels = environments.length > 1; const items = useMemo(() => { const nextItems: Array<{ readonly environmentId: EnvironmentId; @@ -111,6 +120,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps = []; for (const group of repositoryGroups) { const project = group.projects[0]?.project; @@ -123,10 +133,11 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps(null); const reservedDestinationProject = incomingShare?.destination @@ -313,6 +324,16 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps {item.title} + + {buildProjectPickerDescription({ + workspaceRoot: item.workspaceRoot, + environmentLabel: item.environmentLabel, + showEnvironmentLabel: showProjectEnvironmentLabels, + })} + { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps machine identity visible for a remote non-git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + activeEnvironment: { isPrimary: false }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("keeps the existing git controls for a primary project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(true); + }); + + it("hides the strip for a sole primary non-git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + activeEnvironment: { isPrimary: true }, + canPickEnvironment: false, + }), + ).toBe(false); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a32..8a702b34bb8 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,6 +54,17 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + activeEnvironment: Pick | null; + canPickEnvironment: boolean; +}): boolean { + if (!input.hasActiveProject) return false; + if (input.isGitRepo) return true; + return shouldShowEnvironmentIndicator(input); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3a83f5c9a0f..66aba76afc0 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -56,6 +56,7 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + showWorkspaceControls?: boolean; } interface MobileRunContextSelectorProps { @@ -229,6 +230,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, + showWorkspaceControls = true, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -301,11 +303,24 @@ export const BranchToolbar = memo(function BranchToolbar({ }); const isMobile = useIsMobile(); - if (!hasActiveThread || !activeProject) return null; + if (!hasActiveThread || !activeProject || (!showWorkspaceControls && !showEnvironmentIndicator)) { + return null; + } return (
- {isMobile ? ( + {!showWorkspaceControls ? ( +
+ {availableEnvironments ? ( + + ) : null} +
+ ) : isMobile ? ( )} - + {showWorkspaceControls ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..222d7505e87 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -229,7 +229,11 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, + shouldShowComposerContextStrip, +} from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, @@ -2501,7 +2505,17 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const showComposerContextStrip = isGitRepo && activeProject !== null; + const activeProjectEnvironment = activeThread + ? (logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread.environmentId, + ) ?? null) + : null; + const showComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + activeEnvironment: activeProjectEnvironment, + canPickEnvironment: hasMultipleEnvironments, + }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -6020,6 +6034,7 @@ function ChatViewContent(props: ChatViewProps) { : {})} {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})} availableEnvironments={logicalProjectEnvironments} + showWorkspaceControls={isGitRepo} /> )} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4d591500f5c..bbc035c4480 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Thread } from "../types"; import { buildBrowseGroups, + buildProjectActionItems, buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, @@ -111,6 +112,32 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +describe("buildProjectActionItems", () => { + it("uses the caller-provided machine-aware description", () => { + const project = { + id: PROJECT_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + title: "Desktop", + workspaceRoot: "/Users/henry/Desktop", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }; + + const [item] = buildProjectActionItems({ + projects: [project], + valuePrefix: "new-thread-in", + description: (candidate) => `${candidate.workspaceRoot} · Henry's Mac Studio`, + icon: () => null, + runProject: async () => undefined, + }); + + expect(item?.description).toBe("/Users/henry/Desktop · Henry's Mac Studio"); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eee6ba5886e..8ad00ed6e84 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -130,12 +130,13 @@ export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } -export function buildProjectActionItems(input: { - projects: ReadonlyArray; +export function buildProjectActionItems(input: { + projects: ReadonlyArray; valuePrefix: string; - icon: (project: Project) => ReactNode; - runProject: (project: Project) => Promise; - searchTerms?: (project: Project) => ReadonlyArray; + icon: (project: TProject) => ReactNode; + runProject: (project: TProject) => Promise; + description?: (project: TProject) => ReactNode; + searchTerms?: (project: TProject) => ReadonlyArray; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -143,7 +144,7 @@ export function buildProjectActionItems(input: { value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], title: project.title, - description: project.workspaceRoot, + description: input.description?.(project) ?? project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 853d317655a..5add4305416 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { buildProjectPickerDescription } from "@t3tools/client-runtime/state/project-grouping"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { @@ -602,10 +603,21 @@ function OpenCommandPaletteDialog(props: { const environmentLabelById = useMemo( () => new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), + environments.map( + (environment) => + [ + environment.environmentId, + resolveEnvironmentOptionLabel({ + isPrimary: environment.environmentId === primaryEnvironmentId, + environmentId: environment.environmentId, + runtimeLabel: environment.label, + }), + ] as const, + ), ), - [environments], + [environments, primaryEnvironmentId], ); + const showProjectEnvironmentLabels = environments.length > 1; const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -918,10 +930,20 @@ function OpenCommandPaletteDialog(props: { buildProjectActionItems({ projects: pickerProjects, valuePrefix: "project", + description: (project) => + buildProjectPickerDescription({ + workspaceRoot: project.workspaceRoot, + environmentLabel: project.environmentLabel, + showEnvironmentLabel: showProjectEnvironmentLabels, + }), searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); return ( - group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + group?.memberProjects.flatMap((member) => [ + member.title, + member.workspaceRoot, + member.environmentLabel ?? "", + ]) ?? [] ); }, icon: (project) => ( @@ -933,7 +955,7 @@ function OpenCommandPaletteDialog(props: { ), runProject: openProjectFromSearch, }), - [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], + [openProjectFromSearch, pickerProjects, projectGroupByTargetKey, showProjectEnvironmentLabels], ); const projectThreadItems = useMemo( @@ -942,10 +964,20 @@ function OpenCommandPaletteDialog(props: { buildProjectActionItems({ projects: pickerProjects, valuePrefix: "new-thread-in", + description: (project) => + buildProjectPickerDescription({ + workspaceRoot: project.workspaceRoot, + environmentLabel: project.environmentLabel, + showEnvironmentLabel: showProjectEnvironmentLabels, + }), searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); return ( - group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + group?.memberProjects.flatMap((member) => [ + member.title, + member.workspaceRoot, + member.environmentLabel ?? "", + ]) ?? [] ); }, icon: (project) => ( @@ -972,7 +1004,13 @@ function OpenCommandPaletteDialog(props: { }, }), ), - [contextualProjectRef, handleNewThread, pickerProjects, projectGroupByTargetKey], + [ + contextualProjectRef, + handleNewThread, + pickerProjects, + projectGroupByTargetKey, + showProjectEnvironmentLabels, + ], ); const allThreadItems = useMemo( @@ -1355,9 +1393,18 @@ function OpenCommandPaletteDialog(props: { const actionItems: Array = []; if (projects.length > 0) { + const activeProjectEntry = projectPickerEntries.find((entry) => entry.isPreferred) ?? null; const activeProjectTitle = - projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? + activeProjectEntry?.group.displayName ?? (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + const activeProjectDescription = + showProjectEnvironmentLabels && activeProjectEntry + ? buildProjectPickerDescription({ + workspaceRoot: activeProjectEntry.targetProject.workspaceRoot, + environmentLabel: activeProjectEntry.targetProject.environmentLabel, + showEnvironmentLabel: true, + }) + : null; if (activeProjectTitle) { actionItems.push({ @@ -1369,6 +1416,7 @@ function OpenCommandPaletteDialog(props: { New thread in {activeProjectTitle} ), + ...(activeProjectDescription ? { description: activeProjectDescription } : {}), icon: , shortcutCommand: "chat.new", run: async () => { diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4407e621525..2a53c3e03cc 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,5 +1,6 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { buildProjectPickerDescription } from "@t3tools/client-runtime/state/project-grouping"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; @@ -13,6 +14,7 @@ import { } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { resolveEnvironmentOptionLabel } from "../BranchToolbar.logic"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, @@ -45,10 +47,21 @@ export function DraftHeroHeadline({ const environmentLabelById = useMemo( () => new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), + environments.map( + (environment) => + [ + environment.environmentId, + resolveEnvironmentOptionLabel({ + isPrimary: environment.environmentId === primaryEnvironmentId, + environmentId: environment.environmentId, + runtimeLabel: environment.label, + }), + ] as const, + ), ), - [environments], + [environments, primaryEnvironmentId], ); + const showProjectEnvironmentLabels = environments.length > 1; const projectGroups = useMemo( () => sortLogicalProjectsForSidebar( @@ -119,10 +132,21 @@ export function DraftHeroHeadline({ }); }} > - {projectPickerEntries.map(({ group }) => { + {projectPickerEntries.map(({ group, targetProject }) => { return ( - {group.displayName} + + {group.displayName} + {showProjectEnvironmentLabels ? ( + + {buildProjectPickerDescription({ + workspaceRoot: targetProject.workspaceRoot, + environmentLabel: targetProject.environmentLabel, + showEnvironmentLabel: true, + })} + + ) : null} + ); })} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 05418022d23..a9ec2e46a8a 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -121,6 +121,9 @@ npx t3 serve --tailscale-serve --tailscale-serve-port 8443 Once paired, add projects normally: open the Command Palette and choose **Add Project**, then pick the environment the project lives on. Every saved environment is offered, not only the local one. +When multiple environments are connected, project choosers show the environment name beside each +workspace path. In the web and desktop clients, remote conversations keep that environment visible +below the composer. ### Option 3: Desktop-Managed SSH Launch diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts new file mode 100644 index 00000000000..ce66c54af83 --- /dev/null +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildProjectPickerDescription } from "./projectGrouping.ts"; + +describe("buildProjectPickerDescription", () => { + it("adds the selected environment when several environments are visible", () => { + expect( + buildProjectPickerDescription({ + workspaceRoot: "/Users/henry/Desktop", + environmentLabel: "Henry's Mac Studio", + showEnvironmentLabel: true, + }), + ).toBe("/Users/henry/Desktop · Henry's Mac Studio"); + }); + + it("keeps the workspace path compact for a single environment", () => { + expect( + buildProjectPickerDescription({ + workspaceRoot: "/Users/henry/Desktop", + environmentLabel: "Henry's MacBook", + showEnvironmentLabel: false, + }), + ).toBe("/Users/henry/Desktop"); + }); +}); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index ca804c13809..97bdccadd70 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -12,6 +12,16 @@ export interface ProjectGroupingSettings { export type ProjectGroupingMode = SidebarProjectGroupingMode; +export function buildProjectPickerDescription(input: { + readonly workspaceRoot: string; + readonly environmentLabel: string | null; + readonly showEnvironmentLabel: boolean; +}): string { + return input.showEnvironmentLabel && input.environmentLabel + ? `${input.workspaceRoot} · ${input.environmentLabel}` + : input.workspaceRoot; +} + export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings { return { sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode, From fb58657f16b69099eaebd9e9101f9504483c1b0a Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:16:52 +0800 Subject: [PATCH 2/2] fix(mobile): expose scoped projects in new task flows --- .../features/threads/NewTaskRouteScreen.tsx | 13 ++-- .../threads/new-task-flow-provider.tsx | 25 ++------ apps/mobile/src/lib/repositoryGroups.test.ts | 64 ++++++++++++++++++- apps/mobile/src/lib/repositoryGroups.ts | 6 ++ 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 5ceb7b1a0f8..3d47c3fb0af 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -16,7 +16,10 @@ import { useProjects, useThreadShells } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; import { useEnvironments } from "../../state/environments"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { + expandRepositoryGroupProjects, + groupProjectsByRepository, +} from "../../lib/repositoryGroups"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; @@ -122,15 +125,11 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps = []; - for (const group of repositoryGroups) { - const project = group.projects[0]?.project; - if (!project) { - continue; - } + for (const { key, project } of expandRepositoryGroupProjects(repositoryGroups)) { nextItems.push({ environmentId: project.environmentId, id: project.id, - key: group.key, + key, title: project.title, workspaceRoot: project.workspaceRoot, environmentLabel: environmentLabelById.get(project.environmentId) ?? null, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 8d4ce7a7fed..96c66908bed 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -27,7 +27,10 @@ import { groupByProvider, resolveSelectableModelSelection, } from "../../lib/modelOptions"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { + expandRepositoryGroupProjects, + groupProjectsByRepository, +} from "../../lib/repositoryGroups"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; import { @@ -181,25 +184,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [projects, threads], ); const logicalProjects = useMemo( - () => - pipe( - repositoryGroups, - Arr.map((group) => { - const primaryProject = group.projects[0]?.project; - if (!primaryProject) { - return null; - } - return { key: group.key, project: primaryProject }; - }), - Arr.filter( - ( - entry, - ): entry is { - readonly key: string; - readonly project: EnvironmentProject; - } => entry !== null, - ), - ), + () => expandRepositoryGroupProjects(repositoryGroups), [repositoryGroups], ); diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index ab4311524ce..37c726202d3 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { groupProjectsByRepository } from "./repositoryGroups"; +import { expandRepositoryGroupProjects, groupProjectsByRepository } from "./repositoryGroups"; import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; function makeProject( @@ -44,6 +44,68 @@ function makeThread( } describe("groupProjectsByRepository", () => { + it("expands identical repository workspaces into scoped projects for every environment", () => { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + provider: "github", + owner: "t3tools", + name: "t3code", + displayName: "T3 Code", + }; + const projects = [ + makeProject({ + environmentId: EnvironmentId.make("env-macbook"), + id: ProjectId.make("project-t3code"), + title: "T3 Code", + workspaceRoot: "/Users/henry/Desktop/t3code", + repositoryIdentity, + }), + makeProject({ + environmentId: EnvironmentId.make("env-studio"), + id: ProjectId.make("project-t3code"), + title: "T3 Code", + workspaceRoot: "/Users/henry/Desktop/t3code", + repositoryIdentity, + }), + ]; + + const expanded = expandRepositoryGroupProjects( + groupProjectsByRepository({ projects, threads: [] }), + ); + + expect( + expanded + .map(({ key, project }) => ({ + key, + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + })) + .sort((left, right) => left.key.localeCompare(right.key)), + ).toEqual([ + { + key: "env-macbook:project-t3code", + environmentId: "env-macbook", + projectId: "project-t3code", + title: "T3 Code", + workspaceRoot: "/Users/henry/Desktop/t3code", + }, + { + key: "env-studio:project-t3code", + environmentId: "env-studio", + projectId: "project-t3code", + title: "T3 Code", + workspaceRoot: "/Users/henry/Desktop/t3code", + }, + ]); + }); + it("groups projects across environments by repository identity", () => { const repoIdentity = { canonicalKey: "github.com/t3tools/t3code", diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts index bf4c2f3fccd..a945b40a95e 100644 --- a/apps/mobile/src/lib/repositoryGroups.ts +++ b/apps/mobile/src/lib/repositoryGroups.ts @@ -25,6 +25,12 @@ export interface RepositoryGroup { readonly projects: ReadonlyArray; } +export function expandRepositoryGroupProjects( + groups: ReadonlyArray, +): ReadonlyArray { + return Arr.flatMap(groups, (group) => group.projects); +} + function compareIsoDateDescending(left: string, right: string): number { return new Date(right).getTime() - new Date(left).getTime(); }