diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 2bd7c118c69f..12fe54cc5081 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -345,6 +345,28 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildProjectActionItems", () => { + it("keeps offline checkouts visible while skipping them during keyboard navigation", () => { + const local = makeProject(); + const remote = makeProject({ + id: ProjectId.make("remote-copy"), + environmentId: EnvironmentId.make("offline"), + }); + const items = buildProjectActionItems({ + projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), + valuePrefix: "new-thread-in", + icon: () => null, + runProject: async () => undefined, + disabled: (project) => project.environmentId === remote.environmentId, + }); + const groups = [{ value: "projects", label: "Projects", items }]; + const { rows, itemValues } = buildCommandPaletteRows(groups); + + expect(rows.filter((row) => row.kind === "item")).toHaveLength(2); + expect(itemValues).toEqual([`new-thread-in:${local.environmentId}:${local.id}`]); + expect(findHighlightedCommandPaletteItem(groups, items[0]!.value)).toBeNull(); + expect(findHighlightedCommandPaletteItem(groups, items[1]!.value)).toBe(items[1]); + }); + it("shows the grouped display name but keeps the real title for icons", () => { const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); const iconTitles: string[] = []; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 49e963f68313..eb3f6da44752 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -274,6 +274,7 @@ export function buildProjectActionItems(input: { runProject: (project: CommandPaletteProject) => Promise; searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; + disabled?: (project: CommandPaletteProject) => boolean; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -288,6 +289,7 @@ export function buildProjectActionItems(input: { title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), + ...(input.disabled ? { disabled: input.disabled(project) } : {}), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 80aedc344fb1..a60122bf5fc3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -15,7 +15,7 @@ import { getNewProjectPathPreview, normalizePastedCloneUrl, } from "@t3tools/client-runtime/operations/projects"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -979,6 +979,28 @@ function OpenCommandPaletteDialog(props: { }), [contextualProjectRef, projectGroups], ); + const isEnvironmentReachable = useCallback( + (environmentId: EnvironmentId) => + environments.some( + (environment) => + environment.environmentId === environmentId && + canCreateProjectInEnvironment(environment.connection.phase), + ), + [environments], + ); + const threadPickerProjects = useMemo( + () => + buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: contextualProjectRef, + expandCheckouts: true, + isEnvironmentReachable, + }).map(({ group, targetProject }) => ({ + ...targetProject, + displayName: group.displayName, + })), + [contextualProjectRef, isEnvironmentReachable, projectGroups], + ); const pickerProjects = useMemo( () => projectPickerEntries.map(({ group, targetProject }) => ({ @@ -1335,18 +1357,17 @@ function OpenCommandPaletteDialog(props: { const projectItems = enumerateCommandPaletteItems( buildProjectActionItems({ // The no-project home shows once, as the "No project" item below. - projects: pickerProjects.filter((project) => !isScratch(project)), + projects: threadPickerProjects.filter((project) => !isScratch(project)), valuePrefix: "new-thread-in", - searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; - }, + disabled: (project) => !isEnvironmentReachable(project.environmentId), + searchTerms: (project) => [ + project.workspaceRoot, + projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote", + ], renderDescription: (project) => { + const environment = environments.find( + (candidate) => candidate.environmentId === project.environmentId, + ); const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", @@ -1366,24 +1387,23 @@ function OpenCommandPaletteDialog(props: { {project.workspaceRoot} + {isEnvironmentReachable(project.environmentId) ? null : ( + <> + + + {environment ? connectionStatusTitle(environment.connection) : "Unavailable"} + + + )} ); }, icon: projectFaviconIcon, + // Each row is one checkout, so pin it instead of letting load balancing move the draft. runProject: async (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - const contextualRefBelongsToGroup = - contextualProjectRef !== null && - group?.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === contextualProjectRef.environmentId && - projectRef.projectId === contextualProjectRef.projectId, - ); - await handleNewThread( - contextualRefBelongsToGroup - ? contextualProjectRef - : scopeProjectRef(project.environmentId, project.id), - ); + await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + environmentSelection: "manual", + }); }, }), ); @@ -1393,7 +1413,8 @@ function OpenCommandPaletteDialog(props: { // scrolling past every project, while Enter still starts in the current // one. When the current thread has no project, it is the current entry and // goes first. It keeps its own shortcut, so the projects' mod+1..9 hold. - const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; + const noProjectIndex = + threadPickerProjects[0] !== undefined && isScratch(threadPickerProjects[0]) ? 0 : 1; return [ ...projectItems.slice(0, noProjectIndex), { @@ -1408,14 +1429,14 @@ function OpenCommandPaletteDialog(props: { ...projectItems.slice(noProjectIndex), ]; }, [ - contextualProjectRef, + environments, handleNewThread, - pickerProjects, + isEnvironmentReachable, projectEnvironmentLocationById, - projectGroupByTargetKey, scratchTargetEnvironmentId, scratchWorkspaceRootFor, startScratchThread, + threadPickerProjects, ]); const allThreadItems = useMemo( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8a5db463884e..e26972ce0a60 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4942,12 +4942,16 @@ export default function Sidebar() { // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu // for multi-project setups. + const projectChoiceCount = projectGroups.reduce( + (count, group) => count + group.memberProjects.length, + 0, + ); const handleNewThreadClick = useCallback( (event?: ReactMouseEvent) => { - // One project: nothing to pick, create immediately. Shift+click creates + // One checkout: nothing to pick, create immediately. Shift+click creates // directly in the current project even with several projects, skipping // the palette picker. - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectChoiceCount)) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -4960,7 +4964,7 @@ export default function Sidebar() { if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); }, - [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + [isMobile, newThreadContext, projectChoiceCount, setOpenMobile], ); // The button mirrors chat.new: in multi-project setups both route through @@ -4973,7 +4977,7 @@ export default function Sidebar() { // shift+click and its keyboard twin chat.newLocal for direct create. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (projectChoiceCount <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -5125,7 +5129,7 @@ export default function Sidebar() { newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectGroups.length > 1} + showNewThreadInProjectHint={projectChoiceCount > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index efb6d8412ce0..e0f3767dc692 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -426,6 +426,80 @@ describe("environment grouping", () => { }); }); + it("offers every checkout while an offline remote is selected", () => { + const primary = makeProject({ repositoryIdentity }); + const worktree = makeProject({ + id: ProjectId.make("local-worktree"), + workspaceRoot: "/tmp/shared-repo-feature", + repositoryIdentity, + }); + const remote = makeProject({ + id: ProjectId.make("remote-copy"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const groups = buildSidebarProjectSnapshots({ + projects: [remote, primary, worktree], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { environmentId: remoteEnvironmentId, projectId: remote.id }, + expandCheckouts: true, + isEnvironmentReachable: (environmentId) => environmentId === primaryEnvironmentId, + }); + + expect(groups).toHaveLength(1); + expect(entries.map(({ targetProject }) => targetProject.id)).toEqual([ + primary.id, + worktree.id, + remote.id, + ]); + expect(entries[2]?.isPreferred).toBe(true); + expect(entries.map(({ group }) => group.projectKey)).toEqual([ + repositoryIdentity.canonicalKey, + repositoryIdentity.canonicalKey, + repositoryIdentity.canonicalKey, + ]); + }); + + it("prefers the exact connected worktree while keeping other copies available", () => { + const primary = makeProject({ repositoryIdentity }); + const worktree = makeProject({ + id: ProjectId.make("local-worktree"), + workspaceRoot: "/tmp/shared-repo-feature", + repositoryIdentity, + }); + const remote = makeProject({ + id: ProjectId.make("remote-copy"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const groups = buildSidebarProjectSnapshots({ + projects: [primary, worktree, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { environmentId: primaryEnvironmentId, projectId: worktree.id }, + expandCheckouts: true, + isEnvironmentReachable: () => true, + }); + + expect(entries.map(({ targetProject }) => targetProject.id)).toEqual([ + worktree.id, + primary.id, + remote.id, + ]); + expect( + entries.filter((entry) => entry.isPreferred).map(({ targetProject }) => targetProject.id), + ).toEqual([worktree.id]); + }); + it("keeps manual project order when building grouped sidebar entries", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 17597e213d65..d43ededf713b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -72,6 +72,8 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + // Pin the draft to this checkout instead of letting load balancing move it. + environmentSelection?: "manual"; }, // Which draft the thread ended up in, so a caller that has something to put in it — a // prepared checkout, a task to write — addresses that one rather than looking the project @@ -168,6 +170,10 @@ export function useNewThreadHandler() { const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + const selectionOptions = + options?.environmentSelection === "manual" + ? { environmentSelection: "manual" as const, loadBalancedEnvironmentId: null } + : {}; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -299,6 +305,7 @@ export function useNewThreadHandler() { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, + ...selectionOptions, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, @@ -349,6 +356,7 @@ export function useNewThreadHandler() { runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), + ...selectionOptions, }); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -392,6 +400,7 @@ export function useNewThreadHandler() { runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), + ...selectionOptions, }); await router.navigate({ to: "/draft/$draftId", @@ -413,6 +422,7 @@ export function useNewThreadHandler() { newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, }), runtimeMode: defaultRuntimeMode, + ...selectionOptions, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 914b0cac844d..41e60099b450 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -136,8 +136,33 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; + expandCheckouts?: boolean; + isEnvironmentReachable?: (environmentId: EnvironmentId) => boolean; }) { const preferredProjectRef = input.preferredProjectRef; + if (input.expandCheckouts) { + // memberProjects already deduplicates physical checkouts. Keep distinct + // worktrees on the same environment as separate choices. + return input.groups + .flatMap((group) => + group.memberProjects.map((targetProject): SidebarProjectPickerEntry => ({ + group, + targetProject, + isPreferred: + preferredProjectRef !== null && + targetProject.environmentId === preferredProjectRef.environmentId && + targetProject.id === preferredProjectRef.projectId, + })), + ) + .toSorted((left, right) => { + const reachability = input.isEnvironmentReachable; + const reachableDelta = reachability + ? Number(reachability(right.targetProject.environmentId)) - + Number(reachability(left.targetProject.environmentId)) + : 0; + return reachableDelta || Number(right.isPreferred) - Number(left.isPreferred); + }); + } const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { const isPreferred = preferredProjectRef ? group.memberProjectRefs.some(