diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index f051900d0d48..d11c39407f4a 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -364,6 +364,19 @@ describe("buildProjectActionItems", () => { ); expect(iconTitles).toEqual(["fleet"]); }); + + it("marks unreachable projects disabled", () => { + const project = makeProject({ title: "fleet" }); + const [item] = buildProjectActionItems({ + projects: [{ ...project, displayName: "fleet" }], + valuePrefix: "new-thread-in", + icon: () => null, + runProject: async () => undefined, + isDisabled: () => true, + }); + + expect(item?.disabled).toBe(true); + }); }); describe("buildThreadActionItems", () => { diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e2114079e729..50440fa470fb 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -214,6 +214,7 @@ export function buildProjectActionItems(input: { runProject: (project: CommandPaletteProject) => Promise; searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; + isDisabled?: (project: CommandPaletteProject) => boolean; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ @@ -228,6 +229,7 @@ export function buildProjectActionItems(input: { title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), + ...(input.isDisabled?.(project) === true ? { disabled: true } : {}), ...(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 912f294d9538..39636095ddb8 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -925,13 +925,32 @@ function OpenCommandPaletteDialog(props: { }), [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], ); + const isEnvironmentReachable = useCallback( + (environmentId: EnvironmentId) => + canCreateProjectInEnvironment( + environments.find((environment) => environment.environmentId === environmentId)?.connection + .phase, + ), + [environments], + ); const projectPickerEntries = useMemo( () => buildSidebarProjectPickerEntries({ groups: projectGroups, preferredProjectRef: contextualProjectRef, + isEnvironmentReachable, }), - [contextualProjectRef, projectGroups], + [contextualProjectRef, isEnvironmentReachable, projectGroups], + ); + const newThreadPickerEntries = useMemo( + () => + buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: contextualProjectRef, + expandMembers: true, + isEnvironmentReachable, + }), + [contextualProjectRef, isEnvironmentReachable, projectGroups], ); const pickerProjects = useMemo( () => @@ -951,6 +970,24 @@ function OpenCommandPaletteDialog(props: { ), [projectPickerEntries], ); + const newThreadProjects = useMemo( + () => + newThreadPickerEntries.map(({ group, targetProject }) => ({ + ...targetProject, + displayName: group.displayName, + })), + [newThreadPickerEntries], + ); + const newThreadGroupByTargetKey = useMemo( + () => + new Map( + newThreadPickerEntries.map(({ group, targetProject }) => [ + `${targetProject.environmentId}:${targetProject.id}`, + group, + ]), + ), + [newThreadPickerEntries], + ); const addProjectEnvironmentOptions = useMemo(() => { const options = environments.map((environment): AddProjectEnvironmentOption => { @@ -1260,10 +1297,10 @@ function OpenCommandPaletteDialog(props: { () => enumerateCommandPaletteItems( buildProjectActionItems({ - projects: pickerProjects, + projects: newThreadProjects, valuePrefix: "new-thread-in", searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const group = newThreadGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const location = projectEnvironmentLocationById.get(project.environmentId); return [ ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? @@ -1277,6 +1314,13 @@ function OpenCommandPaletteDialog(props: { label: "Remote", machine: "server" as const, }; + const environment = environments.find( + (candidate) => candidate.environmentId === project.environmentId, + ); + const reachable = isEnvironmentReachable(project.environmentId); + const status = environment + ? connectionStatusText(environment.connection) + : "Unavailable"; return ( @@ -1290,34 +1334,24 @@ function OpenCommandPaletteDialog(props: { {location.label} - {project.workspaceRoot} + {reachable ? project.workspaceRoot : status} ); }, + isDisabled: (project) => !isEnvironmentReachable(project.environmentId), icon: projectFavicon, 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)); }, }), ), [ - contextualProjectRef, + environments, handleNewThread, - pickerProjects, + isEnvironmentReachable, + newThreadGroupByTargetKey, + newThreadProjects, projectEnvironmentLocationById, - projectGroupByTargetKey, ], ); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 07956c2b299e..277770d305c7 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,7 +1,12 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; -import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; +import { + resolveEnvironmentMachineKind, + type EnvironmentId, + type ScopedProjectRef, +} from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; import { FolderPlusIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; @@ -105,16 +110,37 @@ export function DraftHeroHeadline({ ), [environments], ); + const isEnvironmentReachable = useCallback( + (environmentId: EnvironmentId) => + canCreateProjectInEnvironment( + environments.find((environment) => environment.environmentId === environmentId)?.connection + .phase, + ), + [environments], + ); const projectPickerEntries = useMemo( () => buildSidebarProjectPickerEntries({ groups: projectGroups, preferredProjectRef: activeProjectRef, + expandMembers: true, + isEnvironmentReachable, }), - [activeProjectRef, projectGroups], + [activeProjectRef, isEnvironmentReachable, projectGroups], ); const projectEntryByKey = useMemo( - () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)), + () => + new Map( + projectPickerEntries.map( + (entry) => + [ + scopedProjectKey( + scopeProjectRef(entry.targetProject.environmentId, entry.targetProject.id), + ), + entry, + ] as const, + ), + ), [projectPickerEntries], ); const activeProjectGroup = @@ -125,7 +151,7 @@ export function DraftHeroHeadline({ (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef), ), ) ?? null); - const activeProjectKey = activeProjectGroup?.projectKey ?? ""; + const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef); const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; @@ -155,7 +181,7 @@ export function DraftHeroHeadline({ value={activeProjectKey} onValueChange={(value) => { const entry = projectEntryByKey.get(value as string); - if (!entry || value === activeProjectKey) { + if (!entry || !entry.reachable || value === activeProjectKey) { return; } const project = entry.targetProject; @@ -188,11 +214,15 @@ export function DraftHeroHeadline({ } }} > - {projectPickerEntries.map(({ group }) => { + {projectPickerEntries.map(({ group, targetProject, reachable }) => { + const value = scopedProjectKey( + scopeProjectRef(targetProject.environmentId, targetProject.id), + ); return ( @@ -207,7 +237,7 @@ export function DraftHeroHeadline({ {showProjectEnvironments ? ( diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index efb6d8412ce0..fa9648c2b32a 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -355,9 +355,75 @@ describe("environment grouping", () => { id: remote.id, }); expect(entries[0]?.isPreferred).toBe(true); + expect(entries[0]?.reachable).toBe(true); expect(entries[1]?.group.displayName).toBe("separate"); }); + it("lists each checkout when expanding grouped members", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const groups = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: remote.id, + }, + expandMembers: true, + isEnvironmentReachable: (environmentId) => environmentId === primaryEnvironmentId, + }); + + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.targetProject.environmentId)).toEqual([ + primaryEnvironmentId, + remoteEnvironmentId, + ]); + expect(entries[0]?.reachable).toBe(true); + expect(entries[1]?.reachable).toBe(false); + expect(entries[1]?.isPreferred).toBe(true); + }); + + it("targets a reachable sibling when the preferred environment is down", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const groups = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const [entry] = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: remote.id, + }, + isEnvironmentReachable: (environmentId) => environmentId === primaryEnvironmentId, + }); + + expect(entry?.isPreferred).toBe(true); + expect(entry?.reachable).toBe(true); + expect(entry?.targetProject).toMatchObject({ + environmentId: primaryEnvironmentId, + id: primary.id, + }); + }); + it("keeps the current environment when available and falls back otherwise", () => { const currentPrimary = makeProject({ repositoryIdentity }); const currentRemote = makeProject({ diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6cae988e87c6..bf0ed0f59384 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -2,8 +2,23 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { RuntimeMode } from "@t3tools/contracts"; const testState = vi.hoisted(() => { + const remoteProject = { + id: "project-remote", + environmentId: "environment-ssh", + workspaceRoot: "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/remote/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, + }; + const localProject = { + id: "project-local", + environmentId: "environment-primary", + workspaceRoot: "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/local/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, + }; let completeProjectFileRead: (value: null) => void = () => undefined; let projectFileRead = Promise.resolve(null); + let projectFileReadCalls = 0; let targetSettings = { defaultThreadEnvMode: "local" as "local" | "worktree", newWorktreesStartFromOrigin: false, @@ -16,6 +31,19 @@ const testState = vi.hoisted(() => { readonly promotedTo: null; readonly threadId: string; } | null = null; + let projects = [remoteProject]; + let environments = [ + { + environmentId: "environment-primary", + label: "Local", + connection: { phase: "connected" }, + }, + { + environmentId: "environment-ssh", + label: "Mac mini", + connection: { phase: "connected" }, + }, + ]; const router = { state: { location: { href: "/" }, @@ -35,6 +63,7 @@ const testState = vi.hoisted(() => { setLogicalProjectDraftThreadId: vi.fn(), setModelSelection: vi.fn(), }; + const toastAdd = vi.fn(); return { completeProjectFileRead: (value: null) => completeProjectFileRead(value), @@ -42,9 +71,34 @@ const testState = vi.hoisted(() => { get projectFileRead() { return projectFileRead; }, + get projectFileReadCalls() { + return projectFileReadCalls; + }, + incrementProjectFileReadCalls() { + projectFileReadCalls += 1; + }, get targetSettings() { return targetSettings; }, + get projects() { + return projects; + }, + get environments() { + return environments; + }, + toastAdd, + remoteProject, + localProject, + setProjects(nextProjects: typeof projects) { + projects = nextProjects; + }, + setEnvironmentPhase(environmentId: string, phase: string) { + environments = environments.map((environment) => + environment.environmentId === environmentId + ? { ...environment, connection: { phase } } + : environment, + ); + }, reset( nextStoredDraft: typeof storedDraft, workspaceDefaults = { @@ -59,6 +113,21 @@ const testState = vi.hoisted(() => { defaultModelSelection: null, defaultRuntimeMode: "full-access", }; + projects = [remoteProject]; + environments = [ + { + environmentId: "environment-primary", + label: "Local", + connection: { phase: "connected" }, + }, + { + environmentId: "environment-ssh", + label: "Mac mini", + connection: { phase: "connected" }, + }, + ]; + projectFileReadCalls = 0; + toastAdd.mockClear(); router.state.location.href = "/"; router.navigate.mockClear(); draftStore.setDraftThreadContext.mockClear(); @@ -93,6 +162,9 @@ vi.mock("@t3tools/client-runtime/environment", () => ({ scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); +vi.mock("@t3tools/client-runtime/operations/projects", () => ({ + canCreateProjectInEnvironment: (phase: string | null | undefined) => phase === "connected", +})); vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, @@ -131,13 +203,20 @@ vi.mock("../composerDraftStore", () => { useComposerDraftStore, }; }); +vi.mock("../components/ui/toast", () => ({ + stackedThreadToast: (options: unknown) => options, + toastManager: { add: (...args: unknown[]) => testState.toastAdd(...args) }, +})); vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ ...(await importOriginal()), hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ - readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead, + readT3ProjectFileDefaultThreadEnvMode: () => { + testState.incrementProjectFileReadCalls(); + return testState.projectFileRead; + }, })); vi.mock("../lib/utils", () => ({ newDraftId: () => "draft-delayed", @@ -149,19 +228,15 @@ vi.mock("../logicalProject", () => ({ selectProjectGroupingSettings: () => ({}), })); vi.mock("../state/entities", () => ({ - readProjects: () => [ - { - id: "project-remote", - environmentId: "environment-ssh", - workspaceRoot: "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/remote/project", - defaultThreadEnvMode: null, - defaultModelSelection: null, - }, - ], + readProjects: () => testState.projects, readThreadShell: () => null, useProjects: () => [], useThread: () => null, })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: testState.environments }), + usePrimaryEnvironmentId: () => "environment-primary", +})); vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", @@ -279,4 +354,51 @@ describe.each([ ); }, ); + + it("does not wait on t3.json when the only copy is reconnecting", async () => { + testState.reset(draft); + testState.setEnvironmentPhase("environment-ssh", "reconnecting"); + const opened = await useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + expect(opened).toBeNull(); + expect(testState.projectFileReadCalls).toBe(0); + expect(testState.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + title: "Environment unavailable", + description: "Mac mini is not connected.", + }), + ); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); +}); + +describe("useNewThreadHandler environment retarget", () => { + it("opens the local copy when the selected remote is reconnecting", async () => { + testState.reset(null); + testState.setProjects([testState.remoteProject, testState.localProject]); + testState.setEnvironmentPhase("environment-ssh", "reconnecting"); + const pendingOpen = useNewThreadHandler()({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + testState.completeProjectFileRead(null); + const opened = await pendingOpen; + + expect(opened).toEqual({ + draftId: "draft-delayed", + threadId: "thread-delayed", + }); + expect(testState.toastAdd).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + { environmentId: "environment-primary", projectId: "project-local" }, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 65ac2307ce89..081721645262 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,9 +4,11 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { composerDraftHasUserContent, markPromotedDraftThreadByRef, @@ -27,10 +29,14 @@ import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; import { hasExplicitComposerModelSelection, + resolveAvailableNewThreadProjectRef, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, + resolveWorkspaceOptionsAfterEnvironmentRetarget, + shouldReadProjectFileForNewThreadDefaults, } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -57,6 +63,8 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef export function useNewThreadHandler() { const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { @@ -66,7 +74,7 @@ export function useNewThreadHandler() { return useCallback( ( - projectRef: ScopedProjectRef, + requestedProjectRef: ScopedProjectRef, options?: { branch?: string | null; worktreePath?: string | null; @@ -79,6 +87,53 @@ export function useNewThreadHandler() { // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); + const isEnvironmentReachable = (environmentId: typeof requestedProjectRef.environmentId) => + canCreateProjectInEnvironment( + environments.find((environment) => environment.environmentId === environmentId) + ?.connection.phase, + ); + const requestedProject = projects.find( + (candidate) => + candidate.id === requestedProjectRef.projectId && + candidate.environmentId === requestedProjectRef.environmentId, + ); + const requestedLogicalProjectKey = requestedProject + ? deriveLogicalProjectKeyFromSettings(requestedProject, projectGroupingSettings) + : scopedProjectKey(requestedProjectRef); + const siblingProjects = requestedProject + ? projects.filter( + (candidate) => + deriveLogicalProjectKeyFromSettings(candidate, projectGroupingSettings) === + requestedLogicalProjectKey, + ) + : []; + const projectRef = resolveAvailableNewThreadProjectRef({ + requested: requestedProjectRef, + members: siblingProjects.map((candidate) => ({ + environmentId: candidate.environmentId, + projectId: candidate.id, + isPrimary: candidate.environmentId === primaryEnvironmentId, + })), + isEnvironmentReachable, + }); + if (!isEnvironmentReachable(projectRef.environmentId)) { + const environment = environments.find( + (candidate) => candidate.environmentId === projectRef.environmentId, + ); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.label ?? "The selected environment"} is not connected.`, + }), + ); + return Promise.resolve(null); + } + const workspaceOptions = resolveWorkspaceOptionsAfterEnvironmentRetarget({ + requestedEnvironmentId: requestedProjectRef.environmentId, + targetEnvironmentId: projectRef.environmentId, + options, + }); const targetServerSettings = environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { @@ -124,11 +179,12 @@ export function useNewThreadHandler() { carrySourceShell?.interactionMode ?? carrySourceDraft?.interactionMode ?? null; - const project = projects.find( - (candidate) => - candidate.id === projectRef.projectId && - candidate.environmentId === projectRef.environmentId, - ); + const project = + projects.find( + (candidate) => + candidate.id === projectRef.projectId && + candidate.environmentId === projectRef.environmentId, + ) ?? requestedProject; // The resolver applies project overrides and, until the server has // folded them, the aggregate's own legacy fields. const projectSettings = resolveProjectSettings( @@ -151,10 +207,15 @@ export function useNewThreadHandler() { destinationDraftId, }); // The shared resolver owns the priority order. The t3.json read is - // skipped entirely when a higher-priority source decides, and its - // query atom caches per project after the first call. + // skipped entirely when a higher-priority source decides or the + // environment cannot serve: executeAtomQuery waits on a live RPC. const resolveDefaultEnvMode = async (): Promise => { - const consultProjectFile = project !== undefined && projectThreadEnvMode == null; + const connectionPhase = environments.find( + (environment) => environment.environmentId === projectRef.environmentId, + )?.connection.phase; + const consultProjectFile = + project !== undefined && + shouldReadProjectFileForNewThreadDefaults(projectThreadEnvMode, connectionPhase); return resolveDefaultThreadEnvMode({ projectSetting: projectThreadEnvMode, projectFile: consultProjectFile @@ -169,10 +230,10 @@ export function useNewThreadHandler() { const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; - const hasStartFromOriginOption = options?.startFromOrigin !== undefined; + const hasBranchOption = workspaceOptions?.branch !== undefined; + const hasWorktreePathOption = workspaceOptions?.worktreePath !== undefined; + const hasEnvModeOption = workspaceOptions?.envMode !== undefined; + const hasStartFromOriginOption = workspaceOptions?.startFromOrigin !== undefined; const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); const storedDraftThreadRef = storedDraftThread ? scopeThreadRef(storedDraftThread.environmentId, storedDraftThread.threadId) @@ -224,7 +285,7 @@ export function useNewThreadHandler() { // below and does not follow this guard. let workspaceContext: NewThreadWorkspaceOptions | null = null; if (hasExplicitWorkspaceOption) { - workspaceContext = pickExplicitWorkspaceOptions(options); + workspaceContext = pickExplicitWorkspaceOptions(workspaceOptions); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); if (routeChangedSinceRequest()) { @@ -346,14 +407,17 @@ export function useNewThreadHandler() { hasEnvModeOption || hasStartFromOriginOption ) { - setDraftThreadContext(currentRouteTarget.draftId, pickExplicitWorkspaceOptions(options)); + setDraftThreadContext( + currentRouteTarget.draftId, + pickExplicitWorkspaceOptions(workspaceOptions), + ); } setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { threadId: latestActiveDraftThread.threadId, createdAt: latestActiveDraftThread.createdAt, runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, - ...pickExplicitWorkspaceOptions(options), + ...pickExplicitWorkspaceOptions(workspaceOptions), }); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -365,7 +429,7 @@ export function useNewThreadHandler() { const threadId = newThreadId(); const createdAt = new Date().toISOString(); return (async () => { - const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); + const initialEnvMode = workspaceOptions?.envMode ?? (await resolveDefaultEnvMode()); if (routeChangedSinceRequest()) { return null; } @@ -396,7 +460,7 @@ export function useNewThreadHandler() { createdAt: racedDraft.createdAt, runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, - ...pickExplicitWorkspaceOptions(options), + ...pickExplicitWorkspaceOptions(workspaceOptions), }); await router.navigate({ to: "/draft/$draftId", @@ -408,11 +472,11 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, + branch: workspaceOptions?.branch ?? null, + worktreePath: workspaceOptions?.worktreePath ?? null, envMode: initialEnvMode, startFromOrigin: - options?.startFromOrigin ?? + workspaceOptions?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ envMode: initialEnvMode, newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, @@ -435,7 +499,14 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [environmentServerConfigs, getCurrentRouteTarget, projectGroupingSettings, router], + [ + environmentServerConfigs, + environments, + getCurrentRouteTarget, + primaryEnvironmentId, + projectGroupingSettings, + router, + ], ); } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2..f38e9be2af05 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -9,8 +9,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { resolveThreadActionProjectRef, hasExplicitComposerModelSelection, + resolveAvailableNewThreadProjectRef, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, + resolveWorkspaceOptionsAfterEnvironmentRetarget, + shouldReadProjectFileForNewThreadDefaults, startNewThreadFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -168,4 +171,83 @@ describe("chatThreadActions", () => { expect(didStart).toBe(false); expect(handleNewThread).not.toHaveBeenCalled(); }); + + it("keeps a reachable new-thread target", () => { + expect( + resolveAvailableNewThreadProjectRef({ + requested: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + members: [ + { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + isPrimary: false, + }, + { + environmentId: EnvironmentId.make("environment-primary"), + projectId: FALLBACK_PROJECT_ID, + isPrimary: true, + }, + ], + isEnvironmentReachable: () => true, + }), + ).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("retargets an unreachable new-thread request to a primary sibling", () => { + const primaryEnvironmentId = EnvironmentId.make("environment-primary"); + expect( + resolveAvailableNewThreadProjectRef({ + requested: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + members: [ + { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + isPrimary: false, + }, + { + environmentId: primaryEnvironmentId, + projectId: FALLBACK_PROJECT_ID, + isPrimary: true, + }, + ], + isEnvironmentReachable: (environmentId) => environmentId === primaryEnvironmentId, + }), + ).toEqual(scopeProjectRef(primaryEnvironmentId, FALLBACK_PROJECT_ID)); + }); + + it("returns the requested project when no sibling is reachable", () => { + expect( + resolveAvailableNewThreadProjectRef({ + requested: scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), + members: [ + { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + isPrimary: false, + }, + ], + isEnvironmentReachable: () => false, + }), + ).toEqual(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID)); + }); + + it("clears machine-specific workspace options after retargeting environments", () => { + expect( + resolveWorkspaceOptionsAfterEnvironmentRetarget({ + requestedEnvironmentId: ENVIRONMENT_ID, + targetEnvironmentId: EnvironmentId.make("environment-primary"), + options: { branch: "feature", worktreePath: "/tmp/wt", envMode: "worktree" as const }, + }), + ).toEqual({ branch: null, worktreePath: null, envMode: "worktree" }); + }); + + it("reads t3.json only when the environment is connected and no project setting exists", () => { + expect(shouldReadProjectFileForNewThreadDefaults(null, "connected")).toBe(true); + expect(shouldReadProjectFileForNewThreadDefaults(undefined, "connected")).toBe(true); + expect(shouldReadProjectFileForNewThreadDefaults("worktree", "connected")).toBe(false); + expect(shouldReadProjectFileForNewThreadDefaults(null, "reconnecting")).toBe(false); + expect(shouldReadProjectFileForNewThreadDefaults(null, "connecting")).toBe(false); + expect(shouldReadProjectFileForNewThreadDefaults(null, "offline")).toBe(false); + expect(shouldReadProjectFileForNewThreadDefaults(null, null)).toBe(false); + }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c14a26d03d1c..54eb3f489a5a 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,9 +1,12 @@ +import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ModelSelection, ProjectId, ScopedProjectRef, + ThreadEnvMode, } from "@t3tools/contracts"; import type { ComposerThreadDraftState, DraftThreadEnvMode } from "../composerDraftStore"; @@ -83,6 +86,56 @@ export function resolveThreadActionProjectRef( return context.defaultProjectRef; } +/** + * `executeAtomQuery` waits on a live RPC. Skip the optional `t3.json` read + * unless the target can serve — connecting / reconnecting / offline would + * leave New Chat hanging with no draft and no toast. + */ +export function shouldReadProjectFileForNewThreadDefaults( + projectThreadEnvMode: ThreadEnvMode | null | undefined, + connectionPhase: EnvironmentConnectionPhase | null | undefined, +): boolean { + return projectThreadEnvMode == null && canCreateProjectInEnvironment(connectionPhase); +} + +export function resolveAvailableNewThreadProjectRef(input: { + requested: ScopedProjectRef; + members: ReadonlyArray<{ + environmentId: EnvironmentId; + projectId: ProjectId; + isPrimary?: boolean; + }>; + isEnvironmentReachable: (environmentId: EnvironmentId) => boolean; +}): ScopedProjectRef { + if (input.isEnvironmentReachable(input.requested.environmentId)) { + return input.requested; + } + const reachable = input.members + .filter((member) => input.isEnvironmentReachable(member.environmentId)) + .toSorted((left, right) => Number(Boolean(right.isPrimary)) - Number(Boolean(left.isPrimary))); + const next = reachable[0]; + return next ? scopeProjectRef(next.environmentId, next.projectId) : input.requested; +} + +export function resolveWorkspaceOptionsAfterEnvironmentRetarget< + TOptions extends { + branch?: string | null; + worktreePath?: string | null; + }, +>(input: { + requestedEnvironmentId: EnvironmentId; + targetEnvironmentId: EnvironmentId; + options: TOptions | undefined; +}): TOptions | undefined { + if (input.options === undefined) return undefined; + if (input.requestedEnvironmentId === input.targetEnvironmentId) return input.options; + return { + ...input.options, + ...(input.options.branch !== undefined ? { branch: null } : {}), + ...(input.options.worktreePath !== undefined ? { worktreePath: null } : {}), + }; +} + // New threads inherit only the *project* from the current context. Branch, // worktree, and env mode always come from the user's configured defaults — // carrying them over from the viewed thread meant "new thread" silently diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 914b0cac844d..a5fc1653bdba 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -43,6 +43,7 @@ export interface SidebarProjectPickerEntry { group: SidebarProjectSnapshot; targetProject: SidebarProjectGroupMember; isPreferred: boolean; + reachable: boolean; } export function buildPhysicalToLogicalProjectKeyMap(input: { @@ -136,9 +137,41 @@ export function buildSidebarProjectSnapshots(input: { export function buildSidebarProjectPickerEntries(input: { groups: ReadonlyArray; preferredProjectRef: ScopedProjectRef | null; + isEnvironmentReachable?: (environmentId: EnvironmentId) => boolean; + // New Chat / draft-hero need every checkout: a grouped local+remote + // project is two choices, not one row that follows the selected chat. + expandMembers?: boolean; }) { const preferredProjectRef = input.preferredProjectRef; + const isEnvironmentReachable = input.isEnvironmentReachable ?? (() => true); const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { + const preferredMember = + (preferredProjectRef + ? (group.memberProjects.find( + (project) => + project.environmentId === preferredProjectRef.environmentId && + project.id === preferredProjectRef.projectId, + ) ?? + group.memberProjects.find( + (project) => project.environmentId === preferredProjectRef.environmentId, + )) + : null) ?? null; + const toEntry = ( + targetProject: SidebarProjectGroupMember, + isPreferred: boolean, + ): SidebarProjectPickerEntry => ({ + group, + targetProject, + isPreferred, + reachable: isEnvironmentReachable(targetProject.environmentId), + }); + + if (input.expandMembers === true) { + return orderPickerMembers(group.memberProjects, preferredMember, isEnvironmentReachable).map( + (member) => toEntry(member, member === preferredMember), + ); + } + const isPreferred = preferredProjectRef ? group.memberProjectRefs.some( (projectRef) => @@ -146,32 +179,50 @@ export function buildSidebarProjectPickerEntries(input: { projectRef.projectId === preferredProjectRef.projectId, ) : false; - const preferredProject = preferredProjectRef - ? (group.memberProjects.find( - (project) => - project.environmentId === preferredProjectRef.environmentId && - project.id === preferredProjectRef.projectId, - ) ?? - group.memberProjects.find( - (project) => project.environmentId === preferredProjectRef.environmentId, - )) - : null; - const targetProject = - preferredProject ?? + const reachablePreferred = + preferredMember !== null && isEnvironmentReachable(preferredMember.environmentId) + ? preferredMember + : null; + const reachableMember = group.memberProjects.find((project) => + isEnvironmentReachable(project.environmentId), + ); + const representative = group.memberProjects.find( (project) => project.environmentId === group.environmentId && project.id === group.id, - ) ?? - group.memberProjects[0]; + ) ?? group.memberProjects[0]; + const targetProject = + reachablePreferred ?? reachableMember ?? preferredMember ?? representative; if (!targetProject) return []; - return [{ group, targetProject, isPreferred }]; + return [toEntry(targetProject, isPreferred)]; }); + const preferredIndex = entries.findIndex((entry) => entry.isPreferred); if (preferredIndex <= 0) return entries; - return [ - entries[preferredIndex]!, - ...entries.slice(0, preferredIndex), - ...entries.slice(preferredIndex + 1), - ]; + const preferredGroupKey = entries[preferredIndex]!.group.projectKey; + const preferredGroupEntries: SidebarProjectPickerEntry[] = []; + const otherEntries: SidebarProjectPickerEntry[] = []; + for (const entry of entries) { + if (entry.group.projectKey === preferredGroupKey) { + preferredGroupEntries.push(entry); + } else { + otherEntries.push(entry); + } + } + return [...preferredGroupEntries, ...otherEntries]; +} + +function orderPickerMembers( + members: ReadonlyArray, + preferredMember: SidebarProjectGroupMember | null, + isEnvironmentReachable: (environmentId: EnvironmentId) => boolean, +) { + return members.toSorted((left, right) => { + const reachability = + Number(isEnvironmentReachable(right.environmentId)) - + Number(isEnvironmentReachable(left.environmentId)); + if (reachability !== 0) return reachability; + return Number(right === preferredMember) - Number(left === preferredMember); + }); }