From 3b2f16f98f403c20f11cfbc860997020dfe85db5 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 9 Apr 2026 11:20:50 -0700 Subject: [PATCH 01/41] feat(desktop): clone V1 new-workspace composer onto V2 modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the tab-based V2 create-workspace modal with a clone of the battle-tested V1 composer, rewiring only the backend boundaries. Backend boundary changes (V1 → V2): - Project list: electronTrpc.projects.getRecents → v2Projects + githubRepositories collections - Branch list: electronTrpc.projects.getBranches* → workspaceCreation.searchBranches on host-service - Create action: 4 V1 mutations (create/createFromPr/openTracked/ openExternal) → single workspaceCreation.create on host-service - GitHub issues/PRs list + content: electronTrpc.projects.{listIssues, listPullRequests, searchPullRequests, getIssueContent} → workspaceCreation.{searchGitHubIssues, searchPullRequests, getGitHubIssueContent} on host-service (Octokit via ctx.github()) - Navigation: navigateToWorkspace → navigateToV2Workspace V2 additions: - DevicePicker in the composer footer for host target selection; on host change, compareBaseBranch resets - hostTarget field in the draft context Intentionally dropped for Phase 1 (deferred to Phase 2): - Branch prefix feature (projects.get, getGitAuthor, settings. getBranchPrefix, settings.getGitInfo, resolveBranchPrefix) — crosses V2 host boundary, needs host-aware prefix in Phase 2 - Worktree preflight UI (getExternalWorktrees, getWorktreesByProject, resolveOpenableWorktrees, worktree badges/filter tab) — host-service workspaceCreation.create handles tracked/external/adopt server-side Host-service endpoints added: - workspaceCreation.getContext — project + default branch - workspaceCreation.searchBranches — git branches with hasWorkspace - workspaceCreation.create — semantic create with outcome resolution (created_workspace / opened_existing_workspace / opened_worktree / adopted_external_worktree) and path-traversal guard on branchName - workspaceCreation.searchGitHubIssues — Octokit issue list/search - workspaceCreation.searchPullRequests — Octokit PR list/search - workspaceCreation.getGitHubIssueContent — Octokit issue body fetch --- .../DashboardNewWorkspaceDraftContext.tsx | 97 +- .../DashboardNewWorkspaceModal.tsx | 69 +- .../DashboardNewWorkspaceForm.tsx | 90 -- .../PromptGroup/PromptGroup.tsx | 1103 +++++++++++++++++ .../GitHubIssueLinkCommand.tsx | 154 +++ .../GitHubIssueLinkCommand/index.ts | 1 + .../LinkedGitHubIssuePill.tsx | 59 + .../components/LinkedGitHubIssuePill/index.ts | 1 + .../components/LinkedPRPill/LinkedPRPill.tsx | 55 + .../components/LinkedPRPill/index.ts | 1 + .../PRLinkCommand/PRLinkCommand.tsx | 168 +++ .../components/PRLinkCommand/index.ts | 1 + .../{components => }/PromptGroup/index.ts | 0 .../BranchesGroup/BranchesGroup.tsx | 206 --- .../components/BranchesGroup/index.ts | 1 - .../DashboardNewWorkspaceFormHeader.tsx | 52 - .../DashboardNewWorkspaceFormHeader/index.ts | 1 - .../DashboardNewWorkspaceListTabContent.tsx | 65 - .../index.ts | 1 - .../DashboardNewWorkspacePromptTabContent.tsx | 24 - .../index.ts | 1 - .../components/IssuesGroup/IssuesGroup.tsx | 211 ---- .../components/IssuesGroup/index.ts | 1 - .../ProjectSelector/ProjectSelector.tsx | 139 --- .../components/ProjectSelector/index.ts | 1 - .../components/PromptGroup/PromptGroup.tsx | 239 ---- .../PromptGroupAdvancedOptions.tsx | 217 ---- .../PromptGroupAdvancedOptions/index.ts | 1 - .../PullRequestsGroup/PullRequestsGroup.tsx | 181 --- .../components/PullRequestsGroup/index.ts | 1 - .../hooks/useBranchContext/index.ts | 1 + .../useBranchContext/useBranchContext.ts | 40 + .../index.ts | 1 - ...seDashboardNewWorkspaceProjectSelection.ts | 102 -- .../hooks/useResolvedLocalProject/index.ts | 1 - .../useResolvedLocalProject.ts | 27 - .../DashboardNewWorkspaceForm/index.ts | 1 - .../DashboardNewWorkspaceModalContent.tsx | 123 ++ .../index.ts | 1 + .../useCreateDashboardWorkspace.ts | 85 +- .../host-service/src/trpc/router/router.ts | 2 + .../trpc/router/workspace-creation/index.ts | 1 + .../workspace-creation/workspace-creation.ts | 644 ++++++++++ 43 files changed, 2510 insertions(+), 1660 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts rename apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/{components => }/PromptGroup/index.ts (100%) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/PromptGroupAdvancedOptions.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/useResolvedLocalProject.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index 07d4bc41f1f..320f167cbcb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx @@ -7,52 +7,64 @@ import { useMemo, useState, } from "react"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; +import type { WorkspaceHostTarget } from "./components/DashboardNewWorkspaceForm/components/DevicePicker"; +import { useCreateDashboardWorkspace } from "./hooks/useCreateDashboardWorkspace"; + +export type LinkedIssue = { + slug: string; // "#123" for GitHub, "SUP-123" for internal + title: string; + source?: "github" | "internal"; + url?: string; // GitHub issue URL + taskId?: string; // Internal task ID for navigation + number?: number; // GitHub issue number + state?: "open" | "closed"; +}; -export type DashboardNewWorkspaceTab = - | "prompt" - | "issues" - | "pull-requests" - | "branches"; +export type LinkedPR = { + prNumber: number; + title: string; + url: string; + state: string; +}; export interface DashboardNewWorkspaceDraft { - activeTab: DashboardNewWorkspaceTab; selectedProjectId: string | null; hostTarget: WorkspaceHostTarget; prompt: string; + compareBaseBranch: string | null; + runSetupScript: boolean; + workspaceName: string; + workspaceNameEdited: boolean; branchName: string; branchNameEdited: boolean; - compareBaseBranch: string | null; - showAdvanced: boolean; - branchSearch: string; - issuesQuery: string; - pullRequestsQuery: string; - branchesQuery: string; + linkedIssues: LinkedIssue[]; + linkedPR: LinkedPR | null; } interface DashboardNewWorkspaceDraftState extends DashboardNewWorkspaceDraft { draftVersion: number; + resetKey: number; } const initialDraft: DashboardNewWorkspaceDraft = { - activeTab: "prompt", selectedProjectId: null, hostTarget: { kind: "local" }, prompt: "", + compareBaseBranch: null, + runSetupScript: true, + workspaceName: "", + workspaceNameEdited: false, branchName: "", branchNameEdited: false, - compareBaseBranch: null, - showAdvanced: false, - branchSearch: "", - issuesQuery: "", - pullRequestsQuery: "", - branchesQuery: "", + linkedIssues: [], + linkedPR: null, }; function buildInitialDraftState(): DashboardNewWorkspaceDraftState { return { ...initialDraft, draftVersion: 0, + resetKey: 0, }; } @@ -69,8 +81,10 @@ interface DashboardNewWorkspaceActionOptions { interface DashboardNewWorkspaceDraftContextValue { draft: DashboardNewWorkspaceDraft; draftVersion: number; + resetKey: number; closeModal: () => void; closeAndResetDraft: () => void; + createWorkspace: ReturnType; runAsyncAction: ( promise: Promise, messages: DashboardNewWorkspaceActionMessages, @@ -89,26 +103,16 @@ export function DashboardNewWorkspaceDraftProvider({ }: PropsWithChildren<{ onClose: () => void }>) { const [state, setState] = useState(buildInitialDraftState); + // Owned here so onSuccess survives Dialog unmounting content on close. + const createWorkspace = useCreateDashboardWorkspace(); + const updateDraft = useCallback( (patch: Partial) => { - setState((state) => { - const entries = Object.entries(patch) as Array< - [ - keyof DashboardNewWorkspaceDraft, - DashboardNewWorkspaceDraft[keyof DashboardNewWorkspaceDraft], - ] - >; - const hasChanges = entries.some(([key, value]) => state[key] !== value); - if (!hasChanges) { - return state; - } - - return { - ...state, - ...patch, - draftVersion: state.draftVersion + 1, - }; - }); + setState((state) => ({ + ...state, + ...patch, + draftVersion: state.draftVersion + 1, + })); }, [], ); @@ -117,6 +121,7 @@ export function DashboardNewWorkspaceDraftProvider({ setState((state) => ({ ...initialDraft, draftVersion: state.draftVersion + 1, + resetKey: state.resetKey + 1, })); }, []); @@ -148,28 +153,30 @@ export function DashboardNewWorkspaceDraftProvider({ const value = useMemo( () => ({ draft: { - activeTab: state.activeTab, selectedProjectId: state.selectedProjectId, hostTarget: state.hostTarget, prompt: state.prompt, + compareBaseBranch: state.compareBaseBranch, + runSetupScript: state.runSetupScript, + workspaceName: state.workspaceName, + workspaceNameEdited: state.workspaceNameEdited, branchName: state.branchName, branchNameEdited: state.branchNameEdited, - compareBaseBranch: state.compareBaseBranch, - showAdvanced: state.showAdvanced, - branchSearch: state.branchSearch, - issuesQuery: state.issuesQuery, - pullRequestsQuery: state.pullRequestsQuery, - branchesQuery: state.branchesQuery, + linkedIssues: state.linkedIssues, + linkedPR: state.linkedPR, }, draftVersion: state.draftVersion, + resetKey: state.resetKey, closeModal: onClose, closeAndResetDraft, + createWorkspace, runAsyncAction, updateDraft, resetDraft, }), [ closeAndResetDraft, + createWorkspace, onClose, resetDraft, runAsyncAction, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx index 12bf45f75db..6e887f5c2f9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx @@ -1,3 +1,7 @@ +import { + PromptInputProvider, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; import { Dialog, DialogContent, @@ -5,38 +9,65 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; +import { useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; -import { DashboardNewWorkspaceForm } from "./components/DashboardNewWorkspaceForm"; -import { DashboardNewWorkspaceDraftProvider } from "./DashboardNewWorkspaceDraftContext"; +import { DashboardNewWorkspaceModalContent } from "./components/DashboardNewWorkspaceModalContent"; +import { + DashboardNewWorkspaceDraftProvider, + useDashboardNewWorkspaceDraft, +} from "./DashboardNewWorkspaceDraftContext"; + +/** Clears the PromptInputProvider text & attachments when the draft resets. */ +function PromptInputResetSync() { + const { resetKey } = useDashboardNewWorkspaceDraft(); + const { textInput, attachments } = usePromptInputController(); + const prevResetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKey !== prevResetKeyRef.current) { + prevResetKeyRef.current = resetKey; + textInput.clear(); + attachments.clear(); + } + }, [resetKey, textInput.clear, attachments.clear]); + + return null; +} export function DashboardNewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); const preSelectedProjectId = usePreSelectedProjectId(); + // Prevents AgentSelect from flashing "No agent" while presets load after refresh. + electronTrpc.settings.getAgentPresets.useQuery(); + return ( - !open && closeModal()}> - - New Workspace - - Create a new workspace from a PR, branch, issue, or prompt. - - - - - - + + + !open && closeModal()}> + + New Workspace + Create a new workspace + + e.preventDefault()} + className="bg-popover text-popover-foreground sm:max-w-[560px] max-h-[min(70vh,600px)] !top-[calc(50%-min(35vh,300px))] !-translate-y-0 flex flex-col overflow-hidden p-0" + > + + + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx deleted file mode 100644 index fe5b9b3ab0c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback } from "react"; -import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; -import { DashboardNewWorkspaceFormHeader } from "./components/DashboardNewWorkspaceFormHeader"; -import { DashboardNewWorkspaceListTabContent } from "./components/DashboardNewWorkspaceListTabContent"; -import { DashboardNewWorkspacePromptTabContent } from "./components/DashboardNewWorkspacePromptTabContent"; -import { useDashboardNewWorkspaceProjectSelection } from "./hooks/useDashboardNewWorkspaceProjectSelection"; -import { useResolvedLocalProject } from "./hooks/useResolvedLocalProject"; - -interface DashboardNewWorkspaceFormProps { - isOpen: boolean; - preSelectedProjectId: string | null; -} - -/** Main form for the new workspace modal with collection-based project selection. */ -export function DashboardNewWorkspaceForm({ - isOpen, - preSelectedProjectId, -}: DashboardNewWorkspaceFormProps) { - const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); - const handleSelectProject = useCallback( - (selectedProjectId: string | null) => { - updateDraft({ selectedProjectId }); - }, - [updateDraft], - ); - const { githubRepository, githubRepositoryId } = - useDashboardNewWorkspaceProjectSelection({ - isOpen, - preSelectedProjectId, - selectedProjectId: draft.selectedProjectId, - onSelectProject: handleSelectProject, - }); - const resolvedLocalProjectId = useResolvedLocalProject(githubRepository); - - const listTab = draft.activeTab === "prompt" ? null : draft.activeTab; - const isListTab = listTab !== null; - const listQuery = - draft.activeTab === "issues" - ? draft.issuesQuery - : draft.activeTab === "branches" - ? draft.branchesQuery - : draft.pullRequestsQuery; - - const handleListQueryChange = (value: string) => { - switch (draft.activeTab) { - case "issues": - updateDraft({ issuesQuery: value }); - return; - case "branches": - updateDraft({ branchesQuery: value }); - return; - case "pull-requests": - updateDraft({ pullRequestsQuery: value }); - return; - default: - return; - } - }; - - return ( - <> - updateDraft({ activeTab })} - onSelectHostTarget={(hostTarget) => updateDraft({ hostTarget })} - onSelectProject={handleSelectProject} - /> - - {isListTab ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx new file mode 100644 index 00000000000..ea202eabfeb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -0,0 +1,1103 @@ +import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { + PromptInput, + PromptInputAttachment, + PromptInputAttachments, + PromptInputButton, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, + useProviderAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Input } from "@superset/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowUpIcon, PaperclipIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { GoGitBranch, GoIssueOpened } from "react-icons/go"; +import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; +import { LuGitPullRequest } from "react-icons/lu"; +import { SiLinear } from "react-icons/si"; +import { AgentSelect } from "renderer/components/AgentSelect"; +import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; +import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; +import { env } from "renderer/env.renderer"; +import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; +import { PLATFORM } from "renderer/hotkeys"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + useClearPendingWorkspace, + useNewWorkspaceModalOpen, + useSetPendingWorkspace, + useSetPendingWorkspaceStatus, +} from "renderer/stores/new-workspace-modal"; +import { buildPromptAgentLaunchRequest } from "shared/utils/agent-launch-request"; +import { + type AgentDefinitionId, + getEnabledAgentConfigs, + indexResolvedAgentConfigs, +} from "shared/utils/agent-settings"; +import { sanitizeBranchNameWithMaxLength } from "shared/utils/branch"; +import type { LinkedPR } from "../../../DashboardNewWorkspaceDraftContext"; +import { useDashboardNewWorkspaceDraft } from "../../../DashboardNewWorkspaceDraftContext"; +import { DevicePicker } from "../components/DevicePicker"; +import { useBranchContext } from "../hooks/useBranchContext"; +import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; +import { LinkedGitHubIssuePill } from "./components/LinkedGitHubIssuePill"; +import { LinkedPRPill } from "./components/LinkedPRPill"; +import { PRLinkCommand } from "./components/PRLinkCommand"; + +type WorkspaceCreateAgent = AgentDefinitionId | "none"; + +const AGENT_STORAGE_KEY = "lastSelectedWorkspaceCreateAgent"; + +const PILL_BUTTON_CLASS = + "!h-[22px] min-h-0 rounded-md border-[0.5px] border-border bg-foreground/[0.04] shadow-none text-[11px]"; + +type ConvertedFile = { + data: string; + mediaType: string; + filename?: string; +}; + +interface ProjectOption { + id: string; + name: string; + githubOwner: string | null; + githubRepoName: string | null; +} + +interface PromptGroupProps { + projectId: string | null; + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; +} + +// ── Attachment buttons ──────────────────────────────────────────────── + +function AttachmentButtons({ + anchorRef, + onOpenIssueLink, + onOpenGitHubIssue, + onOpenPRLink, +}: { + anchorRef: React.RefObject; + onOpenIssueLink: () => void; + onOpenGitHubIssue: () => void; + onOpenPRLink: () => void; +}) { + const attachments = usePromptInputAttachments(); + return ( +
+ + + attachments.openFileDialog()} + > + + + + Add attachment + + + + + + + + Link issue + + + + + + + + Link GitHub issue + + + + + + + + Link pull request + +
+ ); +} + +// ── Project picker pill ─────────────────────────────────────────────── + +function ProjectPickerPill({ + selectedProject, + recentProjects, + onSelectProject, +}: { + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + {selectedProject && ( + + )} + + {selectedProject?.name ?? "Select project"} + + + + + + + + + No projects found. + + {recentProjects.map((project) => ( + { + onSelectProject(project.id); + setOpen(false); + }} + > + + {project.name} + {project.id === selectedProject?.id && ( + + )} + + ))} + + + + + + ); +} + +// ── Compare base branch picker ──────────────────────────────────────── + +function CompareBaseBranchPickerInline({ + effectiveCompareBaseBranch, + defaultBranch, + isBranchesLoading, + isBranchesError, + branches, + onSelectCompareBaseBranch, +}: { + effectiveCompareBaseBranch: string | null; + defaultBranch: string | null | undefined; + isBranchesLoading: boolean; + isBranchesError: boolean; + branches: Array<{ + name: string; + lastCommitDate: number; + isLocal: boolean; + hasWorkspace: boolean; + }>; + onSelectCompareBaseBranch: (branchName: string) => void; +}) { + const [open, setOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); + + const filteredBranches = useMemo(() => { + if (!branchSearch) return branches; + const searchLower = branchSearch.toLowerCase(); + return branches.filter((b) => b.name.toLowerCase().includes(searchLower)); + }, [branches, branchSearch]); + + if (isBranchesError) { + return ( + Failed to load branches + ); + } + + return ( + { + setOpen(v); + if (!v) setBranchSearch(""); + }} + > + + + + event.stopPropagation()} + > + + + + No branches found + {filteredBranches.map((branch) => ( + { + onSelectCompareBaseBranch(branch.name); + setOpen(false); + }} + className="group h-11 flex items-center justify-between gap-3 px-3" + > + + + + {branch.name} + + + {branch.name === defaultBranch && ( + + default + + )} + {branch.hasWorkspace && ( + + workspace + + )} + + + + {branch.lastCommitDate > 0 && ( + + {formatRelativeTime(branch.lastCommitDate * 1000)} + + )} + {effectiveCompareBaseBranch === branch.name && ( + + )} + + + ))} + + + + + ); +} + +// ── Inner component ─────────────────────────────────────────────────── + +function PromptGroupInner({ + projectId, + selectedProject, + recentProjects, + onSelectProject, +}: PromptGroupProps) { + const navigate = useNavigate(); + const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; + const isNewWorkspaceModalOpen = useNewWorkspaceModalOpen(); + const { + closeAndResetDraft, + closeModal, + createWorkspace, + draft, + runAsyncAction, + updateDraft, + } = useDashboardNewWorkspaceDraft(); + const attachments = useProviderAttachments(); + const clearPendingWorkspace = useClearPendingWorkspace(); + const setPendingWorkspace = useSetPendingWorkspace(); + const setPendingWorkspaceStatus = useSetPendingWorkspaceStatus(); + const { activeHostUrl } = useLocalHostService(); + const { + compareBaseBranch, + hostTarget, + prompt, + runSetupScript, + workspaceName, + workspaceNameEdited, + branchName, + branchNameEdited, + linkedIssues, + linkedPR, + } = draft; + + const agentPresetsQuery = electronTrpc.settings.getAgentPresets.useQuery(); + const agentPresets = useMemo( + () => agentPresetsQuery.data ?? [], + [agentPresetsQuery.data], + ); + const enabledAgentPresets = useMemo( + () => getEnabledAgentConfigs(agentPresets), + [agentPresets], + ); + const agentConfigsById = useMemo( + () => indexResolvedAgentConfigs(agentPresets), + [agentPresets], + ); + const selectableAgentIds = useMemo( + () => enabledAgentPresets.map((preset) => preset.id), + [enabledAgentPresets], + ); + const { selectedAgent, setSelectedAgent } = + useAgentLaunchPreferences({ + agentStorageKey: AGENT_STORAGE_KEY, + defaultAgent: "claude", + fallbackAgent: "none", + validAgents: ["none", ...selectableAgentIds], + agentsReady: agentPresetsQuery.isFetched, + }); + + const [issueLinkOpen, setIssueLinkOpen] = useState(false); + const [gitHubIssueLinkOpen, setGitHubIssueLinkOpen] = useState(false); + const [prLinkOpen, setPRLinkOpen] = useState(false); + const plusMenuRef = useRef(null); + const submitStartedRef = useRef(false); + const trimmedPrompt = prompt.trim(); + const firstIssueSlug = linkedIssues[0]?.slug ?? null; + + // AI branch name generation (local Electron helper — stays) + const generateBranchNameMutation = + electronTrpc.workspaces.generateBranchName.useMutation(); + + useEffect(() => { + if (isNewWorkspaceModalOpen) { + submitStartedRef.current = false; + } + }, [isNewWorkspaceModalOpen]); + + // ── Branch data via host-service ───────────────────────────────── + const { + data: branchData, + isLoading: isBranchesLoading, + isError: isBranchesError, + } = useBranchContext(projectId, hostTarget); + + const effectiveCompareBaseBranch = + compareBaseBranch || branchData?.defaultBranch || null; + + // Simple branch slug preview (no prefix support in V2) + const branchSlug = branchNameEdited + ? sanitizeBranchNameWithMaxLength(branchName, undefined, { + preserveFirstSegmentCase: true, + }) + : sanitizeBranchNameWithMaxLength(trimmedPrompt); + const branchPreview = branchSlug; + + // Reset compareBaseBranch when project OR host changes + const previousProjectIdRef = useRef(projectId); + const previousHostRef = useRef(JSON.stringify(hostTarget)); + useEffect(() => { + const nextHost = JSON.stringify(hostTarget); + if ( + previousProjectIdRef.current !== projectId || + previousHostRef.current !== nextHost + ) { + previousProjectIdRef.current = projectId; + previousHostRef.current = nextHost; + updateDraft({ compareBaseBranch: null }); + } + }, [projectId, hostTarget, updateDraft]); + + // ── Helpers ────────────────────────────────────────────────────── + const buildLaunchRequest = useCallback( + ( + promptText: string, + files?: ConvertedFile[], + ): AgentLaunchRequest | null => { + return buildPromptAgentLaunchRequest({ + workspaceId: "pending-workspace", + source: "new-workspace", + selectedAgent, + prompt: promptText, + initialFiles: files, + taskSlug: firstIssueSlug || undefined, + configsById: agentConfigsById, + }); + }, + [agentConfigsById, firstIssueSlug, selectedAgent], + ); + + const convertBlobUrlToDataUrl = useCallback( + async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch attachment: ${response.statusText}`); + } + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => + reject(new Error("Failed to read attachment data")); + reader.onabort = () => reject(new Error("Attachment read was aborted")); + reader.readAsDataURL(blob); + }); + }, + [], + ); + + // Resolve host URL once for inline host-service queries (GH issue content) + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + // ── Create workspace ───────────────────────────────────────────── + const handleCreate = useCallback(async () => { + if (!projectId) { + toast.error("Select a project first"); + return; + } + + if (submitStartedRef.current) return; + submitStartedRef.current = true; + + const displayName = + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : trimmedPrompt || "New workspace"; + const willGenerateAIName = + !branchNameEdited && !!trimmedPrompt && !linkedPR; + const pendingWorkspaceId = crypto.randomUUID(); + const detachedFiles = attachments.takeFiles(); + + setPendingWorkspace({ + id: pendingWorkspaceId, + projectId, + name: displayName, + status: willGenerateAIName ? "generating-branch" : "preparing", + }); + closeAndResetDraft(); + + try { + // 1. AI branch name generation (local Electron) + let aiBranchName: string | null = null; + if (willGenerateAIName) { + try { + const AI_GENERATION_TIMEOUT_MS = 30000; + const result = await Promise.race([ + generateBranchNameMutation.mutateAsync({ + prompt: trimmedPrompt, + projectId, + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("AI generation timeout")), + AI_GENERATION_TIMEOUT_MS, + ), + ), + ]); + aiBranchName = result.branchName; + } catch (err) { + console.warn( + "[PromptGroup] AI branch name generation failed, falling back", + err, + ); + } finally { + setPendingWorkspaceStatus(pendingWorkspaceId, "preparing"); + } + } + + // 2. Convert attachment blob URLs to data URLs + let convertedFiles: ConvertedFile[] = []; + if (detachedFiles.length > 0) { + try { + convertedFiles = await Promise.all( + detachedFiles.map(async (file) => ({ + data: await convertBlobUrlToDataUrl(file.url), + mediaType: file.mediaType, + filename: file.filename, + })), + ); + } catch (err) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error( + err instanceof Error + ? err.message + : "Failed to process attachments", + ); + return; + } + } + + // 3. Fetch linked GitHub issue content via host-service + const githubIssues = linkedIssues.filter( + (issue): issue is typeof issue & { number: number } => + issue.source === "github" && typeof issue.number === "number", + ); + if (githubIssues.length > 0 && hostUrl) { + try { + const client = getHostServiceClientByUrl(hostUrl); + const issueContents = await Promise.all( + githubIssues.map(async (issue) => { + try { + const content = + await client.workspaceCreation.getGitHubIssueContent.query({ + projectId, + issueNumber: issue.number, + }); + + const sanitizeText = (str: string) => + str.replace(/[&<>"']/g, (char) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char] || char; + }); + + const sanitizeUrl = (url: string) => { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return "#invalid-url"; + } + return url; + } catch { + return "#invalid-url"; + } + }; + + const MAX_BODY_LENGTH = 50000; + const truncatedBody = + content.body.length > MAX_BODY_LENGTH + ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` + : content.body; + + const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} + +**URL:** ${sanitizeUrl(content.url)} +**State:** ${content.state} +**Author:** ${sanitizeText(content.author || "Unknown")} +**Created:** ${content.createdAt ? new Date(content.createdAt).toLocaleString() : "Unknown"} +**Updated:** ${content.updatedAt ? new Date(content.updatedAt).toLocaleString() : "Unknown"} + +--- + +${sanitizeText(truncatedBody)}`; + + const base64 = btoa( + encodeURIComponent(markdown).replace( + /%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), + ), + ); + + return { + data: `data:text/markdown;base64,${base64}`, + mediaType: "text/markdown", + filename: `github-issue-${content.number}.md`, + }; + } catch (err) { + console.warn( + `Failed to fetch GitHub issue #${issue.number}:`, + err, + ); + return null; + } + }), + ); + + convertedFiles = [ + ...convertedFiles, + ...(issueContents.filter( + (file) => file !== null, + ) as ConvertedFile[]), + ]; + } catch (err) { + console.warn("Failed to fetch GitHub issue contents:", err); + } + } + + // 4. Build launch request (for future agent handoff; not yet sent to host) + try { + buildLaunchRequest( + trimmedPrompt, + convertedFiles.length > 0 ? convertedFiles : undefined, + ); + } catch (error) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error( + error instanceof Error + ? error.message + : "Failed to prepare agent launch", + ); + return; + } + + setPendingWorkspaceStatus(pendingWorkspaceId, "creating"); + + const resolvedBranchName = + (branchNameEdited && branchName.trim() + ? sanitizeBranchNameWithMaxLength(branchName.trim(), undefined, { + preserveCase: true, + }) + : aiBranchName) || undefined; + + // Map linked issues into typed arrays + const internalIssueIds = linkedIssues + .filter((i) => i.source === "internal" && i.taskId) + .map((i) => i.taskId as string); + const githubIssueUrls = linkedIssues + .filter((i) => i.source === "github" && i.url) + .map((i) => i.url as string); + + // 5. Call host-service create via the draft's cached mutation + void runAsyncAction( + createWorkspace({ + projectId, + hostTarget, + source: linkedPR ? "pull-request" : "prompt", + names: { + workspaceName: + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : undefined, + branchName: resolvedBranchName, + }, + composer: { + prompt: trimmedPrompt || undefined, + compareBaseBranch: compareBaseBranch || undefined, + runSetupScript, + }, + linkedContext: { + internalIssueIds: + internalIssueIds.length > 0 ? internalIssueIds : undefined, + githubIssueUrls: + githubIssueUrls.length > 0 ? githubIssueUrls : undefined, + linkedPrUrl: linkedPR?.url, + attachments: convertedFiles.length > 0 ? convertedFiles : undefined, + }, + behavior: { + onExistingWorkspace: "open", + onExistingWorktree: "adopt", + }, + }).then((result) => { + if (result.workspace) { + void navigateToV2Workspace(result.workspace.id, navigate); + } + return result; + }), + { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + { closeAndReset: false }, + ).finally(() => { + clearPendingWorkspace(pendingWorkspaceId); + }); + } finally { + for (const file of detachedFiles) { + if (file.url?.startsWith("blob:")) { + URL.revokeObjectURL(file.url); + } + } + } + }, [ + attachments, + branchName, + branchNameEdited, + buildLaunchRequest, + clearPendingWorkspace, + closeAndResetDraft, + compareBaseBranch, + convertBlobUrlToDataUrl, + createWorkspace, + generateBranchNameMutation, + hostTarget, + hostUrl, + linkedIssues, + linkedPR, + navigate, + projectId, + runAsyncAction, + runSetupScript, + setPendingWorkspace, + setPendingWorkspaceStatus, + trimmedPrompt, + workspaceName, + workspaceNameEdited, + ]); + + const handlePromptSubmit = useCallback(() => { + void handleCreate(); + }, [handleCreate]); + + useEffect(() => { + if (!isNewWorkspaceModalOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleCreate(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isNewWorkspaceModalOpen, handleCreate]); + + const handleCompareBaseBranchSelect = (branchName: string) => { + updateDraft({ compareBaseBranch: branchName }); + }; + + // ── Issue / PR linking ─────────────────────────────────────────── + + const addLinkedIssue = ( + slug: string, + title: string, + taskId: string | undefined, + url?: string, + ) => { + if (linkedIssues.some((issue) => issue.slug === slug)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { slug, title, source: "internal", taskId, url }, + ], + }); + }; + + const addLinkedGitHubIssue = ( + issueNumber: number, + title: string, + url: string, + state: string, + ) => { + const normalizedState: "open" | "closed" = + state.toLowerCase() === "closed" ? "closed" : "open"; + // Use URL as the dedup key since #number isn't unique across repos + if (linkedIssues.some((i) => i.url === url)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { + slug: `#${issueNumber}`, + title, + source: "github" as const, + url, + number: issueNumber, + state: normalizedState, + }, + ], + }); + }; + + const removeLinkedIssue = (slug: string) => { + updateDraft({ + linkedIssues: linkedIssues.filter((issue) => issue.slug !== slug), + }); + }; + + const setLinkedPR = (pr: LinkedPR) => updateDraft({ linkedPR: pr }); + const removeLinkedPR = () => updateDraft({ linkedPR: null }); + + // ── Render ──────────────────────────────────────────────────────── + + return ( +
+
+ + updateDraft({ + workspaceName: e.target.value, + workspaceNameEdited: true, + }) + } + onBlur={() => { + if (!workspaceName.trim()) { + updateDraft({ workspaceName: "", workspaceNameEdited: false }); + } + }} + /> +
+ + updateDraft({ + branchName: e.target.value.replace(/\s+/g, "-"), + branchNameEdited: true, + }) + } + onBlur={() => { + const sanitized = sanitizeBranchNameWithMaxLength( + branchName.trim(), + undefined, + { preserveCase: true }, + ); + if (!sanitized) { + updateDraft({ branchName: "", branchNameEdited: false }); + } else { + updateDraft({ branchName: sanitized }); + } + }} + /> +
+
+ + + {(linkedPR || + linkedIssues.length > 0 || + attachments.files.length > 0) && ( +
+ + {linkedPR && ( + + + + )} + {linkedIssues.map((issue) => ( + + {issue.source === "github" && issue.number != null ? ( + removeLinkedIssue(issue.slug)} + /> + ) : ( + removeLinkedIssue(issue.slug)} + /> + )} + + ))} + + + {(file) => } + +
+ )} + updateDraft({ prompt: e.target.value })} + /> + + + + agents={enabledAgentPresets} + value={selectedAgent} + placeholder="No agent" + onValueChange={setSelectedAgent} + onBeforeConfigureAgents={closeModal} + triggerClassName={`${PILL_BUTTON_CLASS} px-1.5 gap-1 text-foreground w-auto max-w-[160px]`} + iconClassName="size-3 object-contain" + allowNone + noneLabel="No agent" + noneValue="none" + /> + +
+ + requestAnimationFrame(() => setIssueLinkOpen(true)) + } + onOpenGitHubIssue={() => + requestAnimationFrame(() => setGitHubIssueLinkOpen(true)) + } + onOpenPRLink={() => + requestAnimationFrame(() => setPRLinkOpen(true)) + } + /> + + + addLinkedGitHubIssue( + issue.issueNumber, + issue.title, + issue.url, + issue.state, + ) + } + projectId={projectId} + hostTarget={hostTarget} + anchorRef={plusMenuRef} + /> + + { + e.preventDefault(); + void handleCreate(); + }} + > + + +
+
+
+ +
+
+ + + {linkedPR ? ( + + + based off PR #{linkedPR.prNumber} + + ) : ( + + + + )} + +
+
+ updateDraft({ hostTarget: t })} + /> + + {modKey}↵ + +
+
+
+ ); +} + +export function PromptGroup(props: PromptGroupProps) { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx new file mode 100644 index 00000000000..0820c0b943e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -0,0 +1,154 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; +import { env } from "renderer/env.renderer"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +const MAX_RESULTS = 20; + +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export interface SelectedIssue { + issueNumber: number; + title: string; + url: string; + state: string; +} + +interface GitHubIssueLinkCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (issue: SelectedIssue) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; + anchorRef: RefObject; +} + +export function GitHubIssueLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + hostTarget, + anchorRef, +}: GitHubIssueLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const { activeHostUrl } = useLocalHostService(); + + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data, isLoading } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchGitHubIssues", + projectId, + hostUrl, + debouncedQuery, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { issues: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: debouncedQuery.trim() || undefined, + limit: MAX_RESULTS, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const searchResults = data?.issues ?? []; + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (issue: (typeof searchResults)[number]) => { + onSelect({ + issueNumber: issue.issueNumber, + title: issue.title, + url: issue.url, + state: issue.state, + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {searchResults.length === 0 && ( + + {isLoading ? "Loading issues..." : "No open issues found."} + + )} + {searchResults.length > 0 && ( + + {searchResults.map((issue) => ( + handleSelect(issue)} + className="group" + > + + + #{issue.issueNumber} + + + {issue.title} + + + Link ↵ + + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts new file mode 100644 index 00000000000..c7d5f8cdb50 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts @@ -0,0 +1 @@ +export { GitHubIssueLinkCommand } from "./GitHubIssueLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx new file mode 100644 index 00000000000..75ecb4b52d2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx @@ -0,0 +1,59 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; + +interface LinkedGitHubIssuePillProps { + issueNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +// Normalize issue state to valid IssueState type +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export function LinkedGitHubIssuePill({ + issueNumber, + title, + state, + onRemove, +}: LinkedGitHubIssuePillProps) { + return ( +
+
+ + +
+
+ {title} +
+ #{issueNumber} + · + GitHub +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts new file mode 100644 index 00000000000..fe1657259a6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts @@ -0,0 +1 @@ +export { LinkedGitHubIssuePill } from "./LinkedGitHubIssuePill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx new file mode 100644 index 00000000000..9e2c4b35720 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx @@ -0,0 +1,55 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; + +interface LinkedPRPillProps { + prNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +export function LinkedPRPill({ + prNumber, + title, + state, + onRemove, +}: LinkedPRPillProps) { + return ( +
+
+ + +
+
+ {title} +
+ #{prNumber} + · + GitHub +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts new file mode 100644 index 00000000000..1042cfae4d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts @@ -0,0 +1 @@ +export { LinkedPRPill } from "./LinkedPRPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx new file mode 100644 index 00000000000..a5b79ae418e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -0,0 +1,168 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; +import { env } from "renderer/env.renderer"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +export interface SelectedPR { + prNumber: number; + title: string; + url: string; + state: string; +} + +interface PRLinkCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (pr: SelectedPR) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; + anchorRef: RefObject; +} + +function normalizeState(state: string, isDraft: boolean): string { + if (isDraft) return "draft"; + if (state === "OPEN" || state === "open") return "open"; + return state.toLowerCase(); +} + +export function PRLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + hostTarget, + anchorRef, +}: PRLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const { activeHostUrl } = useLocalHostService(); + + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data, isLoading } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchPullRequests", + projectId, + hostUrl, + debouncedQuery, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { pullRequests: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchPullRequests.query({ + projectId, + query: debouncedQuery.trim() || undefined, + limit: 30, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const pullRequests = data?.pullRequests ?? []; + const debouncedTrimmed = debouncedQuery.trim(); + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (pr: (typeof pullRequests)[number]) => { + onSelect({ + prNumber: pr.prNumber, + title: pr.title, + url: pr.url, + state: normalizeState(pr.state, pr.isDraft), + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {pullRequests.length === 0 && ( + + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading pull requests..." + : debouncedTrimmed + ? "No pull requests found." + : "No open pull requests."} + + )} + {pullRequests.length > 0 && ( + + {pullRequests.map((pr) => ( + handleSelect(pr)} + className="group" + > + + + #{pr.prNumber} + + + {pr.title} + + + Link ↵ + + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts new file mode 100644 index 00000000000..ba614340e89 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts @@ -0,0 +1 @@ +export { PRLinkCommand } from "./PRLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx deleted file mode 100644 index 455999262f6..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import Fuse from "fuse.js"; -import { useCallback, useMemo } from "react"; -import { GoArrowUpRight, GoGitBranch, GoGlobe } from "react-icons/go"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface BranchesGroupProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function BranchesGroup({ - projectId, - localProjectId, - hostTarget, -}: BranchesGroupProps) { - const navigate = useNavigate(); - const collections = useCollections(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const hasLocalProject = !!localProjectId; - - const { data: localData, isLoading: isLocalLoading } = - electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const { data: remoteData } = electronTrpc.projects.getBranches.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const data = remoteData ?? localData; - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const defaultBranch = data?.defaultBranch ?? "main"; - - const branches = (data?.branches ?? []).sort((a, b) => { - if (a.name === defaultBranch) return -1; - if (b.name === defaultBranch) return 1; - if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - const branchRows = useMemo(() => { - return branches.map((branch) => ({ - branch, - existingWorkspaceId: workspaceByBranch.get(branch.name), - })); - }, [branches, workspaceByBranch]); - - const debouncedQuery = useDebouncedValue(draft.branchesQuery, 150); - - const branchFuse = useMemo( - () => - new Fuse(branchRows, { - keys: ["branch.name"], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - }), - [branchRows], - ); - - const visibleBranchRows = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return branchRows.slice(0, 100); - } - return branchFuse - .search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, branchRows, branchFuse]); - - const handleCreate = useCallback( - (branchName: string) => { - if (!projectId) return; - void runAsyncAction( - createWorkspace({ - projectId, - name: branchName, - branch: branchName, - hostTarget, - }), - { - loading: "Creating workspace from branch...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }, - [createWorkspace, hostTarget, projectId, runAsyncAction], - ); - - const handleOpen = useCallback( - (workspaceId: string) => { - closeAndResetDraft(); - navigateToV2Workspace(workspaceId, navigate); - }, - [closeAndResetDraft, navigate], - ); - - const handleBranchAction = useCallback( - (branchName: string) => { - const existingId = workspaceByBranch.get(branchName); - if (existingId) { - handleOpen(existingId); - return; - } - handleCreate(branchName); - }, - [handleCreate, handleOpen, workspaceByBranch], - ); - - if (!projectId) { - return ( - - Select a project to view branches. - - ); - } - - if (!hasLocalProject) { - return ( - - No local repository linked to this project. - - ); - } - - if (isLocalLoading) { - return ( - - Loading branches... - - ); - } - - return ( - - No branches found. - {visibleBranchRows.map(({ branch, existingWorkspaceId }) => { - const buttonLabel = existingWorkspaceId ? "Open" : "Create"; - return ( - handleBranchAction(branch.name)} - className="group h-12" - > - {existingWorkspaceId ? ( - - ) : branch.isLocal ? ( - - ) : ( - - )} - {branch.name} - - - ); - })} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts deleted file mode 100644 index 75953e3d249..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BranchesGroup } from "./BranchesGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx deleted file mode 100644 index cd712d1bbd6..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { DevicePicker } from "../DevicePicker"; -import { ProjectSelector } from "../ProjectSelector"; - -interface DashboardNewWorkspaceFormHeaderProps { - activeTab: DashboardNewWorkspaceTab; - hostTarget: WorkspaceHostTarget; - selectedProjectId: string | null; - onSelectTab: (tab: DashboardNewWorkspaceTab) => void; - onSelectHostTarget: (hostTarget: WorkspaceHostTarget) => void; - onSelectProject: (projectId: string | null) => void; -} - -export function DashboardNewWorkspaceFormHeader({ - activeTab, - hostTarget, - selectedProjectId, - onSelectTab, - onSelectHostTarget, - onSelectProject, -}: DashboardNewWorkspaceFormHeaderProps) { - return ( -
- - onSelectTab(value as DashboardNewWorkspaceTab) - } - > - - Prompt - Issues - Pull requests - Branches - - -
- -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts deleted file mode 100644 index f4469410524..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceFormHeader } from "./DashboardNewWorkspaceFormHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx deleted file mode 100644 index aa01a2dd257..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Command, CommandInput, CommandList } from "@superset/ui/command"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { BranchesGroup } from "../BranchesGroup"; -import { IssuesGroup } from "../IssuesGroup"; -import { PullRequestsGroup } from "../PullRequestsGroup"; - -const COMMAND_CLASS_NAME = - "[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 flex h-full w-full flex-1 flex-col overflow-hidden rounded-none"; - -interface DashboardNewWorkspaceListTabContentProps { - activeTab: Exclude; - projectId: string | null; - githubRepositoryId: string | null; - hostTarget: WorkspaceHostTarget; - localProjectId: string | null; - query: string; - onQueryChange: (value: string) => void; -} - -export function DashboardNewWorkspaceListTabContent({ - activeTab, - projectId, - githubRepositoryId, - hostTarget, - localProjectId, - query, - onQueryChange, -}: DashboardNewWorkspaceListTabContentProps) { - return ( - - - - - {activeTab === "pull-requests" && ( - - )} - {activeTab === "branches" && ( - - )} - {activeTab === "issues" && ( - - )} - - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts deleted file mode 100644 index af9feb38a8c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceListTabContent } from "./DashboardNewWorkspaceListTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx deleted file mode 100644 index 801c7311f06..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { PromptGroup } from "../PromptGroup"; - -interface DashboardNewWorkspacePromptTabContentProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function DashboardNewWorkspacePromptTabContent({ - projectId, - localProjectId, - hostTarget, -}: DashboardNewWorkspacePromptTabContentProps) { - return ( -
- -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts deleted file mode 100644 index 0dd4c4cbf1a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspacePromptTabContent } from "./DashboardNewWorkspacePromptTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx deleted file mode 100644 index d31ef1e646c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Avatar } from "@superset/ui/atoms/Avatar"; -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { toast } from "@superset/ui/sonner"; -import { eq, isNull } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { GoArrowUpRight } from "react-icons/go"; -import { HiOutlineUserCircle } from "react-icons/hi2"; -import { SiLinear } from "react-icons/si"; -import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { getSlugColumnWidth } from "renderer/lib/slug-width"; -import { - StatusIcon, - type StatusType, -} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; -import { useHybridSearch } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch"; -import { compareTasks } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/utils/sorting"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface IssuesGroupProps { - projectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function IssuesGroup({ projectId, hostTarget }: IssuesGroupProps) { - const collections = useCollections(); - const navigate = useNavigate(); - const { gateFeature } = usePaywall(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const { data: integrations } = useLiveQuery( - (q) => - q - .from({ - integrationConnections: collections.integrationConnections, - }) - .select(({ integrationConnections }) => ({ - ...integrationConnections, - })), - [collections], - ); - - const isLinearConnected = - integrations?.some((i) => i.provider === "linear") ?? false; - - const { data } = useLiveQuery( - (q) => - q - .from({ tasks: collections.tasks }) - .innerJoin({ status: collections.taskStatuses }, ({ tasks, status }) => - eq(tasks.statusId, status.id), - ) - .leftJoin({ assignee: collections.users }, ({ tasks, assignee }) => - eq(tasks.assigneeId, assignee.id), - ) - .select(({ tasks, status, assignee }) => ({ - ...tasks, - status, - assignee: assignee ?? null, - })) - .where(({ tasks }) => isNull(tasks.deletedAt)), - [collections], - ); - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const tasks = useMemo(() => data ?? [], [data]); - const sortedTasks = useMemo(() => [...tasks].sort(compareTasks), [tasks]); - - const debouncedQuery = useDebouncedValue(draft.issuesQuery, 150); - const { search } = useHybridSearch(sortedTasks); - - const visibleTasks = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return sortedTasks.slice(0, 100); - } - return search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, sortedTasks, search]); - - const slugWidth = useMemo( - () => getSlugColumnWidth(visibleTasks.map((t) => t.slug)), - [visibleTasks], - ); - - if (!isLinearConnected) { - return ( -
- -
-

Connect Linear

-

- Sync issues from Linear to create workspaces -

-
- -
- ); - } - - return ( - - No issues found. - {visibleTasks.map((task) => ( - { - if (!projectId) { - toast.error("Select a project first"); - return; - } - const existingId = workspaceByBranch.get(task.slug.toLowerCase()); - if (existingId) { - closeAndResetDraft(); - navigateToV2Workspace(existingId, navigate); - return; - } - void runAsyncAction( - createWorkspace({ - projectId, - name: task.title, - branch: task.slug.toLowerCase(), - hostTarget, - }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error - ? err.message - : "Failed to create workspace", - }, - ); - }} - className="group h-12" - > - {workspaceByBranch.has(task.slug.toLowerCase()) ? ( - - ) : ( - - )} - - {task.slug} - - {task.title} - - {task.assignee ? ( - - ) : ( - - )} - - - {workspaceByBranch.has(task.slug.toLowerCase()) ? "Open" : "Create"}{" "} - ↵ - - - ))} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts deleted file mode 100644 index c0762c8495d..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IssuesGroup } from "./IssuesGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx deleted file mode 100644 index 2ed6690d260..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@superset/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useMemo, useState } from "react"; -import { FaGithub } from "react-icons/fa"; -import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; -import { env } from "renderer/env.renderer"; -import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; - -interface ProjectSelectorProps { - selectedProjectId: string | null; - onSelectProject: (projectId: string) => void; -} - -export function ProjectSelector({ - selectedProjectId, - onSelectProject, -}: ProjectSelectorProps) { - const [open, setOpen] = useState(false); - const collections = useCollections(); - - const { data: v2Projects } = useLiveQuery( - (q) => - q - .from({ projects: collections.v2Projects }) - .select(({ projects }) => ({ ...projects })), - [collections], - ); - - const { data: githubRepositories } = useLiveQuery( - (q) => - q.from({ repos: collections.githubRepositories }).select(({ repos }) => ({ - id: repos.id, - owner: repos.owner, - })), - [collections], - ); - - const projects = useMemo(() => { - const ownerByRepoId = new Map( - (githubRepositories ?? []).map((repo) => [repo.id, repo.owner]), - ); - - return (v2Projects ?? []).map((project) => ({ - id: project.id, - name: project.name, - owner: ownerByRepoId.get(project.githubRepositoryId) ?? null, - })); - }, [githubRepositories, v2Projects]); - - const selectedProject = projects.find((p) => p.id === selectedProjectId); - - return ( - - - - - - - - - No projects found. - - {projects.map((project) => ( - { - onSelectProject(project.id); - setOpen(false); - }} - > - -
- {project.name} - {project.owner ? ( - - {project.owner} - - ) : null} -
- {project.id === selectedProjectId && ( - - )} -
- ))} -
-
- -
- -
-
-
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts deleted file mode 100644 index a524b03c166..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectSelector } from "./ProjectSelector"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx deleted file mode 100644 index 40d1d127402..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Kbd, KbdGroup } from "@superset/ui/kbd"; -import { toast } from "@superset/ui/sonner"; -import { Textarea } from "@superset/ui/textarea"; -import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { PLATFORM } from "renderer/hotkeys"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { resolveEffectiveWorkspaceBaseBranch } from "renderer/lib/workspaceBaseBranch"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { - resolveBranchPrefix, - sanitizeBranchNameWithMaxLength, -} from "shared/utils/branch"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; -import { PromptGroupAdvancedOptions } from "./components/PromptGroupAdvancedOptions"; - -interface PromptGroupProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function PromptGroup({ - projectId, - localProjectId, - hostTarget, -}: PromptGroupProps) { - const navigate = useNavigate(); - const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; - const textareaRef = useRef(null); - const { closeModal, draft, runAsyncAction, updateDraft } = - useDashboardNewWorkspaceDraft(); - const [compareBaseBranchOpen, setCompareBaseBranchOpen] = useState(false); - const { - compareBaseBranch, - branchName, - branchNameEdited, - branchSearch, - prompt, - showAdvanced, - } = draft; - const { createWorkspace, isPending } = useCreateDashboardWorkspace(); - - const trimmedPrompt = prompt.trim(); - - const hasLocalProject = !!localProjectId; - - const { data: project } = electronTrpc.projects.get.useQuery( - { id: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const { - data: localBranchData, - isLoading: isBranchesLoading, - isError: isBranchesError, - } = electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const { data: remoteBranchData } = electronTrpc.projects.getBranches.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const branchData = remoteBranchData ?? localBranchData; - const { data: gitAuthor } = electronTrpc.projects.getGitAuthor.useQuery( - { id: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const { data: globalBranchPrefix } = - electronTrpc.settings.getBranchPrefix.useQuery(); - const { data: gitInfo } = electronTrpc.settings.getGitInfo.useQuery(); - - const resolvedPrefix = useMemo(() => { - const projectOverrides = project?.branchPrefixMode != null; - return resolveBranchPrefix({ - mode: projectOverrides - ? project?.branchPrefixMode - : (globalBranchPrefix?.mode ?? "none"), - customPrefix: projectOverrides - ? project?.branchPrefixCustom - : globalBranchPrefix?.customPrefix, - authorPrefix: gitAuthor?.prefix, - githubUsername: gitInfo?.githubUsername, - }); - }, [project, globalBranchPrefix, gitAuthor, gitInfo]); - - const filteredBranches = useMemo(() => { - if (!branchData?.branches) return []; - if (!branchSearch) return branchData.branches; - const searchLower = branchSearch.toLowerCase(); - return branchData.branches.filter((branch) => - branch.name.toLowerCase().includes(searchLower), - ); - }, [branchData?.branches, branchSearch]); - - const effectiveCompareBaseBranch = resolveEffectiveWorkspaceBaseBranch({ - explicitBaseBranch: compareBaseBranch, - workspaceBaseBranch: project?.workspaceBaseBranch, - defaultBranch: branchData?.defaultBranch, - branches: branchData?.branches, - }); - - const branchSlug = branchNameEdited - ? sanitizeBranchNameWithMaxLength(branchName, undefined, { - preserveFirstSegmentCase: true, - }) - : sanitizeBranchNameWithMaxLength(trimmedPrompt); - - const applyPrefix = !branchNameEdited; - - const branchPreview = - branchSlug && applyPrefix && resolvedPrefix - ? sanitizeBranchNameWithMaxLength(`${resolvedPrefix}/${branchSlug}`) - : branchSlug; - - const previousProjectIdRef = useRef(localProjectId); - - useEffect(() => { - if (previousProjectIdRef.current === localProjectId) { - return; - } - previousProjectIdRef.current = localProjectId; - updateDraft({ - compareBaseBranch: null, - branchSearch: "", - }); - setCompareBaseBranchOpen(false); - }, [localProjectId, updateDraft]); - - const handleCreate = () => { - if (!projectId) { - toast.error("Select a project first"); - return; - } - const name = branchSlug || trimmedPrompt || "workspace"; - const branch = branchPreview || "workspace"; - void runAsyncAction( - createWorkspace({ - projectId, - name, - branch, - hostTarget, - }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }; - - const handleBranchNameChange = (value: string) => { - updateDraft({ - branchName: value, - branchNameEdited: true, - }); - }; - - const handleBranchNameBlur = () => { - if (!branchName.trim()) { - updateDraft({ - branchName: "", - branchNameEdited: false, - }); - } - }; - - const handleCompareBaseBranchSelect = (selectedBaseBranch: string) => { - updateDraft({ - compareBaseBranch: selectedBaseBranch, - branchSearch: "", - }); - setCompareBaseBranchOpen(false); - }; - - return ( -
-