From faa39a61a04db9b3d59a3a44db9190c4c823a80a Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Sun, 20 Sep 2026 09:39:08 +0000 Subject: [PATCH] fix(web): New Chat picker lists every checkout and skips offline remotes The New Chat project picker followed the selected chat's environment, so a disconnected remote copy was the only choice and clicking it hung on t3.json. List each machine's checkout, disable unreachable rows, skip the file read unless connected, and retarget or toast instead of hanging. Fixes pingdotgg/t3code#12093 --- .../components/CommandPalette.logic.test.ts | 13 ++ .../src/components/CommandPalette.logic.ts | 2 + apps/web/src/components/CommandPalette.tsx | 74 ++++++--- .../src/components/chat/DraftHeroHeadline.tsx | 48 ++++-- apps/web/src/environmentGrouping.test.ts | 66 ++++++++ apps/web/src/hooks/useHandleNewThread.test.ts | 142 ++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 115 +++++++++++--- apps/web/src/lib/chatThreadActions.test.ts | 82 ++++++++++ apps/web/src/lib/chatThreadActions.ts | 53 +++++++ apps/web/src/sidebarProjectGrouping.ts | 91 ++++++++--- 10 files changed, 605 insertions(+), 81 deletions(-) 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); + }); }