From 7d392e036d156169d491a794306b55a0aed8c239 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:57:58 -0700 Subject: [PATCH] refactor(web): ChatView's project clone banner lives in useProjectCloneBanner Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 114 +-------------- .../components/chat/useProjectCloneBanner.tsx | 134 ++++++++++++++++++ 2 files changed, 138 insertions(+), 110 deletions(-) create mode 100644 apps/web/src/components/chat/useProjectCloneBanner.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 95fdfef8371a..7bb4c62b850b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,4 @@ +import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; import { useTimelineRewind } from "./chat/useTimelineRewind"; import { useThreadLifecycle } from "./chat/useThreadLifecycle"; @@ -176,7 +177,7 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; -import { PaperclipIcon, ChevronDownIcon, DownloadIcon } from "lucide-react"; +import { PaperclipIcon, ChevronDownIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -198,7 +199,6 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { isPreviewFocused } from "../lib/previewFocus"; @@ -235,10 +235,6 @@ import { import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; -import { sourceControlEnvironment } from "../state/sourceControl"; -import { useProjectClone } from "../state/projectClones"; -import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; -import { dismissProjectFileCopy, useProjectFileCopy } from "../lib/addProjectToMachine"; import { type EnvironmentPresentation, useEnvironmentProviders, @@ -1117,110 +1113,8 @@ export default function ChatView(props: ChatViewProps) { activeProject ?? undefined, ); const activeProjectScripts = activeProject ? resolveProjectScripts(settings, activeProject) : []; - const activeProjectClone = useProjectClone(activeProjectRef); - const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { - reportFailure: false, - }); - const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { - reportFailure: false, - }); - const removeClonedProject = useRemoveClonedProject(); - const runProjectCloneAction = async ( - title: string, - action: () => Promise>, - ): Promise => { - const result = await action(); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - }; - const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); - const activeProjectFileCopy = useProjectFileCopy(activeProjectRef); - const projectCloneSendBlockReason = - activeProjectClone?.phase === "running" - ? "Cloning repository" - : activeProjectClone !== null && activeProjectClone.phase !== "done" - ? "Repository not cloned" - : activeProjectFileCopy?.phase === "copying" - ? "Copying files" - : null; - const projectCloneBannerItem = (() => { - if (activeProjectFileCopy?.phase === "failed" && activeProjectRef) { - return { - id: `project-file-copy:${activeProjectRef.projectId}`, - variant: "error", - icon: , - title: activeProjectFileCopy.title, - description: activeProjectFileCopy.failures.join("\n"), - onDismiss: () => dismissProjectFileCopy(activeProjectRef), - }; - } - if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { - return null; - } - const name = projectCloneDisplayName(activeProjectClone); - const { environmentId, projectId } = activeProjectRef; - if (activeProjectClone.phase === "running") { - return { - id: `project-clone:${projectId}`, - variant: "info", - priority: "activity", - icon: , - title: `Cloning ${name}`, - description: projectCloneProgressSummary(activeProjectClone), - actions: ( - - ), - }; - } - const cancelled = activeProjectClone.phase === "cancelled"; - return { - id: `project-clone:${projectId}`, - variant: cancelled ? "warning" : "error", - icon: , - title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, - description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, - actions: ( - <> - - - - ), - }; - })() as ComposerBannerStackItem | null; + const { projectCloneBannerItem, projectCloneSendBlockReason } = + useProjectCloneBanner(activeProjectRef); const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; const handleNewThreadInActiveProject = () => { startNewThreadForProject(activeProjectRef, handleNewThread); diff --git a/apps/web/src/components/chat/useProjectCloneBanner.tsx b/apps/web/src/components/chat/useProjectCloneBanner.tsx new file mode 100644 index 000000000000..ab9bcfa09bfa --- /dev/null +++ b/apps/web/src/components/chat/useProjectCloneBanner.tsx @@ -0,0 +1,134 @@ +import { useStableCallback } from "../../hooks/useStableCallback"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { DownloadIcon } from "lucide-react"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { useRemoveClonedProject } from "../../hooks/useRemoveClonedProject"; +import { sourceControlEnvironment } from "../../state/sourceControl"; +import { useProjectClone } from "../../state/projectClones"; +import { + projectCloneDisplayName, + projectCloneProgressSummary, + type ScopedProjectRef, +} from "@t3tools/contracts"; +import { dismissProjectFileCopy, useProjectFileCopy } from "../../lib/addProjectToMachine"; +import { type ComposerBannerStackItem } from "./ComposerBannerStack"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +/** + * A project still being cloned or copied into place: the composer banner with its cancel, + * retry, and remove actions, and the reason sending waits until it is done. + */ +export function useProjectCloneBanner(activeProjectRef: ScopedProjectRef | null) { + const activeProjectClone = useProjectClone(activeProjectRef); + const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { + reportFailure: false, + }); + const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { + reportFailure: false, + }); + const removeClonedProject = useRemoveClonedProject(); + const runProjectCloneAction = async ( + title: string, + action: () => Promise>, + ): Promise => { + const result = await action(); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }; + const runProjectCloneActionStable = useStableCallback(runProjectCloneAction); + const activeProjectFileCopy = useProjectFileCopy(activeProjectRef); + const projectCloneSendBlockReason = + activeProjectClone?.phase === "running" + ? "Cloning repository" + : activeProjectClone !== null && activeProjectClone.phase !== "done" + ? "Repository not cloned" + : activeProjectFileCopy?.phase === "copying" + ? "Copying files" + : null; + const projectCloneBannerItem = (() => { + if (activeProjectFileCopy?.phase === "failed" && activeProjectRef) { + return { + id: `project-file-copy:${activeProjectRef.projectId}`, + variant: "error", + icon: , + title: activeProjectFileCopy.title, + description: activeProjectFileCopy.failures.join("\n"), + onDismiss: () => dismissProjectFileCopy(activeProjectRef), + }; + } + if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { + return null; + } + const name = projectCloneDisplayName(activeProjectClone); + const { environmentId, projectId } = activeProjectRef; + if (activeProjectClone.phase === "running") { + return { + id: `project-clone:${projectId}`, + variant: "info", + priority: "activity", + icon: , + title: `Cloning ${name}`, + description: projectCloneProgressSummary(activeProjectClone), + actions: ( + + ), + }; + } + const cancelled = activeProjectClone.phase === "cancelled"; + return { + id: `project-clone:${projectId}`, + variant: cancelled ? "warning" : "error", + icon: , + title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, + description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, + actions: ( + <> + + + + ), + }; + })() as ComposerBannerStackItem | null; + return { + projectCloneBannerItem, + projectCloneSendBlockReason, + }; +}