From 7ff40692b07d22606b3b7aabdfadb2e0e74683c3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 12:36:52 +0000 Subject: [PATCH 1/7] fix(web): let new threads choose each repository checkout --- .../components/CommandPalette.logic.test.ts | 22 ++++ .../src/components/CommandPalette.logic.ts | 2 + apps/web/src/components/CommandPalette.tsx | 82 ++++++++------ apps/web/src/components/Sidebar.tsx | 14 ++- .../src/components/chat/DraftHeroHeadline.tsx | 85 ++++++++++++--- apps/web/src/environmentGrouping.test.ts | 103 +++++++++++++++++- apps/web/src/hooks/useHandleNewThread.test.ts | 64 ++++++++++- apps/web/src/hooks/useHandleNewThread.ts | 16 +++ apps/web/src/routes/_chat.tsx | 8 +- apps/web/src/sidebarProjectGrouping.ts | 25 +++++ 10 files changed, 363 insertions(+), 58 deletions(-) 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 f7b78b5ae676..a9fead3f86b6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -973,6 +973,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 }) => ({ @@ -1314,25 +1336,23 @@ function OpenCommandPaletteDialog(props: { enumerateCommandPaletteItems([ ...buildProjectActionItems({ // The no-project home shows once, as the "No project" item below. - projects: pickerProjects.filter( + projects: threadPickerProjects.filter( (project) => !isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)), ), 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) => [ + projectEnvironmentLocationById.get(project.environmentId)?.label ?? "Remote", + ], renderDescription: (project) => { const location = projectEnvironmentLocationById.get(project.environmentId) ?? { kind: "remote", label: "Remote", machine: "server" as const, }; + const environment = environments.find( + (candidate) => candidate.environmentId === project.environmentId, + ); return ( @@ -1347,24 +1367,20 @@ function OpenCommandPaletteDialog(props: { {project.workspaceRoot} + {isEnvironmentReachable(project.environmentId) ? null : ( + <> + + + {environment ? connectionStatusText(environment.connection) : "Unavailable"} + + + )} ); }, icon: projectFaviconIcon, 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)); }, }), ...(scratchTargetEnvironmentId === null @@ -1382,11 +1398,11 @@ function OpenCommandPaletteDialog(props: { ]), ]), [ - contextualProjectRef, + environments, handleNewThread, - pickerProjects, + isEnvironmentReachable, + threadPickerProjects, projectEnvironmentLocationById, - projectGroupByTargetKey, scratchTargetEnvironmentId, scratchWorkspaceRootFor, startScratchThread, @@ -1852,12 +1868,14 @@ function OpenCommandPaletteDialog(props: { currentProjectEnvironmentId && currentProjectId ? `new-thread-in:${currentProjectEnvironmentId}:${currentProjectId}` : null; - const prioritized = currentPrefix - ? [ - ...projectThreadItems.filter((item) => item.value === currentPrefix), - ...projectThreadItems.filter((item) => item.value !== currentPrefix), - ] - : projectThreadItems; + const prioritized = + currentPrefix && + projectThreadItems.some((item) => item.value === currentPrefix && !item.disabled) + ? [ + ...projectThreadItems.filter((item) => item.value === currentPrefix), + ...projectThreadItems.filter((item) => item.value !== currentPrefix), + ] + : projectThreadItems; pushPaletteView({ addonIcon: , groups: [ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf862c50f46d..c032447b2428 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4791,12 +4791,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, @@ -4809,7 +4813,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 @@ -4822,7 +4826,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 ( <> @@ -4974,7 +4978,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/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index ac995a47a670..502c8e4cf120 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -2,6 +2,7 @@ import type { DraftId } from "~/composerDraftStore"; import { useComposerDraftStore } from "~/composerDraftStore"; import { resolveEnvironmentMachineKind, type ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; @@ -40,8 +41,11 @@ import { import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { environmentPresentations } from "~/state/presentation"; +import { stackedThreadToast, toastManager } from "../ui/toast"; -// Menu value for "No project"; real entries are keyed by logical project key. +// Menu value for "No project"; real entries identify the chosen physical checkout. const NO_PROJECT_VALUE = "no-project"; interface DraftHeroHeadlineProps { @@ -125,11 +129,29 @@ export function DraftHeroHeadline({ buildSidebarProjectPickerEntries({ groups: projectGroups, preferredProjectRef: activeProjectRef, + expandCheckouts: true, + isEnvironmentReachable: (environmentId) => + environments.some( + (environment) => + environment.environmentId === environmentId && + environment.connection.phase === "connected", + ), }), - [activeProjectRef, projectGroups], + [activeProjectRef, environments, 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 = @@ -140,7 +162,7 @@ export function DraftHeroHeadline({ (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef), ), ) ?? null); - const activeProjectKey = activeProjectGroup?.projectKey ?? ""; + const activeProjectKey = activeProjectRef ? scopedProjectKey(activeProjectRef) : ""; const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; @@ -179,9 +201,22 @@ export function DraftHeroHeadline({ if (!draftId) { return; } + const environment = appAtomRegistry.get( + environmentPresentations.presentationAtom(project.environmentId), + ); + if (environment?.connection.phase !== "connected") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.entry.target.label ?? "The selected environment"} is not connected.`, + }), + ); + return; + } latestTargetRef.current = { draftId, - activeProjectKey: logicalProjectKey, + activeProjectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), scratchTargetEnvironmentId: project.environmentId, }; const currentDraft = getComposerDraft(draftId); @@ -278,20 +313,42 @@ export function DraftHeroHeadline({ )} - {menuEntries.map(({ group }) => { + {menuEntries.map(({ group, targetProject }) => { + const projectKey = scopedProjectKey( + scopeProjectRef(targetProject.environmentId, targetProject.id), + ); + const environment = environments.find( + (candidate) => candidate.environmentId === targetProject.environmentId, + ); + const reachable = environment?.connection.phase === "connected"; + const locationLabel = + targetProject.environmentId === primaryEnvironmentId + ? "Local" + : (targetProject.environmentLabel ?? "Remote"); return ( - + - - }> - {group.displayName} - - {group.displayName} - + + + }> + {group.displayName} + + + {group.displayName} · {locationLabel} · {targetProject.workspaceRoot} + + + + {showProjectEnvironments ? `${locationLabel} · ` : ""} + {targetProject.workspaceRoot} + {reachable + ? "" + : ` · ${environment ? connectionStatusText(environment.connection) : "Unavailable"}`} + + {showProjectEnvironments ? ( diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index efb6d8412ce0..1aeb2d109985 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -64,7 +64,7 @@ describe("environment grouping", () => { expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); - it("counts cross-environment copies as one new-thread project choice", () => { + it("keeps cross-environment copies in one sidebar group", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ id: ProjectId.make("project-remote"), @@ -426,6 +426,107 @@ 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 unavailable-only checkouts visible and deduplicates duplicate physical registrations", () => { + const stale = makeProject({ + id: ProjectId.make("stale"), + repositoryIdentity, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const canonical = makeProject({ + id: ProjectId.make("canonical"), + repositoryIdentity, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [stale, canonical], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: null, + expandCheckouts: true, + isEnvironmentReachable: () => false, + }); + + expect(entries.map(({ targetProject }) => targetProject.id)).toEqual([canonical.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.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 3a29155bda6d..6942bb2bd85b 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -5,7 +5,7 @@ const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; let projectFileRead = Promise.resolve(null); let targetSettings = { - defaultThreadEnvMode: "local" as "local" | "worktree", + defaultThreadEnvMode: "local" as "local" | "worktree" | null, newWorktreesStartFromOrigin: false, defaultModelSelection: null, defaultRuntimeMode: "full-access" as RuntimeMode, @@ -37,6 +37,14 @@ const testState = vi.hoisted(() => { }; return { + connectionPhase: "connected" as + | "connected" + | "connecting" + | "reconnecting" + | "disconnected" + | null, + toast: vi.fn(), + projectFileReads: vi.fn(), completeProjectFileRead: (value: null) => completeProjectFileRead(value), draftStore, get projectFileRead() { @@ -144,7 +152,10 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ resolveNewThreadModelSelectionOverride: () => null, })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ - readT3ProjectFile: () => testState.projectFileRead, + readT3ProjectFile: () => { + testState.projectFileReads(); + return testState.projectFileRead; + }, })); vi.mock("../lib/utils", () => ({ newDraftId: () => "draft-delayed", @@ -180,6 +191,25 @@ vi.mock("../uiStateStore", () => ({ })); vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { + get: () => + testState.connectionPhase === null + ? null + : { + connection: { phase: testState.connectionPhase }, + entry: { target: { label: "Build box" } }, + }, + }, +})); +vi.mock("../state/presentation", () => ({ + environmentPresentations: { presentationAtom: () => "environment-presentation" }, +})); +vi.mock("../components/ui/toast", () => ({ + stackedThreadToast: (input: T) => input, + toastManager: { add: testState.toast }, +})); + import { useNewThreadHandler } from "./useHandleNewThread"; describe.each([ @@ -194,6 +224,36 @@ describe.each([ }, ], ])("useNewThreadHandler with a %s draft", (_, draft) => { + it.each(["connecting", "reconnecting", "disconnected", null] as const)( + "reports an unavailable %s environment without reading defaults or changing the draft", + async (phase) => { + testState.reset(draft); + testState.toast.mockClear(); + testState.projectFileReads.mockClear(); + testState.targetSettings.defaultThreadEnvMode = null; + // Read connection state at invocation, including a disconnect after + // the picker rendered and captured its handler. + const openThread = useNewThreadHandler(); + testState.connectionPhase = phase; + try { + const pendingOpen = openThread({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + expect(testState.projectFileReads).not.toHaveBeenCalled(); + expect(await pendingOpen).toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", title: "Environment unavailable" }), + ); + } finally { + testState.connectionPhase = "connected"; + } + }, + ); + it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)( "uses the target environment's %s permissions for new threads", async (runtimeMode) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 17597e213d65..1600f1930100 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -34,6 +34,9 @@ import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentPresentations } from "../state/presentation"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; interface NewThreadWorkspaceOptions { branch?: string | null; @@ -77,6 +80,19 @@ export function useNewThreadHandler() { // prepared checkout, a task to write — addresses that one rather than looking the project // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { + const environment = appAtomRegistry.get( + environmentPresentations.presentationAtom(projectRef.environmentId), + ); + if (environment?.connection.phase !== "connected") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Environment unavailable", + description: `${environment?.entry.target.label ?? "The selected environment"} is not connected. Choose a connected checkout from New thread in...`, + }), + ); + return Promise.resolve(null); + } const projects = readProjects(); const targetServerSettings = environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 0a0b1adee06b..461bc00bfe87 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -39,14 +39,14 @@ function ChatRouteGlobalShortcuts() { const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { scratchEnvironmentId, startScratchThread } = useScratchProject(); - const projectGroupCount = useMemo( + const projectChoiceCount = useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: () => null, - }).length, + }).reduce((count, group) => count + group.memberProjects.length, 0), [primaryEnvironmentId, projectGroupingSettings, projects], ); const terminalOpen = useTerminalUiStateStore((state) => @@ -124,7 +124,7 @@ function ChatRouteGlobalShortcuts() { // The default sidebar routes creation through the command palette // whenever there is a real choice to make; the legacy sidebar (and // single-project setups) keep the immediate contextual create. - if (!legacySidebarEnabled && projectGroupCount > 1) { + if (!legacySidebarEnabled && projectChoiceCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -194,7 +194,7 @@ function ChatRouteGlobalShortcuts() { defaultProjectRef, previewOpen, primaryEnvironmentId, - projectGroupCount, + projectChoiceCount, routeThreadRef, scratchEnvironmentId, selectedThreadKeysSize, 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( From 56de8bb90e42109783f15c044368d1c5f3ddabf8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 15:16:46 +0000 Subject: [PATCH 2/7] fix(web): preserve explicit checkout choices and retry --- .../components/CommandPalette.logic.test.ts | 61 ++++++++ .../src/components/CommandPalette.logic.ts | 25 ++- apps/web/src/components/CommandPalette.tsx | 5 +- .../src/components/chat/DraftHeroHeadline.tsx | 1 + apps/web/src/composerDraftStore.test.ts | 59 ++++--- apps/web/src/hooks/useHandleNewThread.test.ts | 144 ++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 9 ++ apps/web/src/routes/_chat.index.tsx | 15 +- 8 files changed, 279 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 12fe54cc5081..79e8d69516c1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -845,6 +845,67 @@ it.each([ }); describe("filterCommandPaletteGroups", () => { + it.each(["", "shared"])( + "refreshes an open checkout picker while keeping its query %s", + (query) => { + const remote = makeProject({ environmentId: EnvironmentId.make("remote") }); + const local = makeProject({ environmentId: EnvironmentId.make("local") }); + const runProject = vi.fn(async () => undefined); + const offline = buildProjectActionItems({ + projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), + valuePrefix: "new-thread-in", + icon: () => null, + runProject, + disabled: (project) => project.environmentId === remote.environmentId, + renderDescription: (project) => + project.environmentId === remote.environmentId ? "Reconnecting" : "Connected", + }); + const activeGroups = [ + { + value: "projects", + label: "Projects", + items: enumerateCommandPaletteItems([offline[1]!, offline[0]!]), + }, + ]; + const connected = buildProjectActionItems({ + projects: [remote, local].map((project) => ({ ...project, displayName: "shared-repo" })), + valuePrefix: "new-thread-in", + icon: () => null, + runProject, + disabled: () => false, + renderDescription: () => "Connected", + }); + const groups = filterCommandPaletteGroups({ + activeGroups, + projectThreadItems: connected, + query, + isInSubmenu: true, + projectSearchItems: [], + threadSearchItems: [], + }); + expect(buildCommandPaletteRows(groups).itemValues).toEqual([ + connected[1]!.value, + connected[0]!.value, + ]); + expect(groups[0]?.items.map((item) => item.shortcutCommand)).toEqual( + activeGroups[0]?.items.map((item) => item.shortcutCommand), + ); + expect(findHighlightedCommandPaletteItem(groups, offline[0]!.value)?.description).toBe( + "Connected", + ); + const disconnected = filterCommandPaletteGroups({ + activeGroups: groups, + projectThreadItems: offline, + query, + isInSubmenu: true, + projectSearchItems: [], + threadSearchItems: [], + }); + expect(buildCommandPaletteRows(disconnected).itemValues).toEqual([offline[1]!.value]); + expect(disconnected[0]?.items[1]?.description).toBe("Reconnecting"); + }, + ); + it("sorts secondary settings results after other matches", () => { const item = (value: string, title: string, secondary?: boolean) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eb3f6da44752..bfb64b057d78 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -442,6 +442,7 @@ function rankCommandPaletteItemMatch( export function filterCommandPaletteGroups(input: { activeGroups: ReadonlyArray; + projectThreadItems?: ReadonlyArray; query: string; isInSubmenu: boolean; projectSearchItems: ReadonlyArray; @@ -451,16 +452,34 @@ export function filterCommandPaletteGroups(input: { const isActionsFilter = input.query.startsWith(">"); const searchQuery = isActionsFilter ? input.query.slice(1) : input.query; const normalizedQuery = normalizeSearchText(searchQuery); + const projectThreadItems = input.projectThreadItems; + const activeGroups = + input.isInSubmenu && projectThreadItems + ? input.activeGroups.map((group) => { + if (group.value !== "projects") return group; + const itemOrder = new Map(group.items.map((item, index) => [item.value, index])); + return { + ...group, + items: enumerateCommandPaletteItems( + projectThreadItems.toSorted( + (left, right) => + (itemOrder.get(left.value) ?? itemOrder.size) - + (itemOrder.get(right.value) ?? itemOrder.size), + ), + ), + }; + }) + : input.activeGroups; if (normalizedQuery.length === 0) { if (isActionsFilter) { - return input.activeGroups.filter((group) => group.value === "actions"); + return activeGroups.filter((group) => group.value === "actions"); } - return [...input.activeGroups]; + return [...activeGroups]; } const queryTokens = normalizedQuery.split(" "); - let baseGroups = [...input.activeGroups]; + let baseGroups = [...activeGroups]; if (isActionsFilter) { baseGroups = baseGroups.filter((group) => group.value === "actions"); } else if (!input.isInSubmenu) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a9fead3f86b6..7e98391e7f31 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1380,7 +1380,9 @@ function OpenCommandPaletteDialog(props: { }, icon: projectFaviconIcon, runProject: async (project) => { - await handleNewThread(scopeProjectRef(project.environmentId, project.id)); + await handleNewThread(scopeProjectRef(project.environmentId, project.id), { + environmentSelection: "manual", + }); }, }), ...(scratchTargetEnvironmentId === null @@ -2349,6 +2351,7 @@ function OpenCommandPaletteDialog(props: { const filteredGroups = filterCommandPaletteGroups({ activeGroups, + projectThreadItems, query: deferredQuery, isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 502c8e4cf120..3a40accd778f 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -224,6 +224,7 @@ export function DraftHeroHeadline({ logicalProjectKey, scopeProjectRef(project.environmentId, project.id), draftId, + { environmentSelection: "manual", loadBalancedEnvironmentId: null }, ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index df7969a86188..11d137b9a8f5 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1774,29 +1774,46 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); - it("clears branch and worktree but keeps env mode when remapping a draft to another environment", () => { - const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectRef, draftId, { - threadId, - branch: "feature/local-only", - worktreePath: "/tmp/local-worktree", - envMode: "worktree", - startFromOrigin: true, - }); + it.each([false, true])( + "keeps prompt and env mode when remapping a draft (manual: %s)", + (manual) => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: "feature/local-only", + worktreePath: "/tmp/local-worktree", + envMode: "worktree", + startFromOrigin: true, + environmentSelection: "auto", + loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID, + }); + store.setPrompt(draftId, "keep this prompt"); - store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), remoteProjectRef, draftId, { - threadId, - }); + store.setLogicalProjectDraftThreadId( + scopedProjectKey(projectRef), + remoteProjectRef, + draftId, + { + threadId, + ...(manual + ? { environmentSelection: "manual" as const, loadBalancedEnvironmentId: null } + : {}), + }, + ); - expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ - environmentId: OTHER_TEST_ENVIRONMENT_ID, - projectId, - branch: null, - worktreePath: null, - envMode: "worktree", - startFromOrigin: true, - }); - }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + projectId, + branch: null, + worktreePath: null, + envMode: "worktree", + startFromOrigin: true, + environmentSelection: manual ? "manual" : "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }, + ); it("clears stale upload metadata when retargeting a draft to another environment", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6942bb2bd85b..f3b978e6723b 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import type { RuntimeMode } from "@t3tools/contracts"; +import { act, createElement } from "react"; +import { create } from "react-test-renderer"; const testState = vi.hoisted(() => { let completeProjectFileRead: (value: null) => void = () => undefined; @@ -96,12 +98,14 @@ vi.mock("@effect/atom-react", () => ({ ["environment-ssh", { settings: testState.targetSettings }], ]), })); -vi.mock("@t3tools/client-runtime/environment", () => ({ +vi.mock("@t3tools/client-runtime/environment", async (importOriginal) => ({ + ...(await importOriginal()), scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ +vi.mock("@t3tools/contracts", async (importOriginal) => ({ + ...(await importOriginal()), DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, })); @@ -127,15 +131,25 @@ vi.mock("@t3tools/shared/projectSettings", () => ({ overrides: {}, }), })); -vi.mock("@tanstack/react-router", () => ({ +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), useParams: () => null, useRouter: () => testState.router, + createFileRoute: () => (options: unknown) => ({ + options, + useRouteContext: () => ({ authGateState: { status: "server" } }), + }), + Link: "a", })); -vi.mock("react", () => ({ +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), useCallback: (callback: T) => callback, useMemo: (factory: () => T) => factory(), })); -vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../components/Sidebar.logic", () => ({ + orderItemsByPreferredIds: () => [], + sortScopedProjectsForSidebar: (projects: T) => projects, +})); vi.mock("../composerDraftStore", () => { const useComposerDraftStore = Object.assign(() => null, { getState: () => testState.draftStore, @@ -166,8 +180,8 @@ vi.mock("../logicalProject", () => ({ getProjectOrderKey: () => "remote-project", selectProjectGroupingSettings: () => ({}), })); -vi.mock("../state/entities", () => ({ - readProjects: () => [ +vi.mock("../state/entities", () => { + const projects = [ { id: "project-remote", environmentId: "environment-ssh", @@ -175,11 +189,32 @@ vi.mock("../state/entities", () => ({ defaultThreadEnvMode: null, defaultModelSelection: null, }, - ], - readThreadShell: () => null, - useProjects: () => [], - useThread: () => null, + ]; + return { + readProjects: () => projects, + readThreadShell: () => null, + useProjects: () => projects, + useThreadShells: () => [], + useAllEnvironmentShellsBootstrapped: () => true, + useThread: () => null, + }; +}); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: [], isReady: true }), })); +vi.mock("../components/NoProjectsHero", () => ({ NoProjectsHero: () => null })); +vi.mock("../components/WorkspacePageHeader", () => ({ WorkspacePageHeader: () => null })); +vi.mock("../components/ui/button", () => ({ Button: "button" })); +vi.mock("../components/ui/sidebar", () => ({ SidebarInset: "main" })); +vi.mock("../components/ui/empty", () => ({ + Empty: "section", + EmptyDescription: "p", + EmptyHeader: "header", + EmptyTitle: "h1", +})); +vi.mock("../components/ui/refresh-icon", () => ({ RefreshIcon: "svg" })); +vi.mock("../localEnvironment", () => ({ isLocalEnvironmentDisabled: () => false })); +vi.mock("../env", () => ({ isElectron: false })); vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", @@ -211,6 +246,8 @@ vi.mock("../components/ui/toast", () => ({ })); import { useNewThreadHandler } from "./useHandleNewThread"; +import * as newThread from "./useHandleNewThread"; +import { Route } from "../routes/_chat.index"; describe.each([ ["new", null], @@ -224,6 +261,57 @@ describe.each([ }, ], ])("useNewThreadHandler with a %s draft", (_, draft) => { + it.each([false, true])( + "keeps physical checkout selection intent (manual: %s)", + async (manual) => { + testState.reset(draft); + const actual = + await vi.importActual("../composerDraftStore"); + actual.useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + const projectRef = { + environmentId: "environment-ssh", + projectId: "project-worktree", + } as never; + const store = actual.useComposerDraftStore.getState(); + if (draft) { + store.setLogicalProjectDraftThreadId( + "remote-project", + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + actual.DraftId.make(draft.draftId), + { + threadId: draft.threadId as never, + environmentSelection: "auto", + loadBalancedEnvironmentId: "previous-environment" as never, + }, + ); + } + testState.draftStore.setLogicalProjectDraftThreadId.mockImplementation( + store.setLogicalProjectDraftThreadId as never, + ); + try { + const opened = await useNewThreadHandler()( + projectRef, + manual ? { environmentSelection: "manual" } : undefined, + ); + expect(store.getDraftThread(opened!.draftId)).toMatchObject({ + environmentId: "environment-ssh", + projectId: "project-worktree", + }); + expect(store.getDraftThread(opened!.draftId)?.environmentSelection).toBe( + manual ? "manual" : draft ? "auto" : undefined, + ); + if (manual) + expect(store.getDraftThread(opened!.draftId)?.loadBalancedEnvironmentId).toBeNull(); + } finally { + testState.draftStore.setLogicalProjectDraftThreadId.mockReset(); + } + }, + ); + it.each(["connecting", "reconnecting", "disconnected", null] as const)( "reports an unavailable %s environment without reading defaults or changing the draft", async (phase) => { @@ -347,3 +435,37 @@ describe.each([ }, ); }); + +it("shows the index retry action after an unavailable result and opens the same checkout after reconnect", async () => { + testState.reset(null); + testState.toast.mockClear(); + testState.connectionPhase = "disconnected"; + const openThread = useNewThreadHandler(); + const handler = vi.spyOn(newThread, "useNewThreadHandler").mockReturnValue(openThread); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await Route.options.component!.preload?.(); + const renderer = await act(async () => create(createElement(Route.options.component!))); + try { + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Environment unavailable" }), + ); + expect(renderer.toJSON()).not.toBeNull(); + const retry = renderer.root.findByType("button"); + expect(retry.children).toContain("Try again"); + expect(testState.router.navigate).not.toHaveBeenCalled(); + testState.connectionPhase = "connected"; + await act(async () => retry.props.onClick()); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + { environmentId: "environment-ssh", projectId: "project-remote" }, + "draft-delayed", + expect.anything(), + ); + } finally { + await act(async () => renderer.unmount()); + handler.mockRestore(); + testState.connectionPhase = "connected"; + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 1600f1930100..4c38786a9177 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -75,6 +75,7 @@ export function useNewThreadHandler() { envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; replace?: boolean; + 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 @@ -184,6 +185,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) @@ -315,6 +320,7 @@ export function useNewThreadHandler() { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, + ...selectionOptions, ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, @@ -365,6 +371,7 @@ export function useNewThreadHandler() { runtimeMode: latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), + ...selectionOptions, }); return Promise.resolve({ draftId: currentRouteTarget.draftId, @@ -408,6 +415,7 @@ export function useNewThreadHandler() { runtimeMode: racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), + ...selectionOptions, }); await router.navigate({ to: "/draft/$draftId", @@ -429,6 +437,7 @@ export function useNewThreadHandler() { newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, }), runtimeMode: defaultRuntimeMode, + ...selectionOptions, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 61e9e7a2e3a2..fa8795ceaa4a 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -62,10 +62,17 @@ function IndexDraftLanding() { startingRef.current = true; void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), { replace: true, - }).catch(() => { - startingRef.current = false; - setStartState((state) => ({ ...state, failed: true })); - }); + }) + .then((opened) => { + if (opened === null) { + startingRef.current = false; + setStartState((state) => ({ ...state, failed: true })); + } + }) + .catch(() => { + startingRef.current = false; + setStartState((state) => ({ ...state, failed: true })); + }); }, [handleNewThread, mostRecentProject, startState.retryRequest]); if (!bootstrapped) { From 1fea01a2157833e428476d5aa398caf549200158 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 15:46:26 +0000 Subject: [PATCH 3/7] fix(web): keep setup open for unavailable checkouts --- .../onboarding/WelcomeWizard.test.tsx | 23 ++++++++ .../components/onboarding/WelcomeWizard.tsx | 4 +- apps/web/src/hooks/useHandleNewThread.test.ts | 53 ++++++++++++++++++- apps/web/src/routes/welcome.tsx | 6 ++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx index b36a8e815238..f53a1ac8bc83 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.test.tsx @@ -212,3 +212,26 @@ it("keeps setup open when saving completion fails and preserves the import warni }), ); }); + +it("retries the same imported project when opening it is unavailable", async () => { + const onDone = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(undefined); + await act(async () => root.render()); + await click("Continue"); + await click("Continue"); + await click("Import 1 project"); + expect(onDone).toHaveBeenCalledWith({ + environmentId: EnvironmentId.make("test-env"), + projectId: ProjectId.make("test-project"), + }); + expect(mocks.toast).not.toHaveBeenCalled(); + await click("Do not import projects"); + expect(onDone).toHaveBeenCalledTimes(2); + expect(onDone.mock.calls[1]).toEqual(onDone.mock.calls[0]); + expect(mocks.importThreads).toHaveBeenCalledOnce(); + expect(mocks.toast).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: "warning", + description: "Imported 28 threads. 1 thread could not be imported.", + }), + ); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index a50a99193585..37190f71ecc4 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -113,7 +113,7 @@ export function WelcomeWizard({ /** Whether this client is authenticated to the server serving the app. */ readonly localAvailable: boolean; readonly resumeEnvironmentId?: EnvironmentId | undefined; - readonly onDone: (projectRef?: ScopedProjectRef) => void | Promise; + readonly onDone: (projectRef?: ScopedProjectRef) => boolean | void | Promise; }) { const completeOnboarding = useCompleteOnboarding(); const [step, setStep] = useState(resumeEnvironmentId ? "agents" : "connection"); @@ -170,7 +170,7 @@ export function WelcomeWizard({ toastManager.close(completionErrorToastIdRef.current); completionErrorToastIdRef.current = null; } - await onDone(projectRef); + if ((await onDone(projectRef)) === false) return false; if (importWarning) { toastManager.add({ type: "warning", diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index f3b978e6723b..808ee963cf57 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { RuntimeMode } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type RuntimeMode } from "@t3tools/contracts"; import { act, createElement } from "react"; import { create } from "react-test-renderer"; @@ -135,6 +135,12 @@ vi.mock("@tanstack/react-router", async (importOriginal) => ({ ...(await importOriginal()), useParams: () => null, useRouter: () => testState.router, + useNavigate: () => testState.router.navigate, + useLocation: ({ + select, + }: { + select: (location: { hash: string; pathname: string }) => unknown; + }) => select({ hash: "", pathname: testState.router.state.location.href }), createFileRoute: () => (options: unknown) => ({ options, useRouteContext: () => ({ authGateState: { status: "server" } }), @@ -203,6 +209,7 @@ vi.mock("../state/environments", () => ({ useEnvironments: () => ({ environments: [], isReady: true }), })); vi.mock("../components/NoProjectsHero", () => ({ NoProjectsHero: () => null })); +vi.mock("../components/onboarding/WelcomeWizard", () => ({ WelcomeWizard: "dialog" })); vi.mock("../components/WorkspacePageHeader", () => ({ WorkspacePageHeader: () => null })); vi.mock("../components/ui/button", () => ({ Button: "button" })); vi.mock("../components/ui/sidebar", () => ({ SidebarInset: "main" })); @@ -248,6 +255,7 @@ vi.mock("../components/ui/toast", () => ({ import { useNewThreadHandler } from "./useHandleNewThread"; import * as newThread from "./useHandleNewThread"; import { Route } from "../routes/_chat.index"; +import { Route as WelcomeRoute } from "../routes/welcome"; describe.each([ ["new", null], @@ -469,3 +477,46 @@ it("shows the index retry action after an unavailable result and opens the same vi.unstubAllGlobals(); } }); + +it("keeps welcome open when an imported checkout is unavailable and retries that checkout after reconnect", async () => { + testState.reset(null); + testState.toast.mockClear(); + testState.projectFileReads.mockClear(); + testState.connectionPhase = "disconnected"; + testState.router.state.location.href = "/welcome"; + const projectRef = { + environmentId: EnvironmentId.make("environment-ssh"), + projectId: ProjectId.make("project-remote"), + }; + const openThread = useNewThreadHandler(); + const handler = vi.spyOn(newThread, "useNewThreadHandler").mockReturnValue(openThread); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await WelcomeRoute.options.component!.preload?.(); + const renderer = await act(async () => create(createElement(WelcomeRoute.options.component!))); + try { + const wizard = renderer.root.findByType("dialog"); + await act(async () => wizard.props.onDone(projectRef)); + expect(renderer.root.findByType("dialog")).toBe(wizard); + expect(testState.router.state.location.href).toBe("/welcome"); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.projectFileReads).not.toHaveBeenCalled(); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Environment unavailable" }), + ); + testState.connectionPhase = "connected"; + await act(async () => wizard.props.onDone(projectRef)); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + expect(renderer.root.findAllByType("dialog")).toHaveLength(0); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + projectRef, + "draft-delayed", + expect.anything(), + ); + } finally { + await act(async () => renderer.unmount()); + handler.mockRestore(); + testState.connectionPhase = "connected"; + vi.unstubAllGlobals(); + } +}); diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx index b69911c0abcb..7905acc318fe 100644 --- a/apps/web/src/routes/welcome.tsx +++ b/apps/web/src/routes/welcome.tsx @@ -46,13 +46,15 @@ function WelcomeRouteView() { localAvailable={localAvailable} resumeEnvironmentId={resumeEnvironmentId} onDone={async (projectRef) => { - setDismissed(true); if (projectRef !== undefined) { - await openNewThread(projectRef, { replace: true }).catch(() => + const opened = await openNewThread(projectRef, { replace: true }).catch(() => navigate({ to: "/", replace: true }), ); + if (opened === null) return false; + setDismissed(true); return; } + setDismissed(true); await navigate({ to: "/", replace: true }); }} /> From 609f3277fc1028a7c1ca53a7001fad9281aad524 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 16:37:07 +0000 Subject: [PATCH 4/7] fix(web): recover drafts for archived checkouts --- apps/web/src/components/ThreadRouteView.tsx | 17 +- apps/web/src/hooks/useHandleNewThread.test.ts | 574 ++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 35 +- apps/web/src/hooks/useThreadActions.ts | 81 ++- .../src/hooks/useThreadActions.undo.test.ts | 20 +- apps/web/src/routes/_chat.index.tsx | 61 +- 6 files changed, 717 insertions(+), 71 deletions(-) diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 934feac61a83..324e9790b8d6 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,6 +1,6 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import ChatView from "./ChatView"; @@ -38,6 +38,7 @@ import { */ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { const navigate = useNavigate(); + const archiveDraftRetry = useLocation({ select: (location) => location.state.archiveDraftRetry }); const draftId = target.kind === "draft" ? target.draftId : null; const draftSession = useComposerDraftStore((store) => draftId === null ? null : store.getDraftSession(draftId), @@ -151,11 +152,21 @@ export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) { if (renderState === "missing") { const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); clearPendingFileDropsForThread(target.threadRef); - if (environmentHasAnyThreads) { + if (archiveDraftRetry && scopedThreadKey(archiveDraftRetry.threadRef) === serverThreadKey) { + void navigate({ to: "/", replace: true, state: { archiveDraftRetry } }); + } else if (environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + }, [ + archiveDraftRetry, + bootstrapComplete, + environmentHasAnyThreads, + navigate, + renderState, + serverThreadKey, + target, + ]); useEffect(() => { if (target.kind !== "server" || !serverThreadStarted || !draftThread) { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 808ee963cf57..63d7d2e43a00 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { EnvironmentId, ProjectId, type RuntimeMode } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ModelSelection, + type RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import type { HistoryState } from "@tanstack/react-router"; import { act, createElement } from "react"; import { create } from "react-test-renderer"; @@ -9,7 +17,7 @@ const testState = vi.hoisted(() => { let targetSettings = { defaultThreadEnvMode: "local" as "local" | "worktree" | null, newWorktreesStartFromOrigin: false, - defaultModelSelection: null, + defaultModelSelection: null as ModelSelection | null, defaultRuntimeMode: "full-access" as RuntimeMode, }; let storedDraft: { @@ -18,17 +26,60 @@ const testState = vi.hoisted(() => { readonly promotedTo: null; readonly threadId: string; } | null = null; + let historyKey = 0; + const location = { + href: "/", + state: { __TSR_key: "initial" } as HistoryState & { __TSR_key: string }, + }; + const previousLocations: (typeof location)[] = []; const router = { state: { - location: { href: "/" }, - matches: [{ params: {} }], + location, + matches: [{ params: {} as Record }], }, - navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => { - router.state.location.href = `/draft/${request.params.draftId}`; - }), + history: { + location, + replace: vi.fn((href: string, state: HistoryState) => { + location.href = href; + location.state = { ...state, __TSR_key: `history-${++historyKey}` }; + }), + back: () => { + const previous = previousLocations.pop(); + if (previous) Object.assign(location, previous); + }, + }, + navigate: vi.fn( + async (request: { + readonly to: string; + readonly params?: { + readonly draftId?: string; + readonly threadId?: string; + readonly environmentId?: string; + }; + readonly state?: HistoryState; + readonly replace?: boolean; + }) => { + if (!request.replace) previousLocations.push({ ...location, state: { ...location.state } }); + location.href = + request.to === "/draft/$draftId" + ? `/draft/${request.params?.draftId}` + : request.to === "/$environmentId/$threadId" + ? `/environment-ssh/${request.params?.threadId}` + : request.to; + location.state = { ...request.state, __TSR_key: `history-${++historyKey}` }; + router.state.matches[0]!.params = request.params ? { ...request.params } : {}; + }, + ), + }; + const defaultProject = { + id: "project-remote", + environmentId: "environment-ssh", + workspaceRoot: "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/remote/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, }; const draftStore = { - getComposerDraft: vi.fn(() => ({})), + getComposerDraft: vi.fn((_key?: unknown) => ({})), getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), getDraftSession: vi.fn(() => null), getDraftThread: vi.fn(() => null), @@ -47,6 +98,20 @@ const testState = vi.hoisted(() => { | null, toast: vi.fn(), projectFileReads: vi.fn(), + projects: [defaultProject], + bootstrapped: true, + nearbyThreads: true, + resolveRouteTargets: false, + archiveShell: null as { environmentId: string; projectId: string } | null, + archive: + vi.fn< + () => Promise<{ _tag: "Success"; value: undefined } | { _tag: "Failure"; cause: unknown }> + >(), + unarchive: + vi.fn< + () => Promise<{ _tag: "Success"; value: undefined } | { _tag: "Failure"; cause: unknown }> + >(), + archiveNotice: vi.fn<(notice: { undo: () => Promise }) => void>(), completeProjectFileRead: (value: null) => completeProjectFileRead(value), draftStore, get projectFileRead() { @@ -70,14 +135,31 @@ const testState = vi.hoisted(() => { defaultRuntimeMode: "full-access", }; router.state.location.href = "/"; + router.state.location.state = { __TSR_key: `history-${++historyKey}` }; + router.state.matches[0]!.params = {}; router.navigate.mockClear(); + router.history.replace.mockClear(); + previousLocations.length = 0; + this.projects = [defaultProject]; + this.bootstrapped = true; + this.nearbyThreads = true; + this.resolveRouteTargets = false; + this.archiveShell = null; + this.archive.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + this.unarchive.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + this.archiveNotice.mockClear(); + this.projectFileReads.mockReset(); + this.toast.mockClear(); draftStore.setDraftThreadContext.mockClear(); draftStore.setLogicalProjectDraftThreadId.mockClear(); + draftStore.getComposerDraft.mockReset().mockReturnValue({}); + draftStore.setModelSelection.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); }, router, + previousLocations, }; }); @@ -139,8 +221,13 @@ vi.mock("@tanstack/react-router", async (importOriginal) => ({ useLocation: ({ select, }: { - select: (location: { hash: string; pathname: string }) => unknown; - }) => select({ hash: "", pathname: testState.router.state.location.href }), + select: (location: { hash: string; pathname: string; state: HistoryState }) => unknown; + }) => + select({ + hash: "", + pathname: testState.router.state.location.href, + state: testState.router.state.location.state, + }), createFileRoute: () => (options: unknown) => ({ options, useRouteContext: () => ({ authGateState: { status: "server" } }), @@ -150,7 +237,6 @@ vi.mock("@tanstack/react-router", async (importOriginal) => ({ vi.mock("react", async (importOriginal) => ({ ...(await importOriginal()), useCallback: (callback: T) => callback, - useMemo: (factory: () => T) => factory(), })); vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [], @@ -163,13 +249,14 @@ vi.mock("../composerDraftStore", () => { return { composerDraftHasUserContent: () => false, markPromotedDraftThreadByRef: vi.fn(), + finalizePromotedDraftThreadByRef: vi.fn(), + useBackgroundDraftSubmissionPending: () => false, useComposerDraftStore, }; }); vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ ...(await importOriginal()), hasExplicitComposerModelSelection: () => false, - resolveNewThreadModelSelectionOverride: () => null, })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => { @@ -186,25 +273,29 @@ vi.mock("../logicalProject", () => ({ getProjectOrderKey: () => "remote-project", selectProjectGroupingSettings: () => ({}), })); -vi.mock("../state/entities", () => { - const projects = [ - { - id: "project-remote", - environmentId: "environment-ssh", - workspaceRoot: "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/remote/project", - defaultThreadEnvMode: null, - defaultModelSelection: null, - }, - ]; - return { - readProjects: () => projects, - readThreadShell: () => null, - useProjects: () => projects, - useThreadShells: () => [], - useAllEnvironmentShellsBootstrapped: () => true, - useThread: () => null, - }; -}); +vi.mock("../state/entities", () => ({ + readProjects: () => testState.projects, + readThreadShell: (ref: { threadId: string }) => + ref.threadId === "archive-last" ? testState.archiveShell : null, + useProjects: () => testState.projects, + useThreadShells: () => [], + useAllEnvironmentShellsBootstrapped: () => testState.bootstrapped, + useThread: () => null, + useThreadShell: () => testState.archiveShell, + useThreadRefs: () => [], + useEnvironmentThreadRefs: () => + testState.nearbyThreads + ? [{ environmentId: "environment-ssh", threadId: "nearby-thread" }] + : [], +})); +vi.mock("../state/query", () => ({ + useEnvironmentQuery: () => ({ data: { snapshot: { _tag: "Some" } } }), +})); +vi.mock("../components/ChatView", () => ({ default: "article" })); +vi.mock("../components/ChatView.logic", () => ({ + threadHasStarted: () => false, + resolveDraftPromotionNavigationTarget: () => null, +})); vi.mock("../state/environments", () => ({ useEnvironments: () => ({ environments: [], isReady: true }), })); @@ -226,26 +317,46 @@ vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", })); -vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../threadRoutes", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + resolveThreadRouteTarget: (params: Parameters[0]) => + testState.resolveRouteTargets ? original.resolveThreadRouteTarget(params) : null, + }; +}); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", useUiStateStore: () => [], })); vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); +vi.mock("../terminalUiStateStore", () => ({ useTerminalUiStateStore: () => vi.fn() })); +vi.mock("../lib/archivedThreadsState", () => ({ refreshArchivedThreadsForEnvironment: vi.fn() })); +vi.mock("./showThreadUndoNotice", () => ({ showThreadUndoNotice: testState.archiveNotice })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === threadEnvironment.archive + ? testState.archive + : command === threadEnvironment.unarchive + ? testState.unarchive + : vi.fn(), +})); vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { - get: () => + get: (atom: unknown) => testState.connectionPhase === null ? null : { - connection: { phase: testState.connectionPhase }, + connection: { + phase: atom === "environment-primary" ? "connected" : testState.connectionPhase, + }, entry: { target: { label: "Build box" } }, }, }, })); vi.mock("../state/presentation", () => ({ - environmentPresentations: { presentationAtom: () => "environment-presentation" }, + environmentPresentations: { presentationAtom: (environmentId: string) => environmentId }, })); vi.mock("../components/ui/toast", () => ({ stackedThreadToast: (input: T) => input, @@ -253,9 +364,12 @@ vi.mock("../components/ui/toast", () => ({ })); import { useNewThreadHandler } from "./useHandleNewThread"; +import { useThreadActions } from "./useThreadActions"; +import { threadEnvironment } from "../state/threads"; import * as newThread from "./useHandleNewThread"; import { Route } from "../routes/_chat.index"; import { Route as WelcomeRoute } from "../routes/welcome"; +import { ThreadRouteView } from "../components/ThreadRouteView"; describe.each([ ["new", null], @@ -520,3 +634,391 @@ it("keeps welcome open when an imported checkout is unavailable and retries that vi.unstubAllGlobals(); } }); + +describe("archive draft recovery", () => { + const target = { + environmentId: EnvironmentId.make("environment-ssh"), + threadId: ThreadId.make("archive-last"), + }; + const projectRef = { + environmentId: target.environmentId, + projectId: ProjectId.make("project-remote"), + }; + let actions: ReturnType; + let renderer: ReturnType; + + function Probe() { + actions = useThreadActions(); + return createElement(ThreadRouteView, { target: { kind: "server", threadRef: target } }); + } + + beforeEach(async () => { + testState.reset(null); + testState.connectionPhase = "connected"; + testState.archiveShell = projectRef; + testState.router.state.location.href = "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/environment-ssh/archive-last"; + testState.router.state.matches[0]!.params = target; + testState.projects.unshift({ + ...testState.projects[0]!, + environmentId: "environment-primary", + id: "project-other", + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await Route.options.component!.preload?.(); + renderer = await act(async () => create(createElement(Probe))); + }); + + afterEach(async () => { + await act(async () => renderer.unmount()); + vi.unstubAllGlobals(); + }); + + it("retains the archived checkout after disconnect and retries that same checkout", async () => { + let resolveArchive!: (value: { _tag: "Success"; value: undefined }) => void; + const archiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + resolveArchive = resolve; + }); + testState.archive.mockReturnValueOnce(archiveDone); + const pendingArchive = actions.archiveThread(target); + await act(async () => { + testState.connectionPhase = "reconnecting"; + testState.archiveShell = null; + renderer.update(createElement(Probe)); + }); + expect(testState.router.state.location.href).toBe("/"); + expect(testState.router.state.location.state.archiveDraftRetry?.projectRef).toEqual(projectRef); + resolveArchive({ _tag: "Success", value: undefined }); + await pendingArchive; + + expect(testState.router.state.location.href).toBe("/"); + expect(testState.router.state.location.state.archiveDraftRetry?.projectRef).toEqual(projectRef); + expect(testState.projectFileReads).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + await act(async () => renderer.update(createElement(Route.options.component!))); + const retry = renderer.root.findByType("button"); + expect(retry.children).toContain("Try again"); + expect(renderer.toJSON()).not.toBeNull(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + + await act(async () => retry.props.onClick()); + expect(testState.projectFileReads).not.toHaveBeenCalled(); + expect(testState.router.state.location.href).toBe("/"); + testState.connectionPhase = "connected"; + await act(async () => renderer.root.findByType("button").props.onClick()); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + expect(testState.archive).toHaveBeenCalledOnce(); + }); + + it("preserves user navigation during the archive rpc", async () => { + let resolveArchive!: (value: { _tag: "Success"; value: undefined }) => void; + const archiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + resolveArchive = resolve; + }); + testState.archive.mockReturnValueOnce(archiveDone); + const pendingArchive = actions.archiveThread(target); + await testState.router.navigate({ to: "/usage" }); + testState.connectionPhase = "reconnecting"; + resolveArchive({ _tag: "Success", value: undefined }); + await pendingArchive; + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); + expect(testState.router.navigate).toHaveBeenCalledOnce(); + expect(testState.toast).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + testState.archiveShell = null; + testState.router.history.back(); + await act(async () => renderer.update(createElement(Probe))); + expect(testState.router.state.location.href).toBe("/"); + expect(testState.router.state.location.state.archiveDraftRetry?.projectRef).toEqual(projectRef); + }); + + it("recovers the archived checkout when back reaches the missing thread", async () => { + await actions.archiveThread(target); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledOnce(); + testState.archiveShell = null; + testState.router.history.back(); + await act(async () => renderer.update(createElement(Probe))); + expect(testState.router.state.location.href).toBe("/"); + expect(testState.router.state.location.state.archiveDraftRetry?.projectRef).toEqual(projectRef); + }); + + it("clears archive recovery context when its rpc fails", async () => { + testState.archive.mockResolvedValueOnce({ + _tag: "Failure", + cause: new Error("archive failed"), + }); + const result = await actions.archiveThread(target); + expect(result._tag).toBe("Failure"); + expect(testState.router.state.location.href).toBe("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/environment-ssh/archive-last"); + expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.archiveNotice).not.toHaveBeenCalled(); + }); + + it("automatically opens the exact checkout when its missing-thread redirect precedes the rpc", async () => { + let resolveArchive!: (value: { _tag: "Success"; value: undefined }) => void; + const archiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + resolveArchive = resolve; + }); + testState.archive.mockReturnValueOnce(archiveDone); + const pendingArchive = actions.archiveThread(target); + testState.archiveShell = null; + await act(async () => renderer.update(createElement(Probe))); + expect(testState.router.state.location.href).toBe("/"); + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + resolveArchive({ _tag: "Success", value: undefined }); + await pendingArchive; + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledOnce(); + expect(testState.toast).not.toHaveBeenCalled(); + }); + + it("keeps the last archived thread's checkout available to retry", async () => { + testState.nearbyThreads = false; + let resolveArchive!: (value: { _tag: "Success"; value: undefined }) => void; + const archiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + resolveArchive = resolve; + }); + testState.archive.mockReturnValueOnce(archiveDone); + const pendingArchive = actions.archiveThread(target); + testState.connectionPhase = "reconnecting"; + testState.archiveShell = null; + await act(async () => renderer.update(createElement(Probe))); + expect(testState.router.state.location.href).toBe("/"); + await act(async () => renderer.update(createElement(Route.options.component!))); + resolveArchive({ _tag: "Success", value: undefined }); + await pendingArchive; + expect(renderer.root.findByType("button").props.disabled).toBe(false); + expect(testState.router.state.location.state.archiveDraftRetry?.projectRef).toEqual(projectRef); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); + + it("keeps ordinary missing-thread recovery free of archive context", async () => { + testState.archiveShell = null; + await act(async () => renderer.update(createElement(Probe))); + expect(testState.router.state.location.href).toBe("/"); + expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); + }); + + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])( + "carries the archived thread's working mode and model (early redirect: %s, configured model: %s)", + async (early, configured) => { + testState.resolveRouteTargets = true; + const { useComposerDraftStore: realComposerDraftStore } = + await vi.importActual("../composerDraftStore"); + const originStore = realComposerDraftStore.getState(); + const carriedSelection = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "carried-model", + }; + const configuredSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "configured-model", + }; + testState.targetSettings.defaultModelSelection = configured ? configuredSelection : null; + originStore.clearDraftThread(target); + originStore.setInteractionMode(target, "plan"); + originStore.setModelSelection(target, carriedSelection, { explicit: true }); + testState.draftStore.getComposerDraft.mockImplementation((key) => + typeof key === "object" && + key !== null && + "threadId" in key && + key.threadId === target.threadId + ? (realComposerDraftStore.getState().getComposerDraft(target) ?? {}) + : {}, + ); + let resolveArchive!: (value: { _tag: "Success"; value: undefined }) => void; + testState.archive.mockReturnValueOnce( + new Promise((resolve) => { + resolveArchive = resolve; + }), + ); + const pendingArchive = actions.archiveThread(target); + testState.archiveShell = null; + expect(originStore.getComposerDraft(target)?.interactionMode).toBe("plan"); + expect( + originStore.getComposerDraft(target)?.modelSelectionByProvider[carriedSelection.instanceId], + ).toEqual(carriedSelection); + if (early) { + await act(async () => renderer.update(createElement(Probe))); + await act(async () => renderer.update(createElement(Route.options.component!))); + } + resolveArchive({ _tag: "Success", value: undefined }); + await pendingArchive; + expect.soft(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ + interactionMode: "plan", + runtimeMode: "full-access", + envMode: "local", + }), + ); + expect + .soft(testState.draftStore.setModelSelection) + .toHaveBeenCalledWith( + "draft-delayed", + configured ? configuredSelection : carriedSelection, + { replaceOptions: true }, + ); + originStore.clearDraftThread(target); + }, + ); + + it("does not turn a stale draft result into an archive retry", async () => { + testState.targetSettings.defaultThreadEnvMode = null; + let resolveReadStarted!: () => void; + const readStarted = new Promise((resolve) => { + resolveReadStarted = resolve; + }); + testState.projectFileReads.mockImplementationOnce(() => resolveReadStarted()); + const pendingArchive = actions.archiveThread(target); + await readStarted; + await testState.router.navigate({ to: "/usage" }); + testState.completeProjectFileRead(null); + await pendingArchive; + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); + expect(testState.router.navigate).toHaveBeenCalledOnce(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); + + it("cancels a pending recovery retry as soon as undo starts", async () => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + testState.connectionPhase = "connected"; + testState.targetSettings.defaultThreadEnvMode = null; + await act(async () => renderer.root.findByType("button").props.onClick()); + expect(testState.projectFileReads).toHaveBeenCalledOnce(); + let resolveUnarchive!: (value: { _tag: "Success"; value: undefined }) => void; + const unarchiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + resolveUnarchive = resolve; + }); + testState.unarchive.mockReturnValueOnce(unarchiveDone); + const undo = testState.archiveNotice.mock.lastCall![0].undo(); + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(renderer.root.findByType("button").props.disabled).toBe(true); + await act(async () => testState.completeProjectFileRead(null)); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + expect(testState.router.state.location.href).toBe("/"); + resolveUnarchive({ _tag: "Success", value: undefined }); + await undo; + expect(testState.router.state.location.href).toBe("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/environment-ssh/archive-last"); + }); + + it("does not overwrite navigation after undo starts", async () => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + let resolveUnarchive!: (value: { _tag: "Success"; value: undefined }) => void; + const unarchiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { + resolveUnarchive = resolve; + }); + testState.unarchive.mockReturnValueOnce(unarchiveDone); + const undo = testState.archiveNotice.mock.lastCall![0].undo(); + await testState.router.navigate({ to: "/usage" }); + resolveUnarchive({ _tag: "Success", value: undefined }); + await undo; + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); + }); + + it("retries the same checkout after undo fails", async () => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + testState.unarchive.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("undo failed") }); + await testState.archiveNotice.mock.lastCall![0].undo(); + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(renderer.root.findByType("button").props.disabled).toBe(false); + testState.connectionPhase = "connected"; + await act(async () => renderer.root.findByType("button").props.onClick()); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + }); + + it("can recover the checkout after successful undo and back", async () => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + await testState.archiveNotice.mock.lastCall![0].undo(); + await act(async () => renderer.update(createElement(Probe))); + testState.connectionPhase = "connected"; + testState.router.history.back(); + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + }); + + it("keeps retry available after unarchiving without navigation", async () => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + await actions.unarchiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(testState.router.state.location.href).toBe("/"); + expect(renderer.root.findByType("button").props.disabled).toBe(false); + testState.connectionPhase = "connected"; + await act(async () => renderer.root.findByType("button").props.onClick()); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + }); + + it("keeps a removed recovery checkout visible without opening another project", async () => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + testState.projects = testState.projects.filter( + (project) => project.id !== projectRef.projectId, + ); + testState.connectionPhase = "connected"; + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(renderer.toJSON()).not.toBeNull(); + expect(renderer.root.findByType("button").props.disabled).toBe(true); + expect(testState.router.state.location.state.archiveDraftRetry?.projectRef).toEqual(projectRef); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); + + it("keeps ordinary index startup on its most recent project", async () => { + testState.router.state.location.href = "/"; + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + { environmentId: "environment-primary", projectId: "project-other" }, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + expect(testState.archive).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4c38786a9177..83b029fb15f9 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -76,6 +76,8 @@ export function useNewThreadHandler() { startFromOrigin?: boolean; replace?: boolean; environmentSelection?: "manual"; + // Stale requests also return null; only this branch needs recovery. + onUnavailable?: () => void; }, // 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 @@ -85,6 +87,7 @@ export function useNewThreadHandler() { environmentPresentations.presentationAtom(projectRef.environmentId), ); if (environment?.connection.phase !== "connected") { + options?.onUnavailable?.(); toastManager.add( stackedThreadToast({ type: "error", @@ -108,27 +111,35 @@ export function useNewThreadHandler() { setModelSelection, } = useComposerDraftStore.getState(); const requestingRouteHref = router.state.location.href; - const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; + const requestingHistoryKey = router.history.location.state.__TSR_key; + const routeChangedSinceRequest = () => + router.state.location.href !== requestingRouteHref || + router.history.location.state.__TSR_key !== requestingHistoryKey; const currentRouteTarget = getCurrentRouteTarget(); + const archiveDraftRetry = router.history.location.state.archiveDraftRetry; + const recoveryThreadRef = + currentRouteTarget === null && + archiveDraftRetry?.cancelled !== true && + archiveDraftRetry?.projectRef.environmentId === projectRef.environmentId && + archiveDraftRetry.projectRef.projectId === projectRef.projectId + ? archiveDraftRetry.threadRef + : null; + const carryThreadRef = + currentRouteTarget?.kind === "server" ? currentRouteTarget.threadRef : recoveryThreadRef; // A new thread carries the user's working mode from the thread being // viewed. The target project's configured model still wins; interaction // mode carries independently. Permissions, branch, worktree, and env mode // come from configured defaults unless the caller passes them explicitly. - const carrySourceShell = - currentRouteTarget?.kind === "server" - ? readThreadShell(currentRouteTarget.threadRef) - : null; + const carrySourceShell = carryThreadRef ? readThreadShell(carryThreadRef) : null; const carrySourceDraft = currentRouteTarget?.kind === "draft" ? getDraftSession(currentRouteTarget.draftId) : null; // Composer overrides win over the persisted thread state — they are // what the user currently sees in the composer controls. - const carrySourceComposer = currentRouteTarget - ? getComposerDraft( - currentRouteTarget.kind === "server" - ? currentRouteTarget.threadRef - : currentRouteTarget.draftId, - ) - : null; + const carrySourceComposer = carryThreadRef + ? getComposerDraft(carryThreadRef) + : currentRouteTarget?.kind === "draft" + ? getComposerDraft(currentRouteTarget.draftId) + : null; const composerActiveProvider = carrySourceComposer?.activeProvider ?? null; const composerModelSelection = composerActiveProvider ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 4328d61da82f..073fbbef4ad0 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -7,7 +7,12 @@ import { import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; -import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + type ScopedProjectRef, + type ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -50,6 +55,16 @@ import * as ThreadUndo from "./threadUndo"; import { showThreadUndoNotice } from "./showThreadUndoNotice"; import { useAtomCommand } from "../state/use-atom-command"; +declare module "@tanstack/react-router" { + interface HistoryState { + archiveDraftRetry?: { + projectRef: ScopedProjectRef; + threadRef: ScopedThreadRef; + cancelled?: boolean; + }; + } +} + export class ThreadArchiveBlockedError extends Schema.TaggedError()( "ThreadArchiveBlockedError", { @@ -325,15 +340,39 @@ export function useThreadActions() { const unarchiveThread = useCallback( async (target: ScopedThreadRef, opts: { navigate?: boolean } = {}) => { ThreadUndo.invalidate("archive", scopedThreadKey(target)); + const recoveryLocation = router.history.location; + const recovery = recoveryLocation.state.archiveDraftRetry; + const cancelsRecovery = + recovery?.threadRef.environmentId === target.environmentId && + recovery.threadRef.threadId === target.threadId; + if (cancelsRecovery) { + // Replacing the history key cancels an in-flight retry before Undo's RPC settles. + router.history.replace(recoveryLocation.href, { + ...recoveryLocation.state, + archiveDraftRetry: { ...recovery, cancelled: true }, + }); + } + const requestingHistoryKey = router.history.location.state.__TSR_key; + const requestingRouteHref = router.state.location.href; + const routeUnchanged = () => + router.history.location.state.__TSR_key === requestingHistoryKey && + router.state.location.href === requestingRouteHref; const result = await unarchiveThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + const remainsOnRecovery = cancelsRecovery && routeUnchanged(); + if (remainsOnRecovery) { + router.history.replace(recoveryLocation.href, { + ...router.history.location.state, + archiveDraftRetry: recovery, + }); + } if (result._tag === "Failure") { return result; } refreshArchivedThreadsForEnvironment(target.environmentId); - if (opts.navigate) { + if (opts.navigate && (!cancelsRecovery || remainsOnRecovery)) { return settlePromise(() => router.navigate({ to: "/$environmentId/$threadId", @@ -366,12 +405,30 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; + const requestingRouteHref = router.state.location.href; + const projectRef = scopeProjectRef(thread.environmentId, thread.projectId); + if (shouldNavigateToDraft) { + router.history.replace(requestingRouteHref, { + ...router.history.location.state, + archiveDraftRetry: { projectRef, threadRef }, + }); + } + const requestingHistoryKey = router.history.location.state.__TSR_key; + const routeUnchanged = () => + router.state.location.href === requestingRouteHref && + router.history.location.state.__TSR_key === requestingHistoryKey; const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); if (archiveResult._tag === "Failure") { + if (shouldNavigateToDraft && routeUnchanged()) { + router.history.replace(requestingRouteHref, { + ...router.history.location.state, + archiveDraftRetry: undefined, + }); + } action.finish(); return archiveResult; } @@ -389,13 +446,28 @@ export function useThreadActions() { failureTitle: "Failed to undo archive", }); - if (shouldNavigateToDraft) { + if (shouldNavigateToDraft && routeUnchanged()) { + let unavailable = false; const navigationResult = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), + handleNewThreadRef.current(projectRef, { + onUnavailable: () => { + unavailable = true; + }, + }), ); if (navigationResult._tag === "Failure") { return navigationResult; } + if (navigationResult.value === null && unavailable && routeUnchanged()) { + const recoveryResult = await settlePromise(() => + router.navigate({ + to: "/", + replace: true, + state: { archiveDraftRetry: { projectRef, threadRef } }, + }), + ); + if (recoveryResult._tag === "Failure") return recoveryResult; + } return archiveResult; } @@ -406,6 +478,7 @@ export function useThreadActions() { getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget, + router, unarchiveThread, ], ); diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index 6e61b298be51..68a765e56f9b 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -16,10 +16,19 @@ const commands = vi.hoisted(() => ({ snooze: vi.fn(), unsnooze: vi.fn(), })); -const router = vi.hoisted(() => ({ - navigate: vi.fn(async () => {}), - state: { matches: [{ params: {} as Record }] }, -})); +const router = vi.hoisted(() => { + const location = { href: "/", state: { __TSR_key: "initial" } }; + return { + navigate: vi.fn(async () => {}), + state: { location, matches: [{ params: {} as Record }] }, + history: { + location, + replace: vi.fn(() => { + location.state.__TSR_key += "-replaced"; + }), + }, + }; +}); vi.mock("react", async (original) => ({ ...(await original()), useCallback: (callback: unknown) => callback, @@ -91,6 +100,9 @@ beforeEach(() => { command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); } router.navigate.mockClear(); + router.state.location.href = "/"; + router.state.location.state.__TSR_key = "initial"; + router.history.replace.mockClear(); router.state.matches[0]!.params = {}; threadShell.pinnedAt = null; threadShell.snoozedUntil = null; diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index fa8795ceaa4a..a59334daba00 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,6 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useLocation } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -44,6 +44,7 @@ function IndexDraftLanding() { const threads = useThreadShells(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); + const archiveDraftRetry = useLocation({ select: (location) => location.state.archiveDraftRetry }); const startingRef = useRef(false); const [startState, setStartState] = useState({ failed: false, retryRequest: 0 }); @@ -54,34 +55,60 @@ function IndexDraftLanding() { : null, [bootstrapped, projects, threads], ); + const recoveryProjectMissing = + bootstrapped && + archiveDraftRetry !== undefined && + !projects.some( + (project) => + project.environmentId === archiveDraftRetry.projectRef.environmentId && + project.id === archiveDraftRetry.projectRef.projectId, + ); + const retryDisabled = + archiveDraftRetry !== undefined && + (!bootstrapped || archiveDraftRetry.cancelled === true || recoveryProjectMissing); useEffect(() => { - if (mostRecentProject === null || startingRef.current) { + const projectRef = + archiveDraftRetry?.projectRef ?? + (mostRecentProject + ? scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id) + : null); + if (projectRef === null || startingRef.current || retryDisabled || !bootstrapped) { return; } startingRef.current = true; - void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), { + void handleNewThread(projectRef, { replace: true, }) .then((opened) => { if (opened === null) { - startingRef.current = false; + startingRef.current = archiveDraftRetry !== undefined; setStartState((state) => ({ ...state, failed: true })); } }) .catch(() => { - startingRef.current = false; + startingRef.current = archiveDraftRetry !== undefined; setStartState((state) => ({ ...state, failed: true })); }); - }, [handleNewThread, mostRecentProject, startState.retryRequest]); + }, [ + archiveDraftRetry, + bootstrapped, + handleNewThread, + mostRecentProject, + retryDisabled, + startState.retryRequest, + ]); - if (!bootstrapped) { + if (!bootstrapped && archiveDraftRetry === undefined) { return null; } - if (mostRecentProject !== null) { - return startState.failed ? ( + if (archiveDraftRetry !== undefined || mostRecentProject !== null) { + return startState.failed || retryDisabled ? ( { + startingRef.current = false; setStartState((state) => ({ failed: false, retryRequest: state.retryRequest + 1, @@ -95,7 +122,15 @@ function IndexDraftLanding() { return ; } -function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { +function DraftStartError({ + onRetry, + disabled = false, + projectMissing = false, +}: { + readonly onRetry: () => void; + readonly disabled?: boolean; + readonly projectMissing?: boolean; +}) { return ( {isElectron ? : null} @@ -103,10 +138,12 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) { Couldn’t start a new thread - The project is still available. Try opening the draft again. + {projectMissing + ? "This checkout is no longer registered. Choose another checkout from New thread in..." + : "The project is still available. Try opening the draft again."}
- From 61f36abef87c6e3160ef52c0ab3c200dbd00f34b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 16:53:19 +0000 Subject: [PATCH 5/7] fix(web): retry archive drafts after undo navigation --- apps/web/src/hooks/useHandleNewThread.test.ts | 136 +++++++++++++++--- apps/web/src/routes/_chat.index.tsx | 32 ++++- 2 files changed, 140 insertions(+), 28 deletions(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 63d7d2e43a00..b4701a2e5408 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -902,29 +902,49 @@ describe("archive draft recovery", () => { expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); - it("cancels a pending recovery retry as soon as undo starts", async () => { - testState.connectionPhase = "reconnecting"; - await actions.archiveThread(target); - await act(async () => renderer.update(createElement(Route.options.component!))); - testState.connectionPhase = "connected"; - testState.targetSettings.defaultThreadEnvMode = null; - await act(async () => renderer.root.findByType("button").props.onClick()); - expect(testState.projectFileReads).toHaveBeenCalledOnce(); - let resolveUnarchive!: (value: { _tag: "Success"; value: undefined }) => void; - const unarchiveDone = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => { - resolveUnarchive = resolve; - }); - testState.unarchive.mockReturnValueOnce(unarchiveDone); - const undo = testState.archiveNotice.mock.lastCall![0].undo(); - await act(async () => renderer.update(createElement(Route.options.component!))); - expect(renderer.root.findByType("button").props.disabled).toBe(true); - await act(async () => testState.completeProjectFileRead(null)); - expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); - expect(testState.router.state.location.href).toBe("/"); - resolveUnarchive({ _tag: "Success", value: undefined }); - await undo; - expect(testState.router.state.location.href).toBe("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/environment-ssh/archive-last"); - }); + it.each(["Success", "Failure"] as const)( + "cancels a pending recovery and lets a fresh retry supersede undo's %s result", + async (resultTag) => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + testState.connectionPhase = "connected"; + testState.targetSettings.defaultThreadEnvMode = null; + await act(async () => renderer.root.findByType("button").props.onClick()); + expect(testState.projectFileReads).toHaveBeenCalledOnce(); + let resolveUnarchive!: ( + value: { _tag: "Success"; value: undefined } | { _tag: "Failure"; cause: unknown }, + ) => void; + testState.unarchive.mockReturnValueOnce( + new Promise((resolve) => { + resolveUnarchive = resolve; + }), + ); + const undo = testState.archiveNotice.mock.lastCall![0].undo(); + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(renderer.root.findByType("button").props.disabled).toBe(false); + await act(async () => testState.completeProjectFileRead(null)); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + expect(testState.router.state.location.href).toBe("/"); + await act(async () => { + renderer.root.findByType("button").props.onClick(); + resolveUnarchive( + resultTag === "Success" + ? { _tag: "Success", value: undefined } + : { _tag: "Failure", cause: new Error("undo failed") }, + ); + await undo; + expect(testState.router.state.location.href).toBe("/"); + }); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + }, + ); it("does not overwrite navigation after undo starts", async () => { testState.connectionPhase = "reconnecting"; @@ -942,6 +962,76 @@ describe("archive draft recovery", () => { expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); }); + it.each(["Success", "Failure"] as const)( + "can retry after navigating during undo, its %s result, and back", + async (resultTag) => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + let resolveUnarchive!: ( + value: { _tag: "Success"; value: undefined } | { _tag: "Failure"; cause: unknown }, + ) => void; + testState.unarchive.mockReturnValueOnce( + new Promise((resolve) => { + resolveUnarchive = resolve; + }), + ); + const undo = testState.archiveNotice.mock.lastCall![0].undo(); + await testState.router.navigate({ to: "/usage" }); + await act(async () => renderer.update(createElement("div"))); + resolveUnarchive( + resultTag === "Success" + ? { _tag: "Success", value: undefined } + : { _tag: "Failure", cause: new Error("undo failed") }, + ); + await undo; + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.state.location.state.archiveDraftRetry).toBeUndefined(); + + testState.router.history.back(); + testState.connectionPhase = "connected"; + await act(async () => renderer.update(createElement(Route.options.component!))); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + expect(renderer.root.findByType("button").props.disabled).toBe(false); + await act(async () => renderer.root.findByType("button").props.onClick()); + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledExactlyOnceWith( + "remote-project", + projectRef, + "draft-delayed", + expect.objectContaining({ envMode: "local" }), + ); + expect(testState.router.state.location.href).toBe("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/draft/draft-delayed"); + }, + ); + + it.each(["/usage", "/"])( + "ignores a stale recovery retry after navigating to %s", + async (href) => { + testState.connectionPhase = "reconnecting"; + await actions.archiveThread(target); + await act(async () => renderer.update(createElement(Route.options.component!))); + let resolveUnarchive!: (value: { _tag: "Success"; value: undefined }) => void; + testState.unarchive.mockReturnValueOnce( + new Promise((resolve) => { + resolveUnarchive = resolve; + }), + ); + const undo = testState.archiveNotice.mock.lastCall![0].undo(); + await act(async () => renderer.update(createElement(Route.options.component!))); + const retry = renderer.root.findByType("button").props.onClick; + const replacements = testState.router.history.replace.mock.calls.length; + await testState.router.navigate({ to: href }); + await act(async () => renderer.update(createElement("div"))); + await act(async () => retry()); + expect(testState.router.state.location.href).toBe(href); + expect(testState.router.history.replace).toHaveBeenCalledTimes(replacements); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + resolveUnarchive({ _tag: "Success", value: undefined }); + await undo; + expect(testState.router.state.location.href).toBe(href); + }, + ); + it("retries the same checkout after undo fails", async () => { testState.connectionPhase = "reconnecting"; await actions.archiveThread(target); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index a59334daba00..202a36173529 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,6 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { createFileRoute, Link, useLocation } from "@tanstack/react-router"; +import { createFileRoute, Link, useLocation, useRouter } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -40,11 +40,14 @@ function ChatIndexRouteView() { * end. Falls back to an add-project hero when no project exists yet. */ function IndexDraftLanding() { + const router = useRouter(); const projects = useProjects(); const threads = useThreadShells(); const bootstrapped = useAllEnvironmentShellsBootstrapped(); const handleNewThread = useNewThreadHandler(); const archiveDraftRetry = useLocation({ select: (location) => location.state.archiveDraftRetry }); + const retryRouteHref = router.state.location.href; + const retryHistoryKey = router.history.location.state.__TSR_key; const startingRef = useRef(false); const [startState, setStartState] = useState({ failed: false, retryRequest: 0 }); @@ -63,9 +66,9 @@ function IndexDraftLanding() { project.environmentId === archiveDraftRetry.projectRef.environmentId && project.id === archiveDraftRetry.projectRef.projectId, ); + const retryCancelled = archiveDraftRetry?.cancelled === true; const retryDisabled = - archiveDraftRetry !== undefined && - (!bootstrapped || archiveDraftRetry.cancelled === true || recoveryProjectMissing); + archiveDraftRetry !== undefined && (!bootstrapped || recoveryProjectMissing); useEffect(() => { const projectRef = @@ -73,7 +76,13 @@ function IndexDraftLanding() { (mostRecentProject ? scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id) : null); - if (projectRef === null || startingRef.current || retryDisabled || !bootstrapped) { + if ( + projectRef === null || + startingRef.current || + retryCancelled || + retryDisabled || + !bootstrapped + ) { return; } startingRef.current = true; @@ -95,6 +104,7 @@ function IndexDraftLanding() { bootstrapped, handleNewThread, mostRecentProject, + retryCancelled, retryDisabled, startState.retryRequest, ]); @@ -103,11 +113,23 @@ function IndexDraftLanding() { return null; } if (archiveDraftRetry !== undefined || mostRecentProject !== null) { - return startState.failed || retryDisabled ? ( + return startState.failed || retryCancelled || retryDisabled ? ( { + if ( + router.state.location.href !== retryRouteHref || + router.history.location.state.__TSR_key !== retryHistoryKey + ) { + return; + } + if (archiveDraftRetry?.cancelled === true) { + router.history.replace(retryRouteHref, { + ...router.history.location.state, + archiveDraftRetry: { ...archiveDraftRetry, cancelled: undefined }, + }); + } startingRef.current = false; setStartState((state) => ({ failed: false, From 35490c8d061959bf60e91db4f2081739ebb829b9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 17:09:34 +0000 Subject: [PATCH 6/7] fix(web): clarify archive recovery retry guidance --- apps/web/src/routes/_chat.index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 202a36173529..965631ffe261 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -162,7 +162,9 @@ function DraftStartError({ {projectMissing ? "This checkout is no longer registered. Choose another checkout from New thread in..." - : "The project is still available. Try opening the draft again."} + : disabled + ? "Waiting for this checkout to become available." + : "Try opening the draft again when its environment is connected."}