diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 35e600a00c7..d6912432492 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -40,6 +40,7 @@ import { sanitizeAuthorPrefix, } from "../workspaces/utils/git"; import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client"; +import { execWithShellEnv } from "../workspaces/utils/shell-env"; import { getDefaultProjectColor } from "./utils/colors"; import { discoverAndSaveProjectIcon } from "./utils/favicon-discovery"; import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; @@ -296,6 +297,66 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .all(); }), + listPullRequests: publicProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) return []; + + try { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + "--state", + "open", + "--limit", + "30", + "--json", + "number,title,url,state,isDraft", + ], + { cwd: project.mainRepoPath }, + ); + const raw: unknown = JSON.parse(stdout.trim() || "[]"); + if (!Array.isArray(raw)) return []; + return raw + .filter( + ( + item: unknown, + ): item is { + number: number; + title: string; + url: string; + state: string; + isDraft: boolean; + } => + typeof item === "object" && + item !== null && + "number" in item && + "title" in item && + "url" in item, + ) + .map((pr) => ({ + prNumber: pr.number, + title: pr.title, + url: pr.url, + state: pr.isDraft + ? "draft" + : pr.state === "OPEN" + ? "open" + : pr.state.toLowerCase(), + })); + } catch (err) { + console.warn("[listPullRequests] Failed to list PRs:", err); + return []; + } + }), + selectDirectory: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 099089e4002..0fdb7582e76 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -45,9 +45,7 @@ export function NewWorkspaceModal() { !open && closeModal()}> New Workspace - - Create a new workspace from a PR, branch, issue, or prompt. - + Create a new workspace ( () => ({ draft: { - activeTab: state.activeTab, selectedProjectId: state.selectedProjectId, prompt: state.prompt, - branchName: state.branchName, - branchNameEdited: state.branchNameEdited, baseBranch: state.baseBranch, - showAdvanced: state.showAdvanced, runSetupScript: state.runSetupScript, - branchSearch: state.branchSearch, - issuesQuery: state.issuesQuery, - pullRequestsQuery: state.pullRequestsQuery, - branchesQuery: state.branchesQuery, + workspaceName: state.workspaceName, + workspaceNameEdited: state.workspaceNameEdited, + branchName: state.branchName, + branchNameEdited: state.branchNameEdited, + linkedIssues: state.linkedIssues, + linkedPR: state.linkedPR, }, draftVersion: state.draftVersion, closeModal: onClose, diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/BranchesGroup.test.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/BranchesGroup.test.ts deleted file mode 100644 index 19b726f04e1..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/BranchesGroup.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildCreateWorkspaceFromBranchInput } from "./buildCreateWorkspaceFromBranchInput"; - -describe("buildCreateWorkspaceFromBranchInput", () => { - test("creates a worktree workspace request for an existing branch", () => { - expect( - buildCreateWorkspaceFromBranchInput( - "project-123", - "feature/fix-worktree-regression", - ), - ).toEqual({ - projectId: "project-123", - branchName: "feature/fix-worktree-regression", - useExistingBranch: true, - applyPrefix: false, - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/BranchesGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/BranchesGroup.tsx deleted file mode 100644 index 65bc95e5d0b..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/BranchesGroup.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { toast } from "@superset/ui/sonner"; -import { cn } from "@superset/ui/utils"; -import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { GoArrowUpRight, GoGitBranch, GoGlobe } from "react-icons/go"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useImportAllWorktrees } from "renderer/react-query/workspaces"; -import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useHotkeysStore } from "renderer/stores/hotkeys/store"; -import { useNewWorkspaceModalDraft } from "../../NewWorkspaceModalDraftContext"; -import { buildCreateWorkspaceFromBranchInput } from "./buildCreateWorkspaceFromBranchInput"; -import { resolveBranchAction } from "./resolveBranchAction"; - -interface BranchesGroupProps { - projectId: string | null; -} - -const PAGE_SIZE = 50; -const BRANCH_SEARCH_LIMIT = 5000; - -type BranchFilterMode = "all" | "worktrees"; - -/** Displays a searchable, infinitely-scrollable list of git branches for creating or opening workspaces. */ -export function BranchesGroup({ projectId }: BranchesGroupProps) { - const platform = useHotkeysStore((state) => state.platform); - const modKey = platform === "darwin" ? "⌘" : "Ctrl"; - const navigate = useNavigate(); - const importAllWorktrees = useImportAllWorktrees(); - const { - createWorkspace, - openTrackedWorktree, - openExternalWorktree, - draft, - closeAndResetDraft, - runAsyncAction, - } = useNewWorkspaceModalDraft(); - const [filterMode, setFilterMode] = useState("all"); - const [displayLimit, setDisplayLimit] = useState(PAGE_SIZE); - - // Reset pagination when search query changes - const [prevQuery, setPrevQuery] = useState(draft.branchesQuery); - if (prevQuery !== draft.branchesQuery) { - setPrevQuery(draft.branchesQuery); - setDisplayLimit(PAGE_SIZE); - } - - const { - data: searchData, - isLoading: isSearchLoading, - isError: isSearchError, - } = electronTrpc.projects.searchBranches.useQuery( - { - projectId: projectId ?? "", - search: "", - limit: BRANCH_SEARCH_LIMIT, - offset: 0, - }, - { - enabled: !!projectId && filterMode === "all", - placeholderData: (previous) => previous, - retry: false, - }, - ); - - // Fallback: use getBranchesLocal when searchBranches fails - const { data: localBranchData, isLoading: isLocalLoading } = - electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: projectId ?? "" }, - { enabled: !!projectId && isSearchError }, - ); - - // Background: fetch from remote to refresh local refs, then invalidate search cache - const utils = electronTrpc.useUtils(); - const { data: remoteBranchData } = electronTrpc.projects.getBranches.useQuery( - { projectId: projectId ?? "" }, - { enabled: !!projectId }, - ); - const prevRemoteDataRef = useRef(remoteBranchData); - useEffect(() => { - if (remoteBranchData && remoteBranchData !== prevRemoteDataRef.current) { - prevRemoteDataRef.current = remoteBranchData; - void utils.projects.searchBranches.invalidate(); - } - }, [remoteBranchData, utils]); - - // Combine: prefer searchBranches, fall back to getBranchesLocal; always filter client-side - const allBranchData = useMemo(() => { - const source = searchData && !isSearchError ? searchData : localBranchData; - if (!source) return undefined; - const query = draft.branchesQuery.trim().toLowerCase(); - const filtered = query - ? source.branches.filter((b) => b.name.toLowerCase().includes(query)) - : source.branches; - return { - branches: filtered, - defaultBranch: source.defaultBranch, - totalCount: filtered.length, - }; - }, [searchData, isSearchError, localBranchData, draft.branchesQuery]); - - const effectiveData = useMemo( - () => - allBranchData - ? { - ...allBranchData, - branches: allBranchData.branches.slice(0, displayLimit), - hasMore: allBranchData.branches.length > displayLimit, - } - : undefined, - [allBranchData, displayLimit], - ); - - const { data: allWorkspaces = [] } = - electronTrpc.workspaces.getAll.useQuery(); - const { data: trackedWorktrees = [] } = - electronTrpc.workspaces.getWorktreesByProject.useQuery( - { projectId: projectId ?? "" }, - { enabled: !!projectId }, - ); - const { - data: externalWorktrees = [], - isLoading: isExternalWorktreesLoading, - } = electronTrpc.workspaces.getExternalWorktrees.useQuery( - { projectId: projectId ?? "" }, - { enabled: !!projectId }, - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of allWorkspaces) { - if (w.projectId === projectId) { - map.set(w.branch, w.id); - } - } - return map; - }, [allWorkspaces, projectId]); - - const trackedWorktreeByBranch = useMemo(() => { - const map = new Map< - string, - { worktreeId: string; existsOnDisk: boolean } - >(); - for (const worktree of trackedWorktrees) { - if (worktree.hasActiveWorkspace) continue; - map.set(worktree.branch, { - worktreeId: worktree.id, - existsOnDisk: worktree.existsOnDisk, - }); - } - return map; - }, [trackedWorktrees]); - - const externalWorktreeByBranch = useMemo(() => { - const map = new Map(); - for (const worktree of externalWorktrees) { - map.set(worktree.branch, { path: worktree.path }); - } - return map; - }, [externalWorktrees]); - - // For "all" mode, use server-side searched data - const serverBranchRows = useMemo(() => { - if (!effectiveData) return []; - return effectiveData.branches.map((branch) => { - const action = resolveBranchAction({ - branchName: branch.name, - workspaceByBranch, - trackedWorktreeByBranch, - externalWorktreeByBranch, - }); - return { branch, action }; - }); - }, [ - effectiveData, - workspaceByBranch, - trackedWorktreeByBranch, - externalWorktreeByBranch, - ]); - - // For "worktrees" mode, keep client-side (small dataset). - // Uses allBranchData (unpaginated) so metadata is available for all worktree branches. - const worktreeBranchRows = useMemo(() => { - const query = draft.branchesQuery.trim().toLowerCase(); - return externalWorktrees - .filter((wt) => !query || wt.branch.toLowerCase().includes(query)) - .map((worktree) => { - const branch = allBranchData?.branches.find( - (b) => b.name === worktree.branch, - ) ?? { - name: worktree.branch, - lastCommitDate: 0, - isLocal: true, - isRemote: false, - }; - const action = resolveBranchAction({ - branchName: worktree.branch, - workspaceByBranch, - trackedWorktreeByBranch, - externalWorktreeByBranch, - }); - return { branch, action }; - }) - .sort((a, b) => { - const defaultBranch = allBranchData?.defaultBranch ?? "main"; - if (a.branch.name === defaultBranch) return -1; - if (b.branch.name === defaultBranch) return 1; - if (a.branch.isLocal !== b.branch.isLocal) { - return a.branch.isLocal ? -1 : 1; - } - return a.branch.name.localeCompare(b.branch.name); - }); - }, [ - externalWorktrees, - allBranchData, - workspaceByBranch, - trackedWorktreeByBranch, - externalWorktreeByBranch, - draft.branchesQuery, - ]); - - const visibleBranchRows = - filterMode === "worktrees" ? worktreeBranchRows : serverBranchRows; - - // Infinite scroll: load more when sentinel is visible - const sentinelRef = useRef(null); - const hasMore = filterMode === "all" && (effectiveData?.hasMore ?? false); - useEffect(() => { - const el = sentinelRef.current; - if (!el || !hasMore) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - setDisplayLimit((prev) => prev + PAGE_SIZE); - } - }, - { threshold: 0 }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [hasMore]); - - const handleCreate = useCallback( - (branchName: string) => { - if (!projectId) return; - void runAsyncAction( - createWorkspace.mutateAsync( - buildCreateWorkspaceFromBranchInput(projectId, branchName), - ), - { - loading: "Creating workspace from branch...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }, - [createWorkspace, projectId, runAsyncAction], - ); - - const handleOpen = useCallback( - (workspaceId: string) => { - closeAndResetDraft(); - navigateToWorkspace(workspaceId, navigate); - }, - [closeAndResetDraft, navigate], - ); - - const handleOpenTrackedWorktree = useCallback( - (worktreeId: string, branchName: string) => { - void runAsyncAction(openTrackedWorktree.mutateAsync({ worktreeId }), { - loading: "Importing worktree...", - success: `Imported ${branchName}`, - error: (err) => - err instanceof Error ? err.message : "Failed to import worktree", - }); - }, - [openTrackedWorktree, runAsyncAction], - ); - - const handleImportExternalWorktree = useCallback( - (branchName: string, worktreePath: string) => { - if (!projectId) return; - void runAsyncAction( - openExternalWorktree.mutateAsync({ - projectId, - worktreePath, - branch: branchName, - }), - { - loading: "Importing worktree...", - success: `Imported ${branchName}`, - error: (err) => - err instanceof Error ? err.message : "Failed to import worktree", - }, - ); - }, - [openExternalWorktree, projectId, runAsyncAction], - ); - - const handleBranchAction = useCallback( - (branchName: string) => { - const action = resolveBranchAction({ - branchName, - workspaceByBranch, - trackedWorktreeByBranch, - externalWorktreeByBranch, - }); - - if (action.kind === "open-workspace") { - handleOpen(action.workspaceId); - return; - } - - if (action.kind === "open-worktree") { - handleOpenTrackedWorktree(action.worktreeId, branchName); - return; - } - - if (action.kind === "import-worktree") { - handleImportExternalWorktree(branchName, action.worktreePath); - return; - } - - handleCreate(branchName); - }, - [ - externalWorktreeByBranch, - handleCreate, - handleImportExternalWorktree, - handleOpen, - handleOpenTrackedWorktree, - trackedWorktreeByBranch, - workspaceByBranch, - ], - ); - - const handleImportAll = useCallback(async () => { - if (!projectId) return; - - try { - const result = await importAllWorktrees.mutateAsync({ projectId }); - toast.success( - `Imported ${result.imported} workspace${result.imported === 1 ? "" : "s"}`, - ); - } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to import worktrees", - ); - } - }, [importAllWorktrees, projectId]); - - if (!projectId) { - return ( - - Select a project to view branches. - - ); - } - - if ( - !allBranchData && - (isSearchLoading || (isSearchError && isLocalLoading)) && - filterMode === "all" - ) { - return ( - - Loading branches... - - ); - } - - return ( - <> -
-
- {(["all", "worktrees"] as const).map((value) => { - const count = - value === "all" - ? (effectiveData?.totalCount ?? 0) - : worktreeBranchRows.length; - return ( - - ); - })} -
- {filterMode === "worktrees" && ( - - )} -
- - - {filterMode === "worktrees" && isExternalWorktreesLoading - ? "Loading worktree branches..." - : filterMode === "worktrees" - ? "No worktree branches found." - : "No branches found."} - - {visibleBranchRows.map(({ branch, action }) => { - const existingWorkspaceId = - action.kind === "open-workspace" ? action.workspaceId : undefined; - const isImportAction = - action.kind === "open-worktree" || - action.kind === "import-worktree"; - const buttonLabel = - action.kind === "open-workspace" - ? "Open" - : isImportAction - ? "Import" - : "Create"; - return ( - handleBranchAction(branch.name)} - className="group h-12" - > - {existingWorkspaceId ? ( - - ) : branch.isLocal ? ( - - ) : ( - - )} - {branch.name} - {existingWorkspaceId ? ( - - - - - ) : ( - - )} - - ); - })} - {hasMore && ( -
- )} - - - ); -} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/buildCreateWorkspaceFromBranchInput.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/buildCreateWorkspaceFromBranchInput.ts deleted file mode 100644 index dd75ddb2955..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/buildCreateWorkspaceFromBranchInput.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function buildCreateWorkspaceFromBranchInput( - projectId: string, - branchName: string, -) { - return { - projectId, - branchName, - useExistingBranch: true, - applyPrefix: false, - } as const; -} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/index.ts deleted file mode 100644 index 75953e3d249..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BranchesGroup } from "./BranchesGroup"; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/resolveBranchAction.test.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/resolveBranchAction.test.ts deleted file mode 100644 index 7a0c332d691..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/resolveBranchAction.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveBranchAction } from "./resolveBranchAction"; - -describe("resolveBranchAction", () => { - test("opens an existing workspace before any worktree handling", () => { - const resolved = resolveBranchAction({ - branchName: "feature/existing", - workspaceByBranch: new Map([["feature/existing", "ws_123"]]), - trackedWorktreeByBranch: new Map([ - ["feature/existing", { worktreeId: "wt_123", existsOnDisk: true }], - ]), - externalWorktreeByBranch: new Map([ - ["feature/existing", { path: "/tmp/feature-existing" }], - ]), - }); - - expect(resolved).toEqual({ - kind: "open-workspace", - workspaceId: "ws_123", - }); - }); - - test("reopens a tracked worktree when no workspace exists", () => { - const resolved = resolveBranchAction({ - branchName: "feature/tracked", - workspaceByBranch: new Map(), - trackedWorktreeByBranch: new Map([ - ["feature/tracked", { worktreeId: "wt_456", existsOnDisk: true }], - ]), - externalWorktreeByBranch: new Map([ - ["feature/tracked", { path: "/tmp/feature-tracked" }], - ]), - }); - - expect(resolved).toEqual({ - kind: "open-worktree", - worktreeId: "wt_456", - }); - }); - - test("imports an external worktree when it is not tracked yet", () => { - const resolved = resolveBranchAction({ - branchName: "feature/external", - workspaceByBranch: new Map(), - trackedWorktreeByBranch: new Map(), - externalWorktreeByBranch: new Map([ - ["feature/external", { path: "/tmp/feature-external" }], - ]), - }); - - expect(resolved).toEqual({ - kind: "import-worktree", - worktreePath: "/tmp/feature-external", - }); - }); - - test("falls back to creating a branch workspace", () => { - const resolved = resolveBranchAction({ - branchName: "feature/new", - workspaceByBranch: new Map(), - trackedWorktreeByBranch: new Map(), - externalWorktreeByBranch: new Map(), - }); - - expect(resolved).toEqual({ - kind: "create-workspace", - }); - }); - - test("ignores tracked worktrees that no longer exist on disk", () => { - const resolved = resolveBranchAction({ - branchName: "feature/missing", - workspaceByBranch: new Map(), - trackedWorktreeByBranch: new Map([ - ["feature/missing", { worktreeId: "wt_missing", existsOnDisk: false }], - ]), - externalWorktreeByBranch: new Map(), - }); - - expect(resolved).toEqual({ - kind: "create-workspace", - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/resolveBranchAction.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/resolveBranchAction.ts deleted file mode 100644 index fbc30a6129e..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/BranchesGroup/resolveBranchAction.ts +++ /dev/null @@ -1,61 +0,0 @@ -export type ResolvedBranchAction = - | { - kind: "open-workspace"; - workspaceId: string; - } - | { - kind: "open-worktree"; - worktreeId: string; - } - | { - kind: "import-worktree"; - worktreePath: string; - } - | { - kind: "create-workspace"; - }; - -interface ResolveBranchActionInput { - branchName: string; - workspaceByBranch: ReadonlyMap; - trackedWorktreeByBranch: ReadonlyMap< - string, - { worktreeId: string; existsOnDisk: boolean } - >; - externalWorktreeByBranch: ReadonlyMap; -} - -export function resolveBranchAction({ - branchName, - workspaceByBranch, - trackedWorktreeByBranch, - externalWorktreeByBranch, -}: ResolveBranchActionInput): ResolvedBranchAction { - const workspaceId = workspaceByBranch.get(branchName); - if (workspaceId) { - return { - kind: "open-workspace", - workspaceId, - }; - } - - const trackedWorktree = trackedWorktreeByBranch.get(branchName); - if (trackedWorktree?.existsOnDisk) { - return { - kind: "open-worktree", - worktreeId: trackedWorktree.worktreeId, - }; - } - - const externalWorktree = externalWorktreeByBranch.get(branchName); - if (externalWorktree) { - return { - kind: "import-worktree", - worktreePath: externalWorktree.path, - }; - } - - return { - kind: "create-workspace", - }; -} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/IssuesGroup/IssuesGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/IssuesGroup/IssuesGroup.tsx deleted file mode 100644 index 999a1dfd209..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/IssuesGroup/IssuesGroup.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { - buildTaskLaunchRequest, - STARTABLE_AGENT_TYPES, - type StartableAgentType, -} from "@superset/shared/agent-launch"; -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 { useEffect, useMemo, useRef, useState } 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 { electronTrpc } from "renderer/lib/electron-trpc"; -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 { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useNewWorkspaceModalDraft } from "../../NewWorkspaceModalDraftContext"; - -const PAGE_SIZE = 50; - -interface IssuesGroupProps { - projectId: string | null; -} - -export function IssuesGroup({ projectId }: IssuesGroupProps) { - const collections = useCollections(); - const navigate = useNavigate(); - const { gateFeature } = usePaywall(); - const { createWorkspace, draft, closeAndResetDraft, runAsyncAction } = - useNewWorkspaceModalDraft(); - - 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], - ); - - const { data: allWorkspaces = [] } = - electronTrpc.workspaces.getAll.useQuery(); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of allWorkspaces) { - if (w.projectId === projectId) { - map.set(w.branch, w.id); - } - } - return map; - }, [allWorkspaces, projectId]); - - const tasks = useMemo(() => data ?? [], [data]); - const sortedTasks = useMemo(() => [...tasks].sort(compareTasks), [tasks]); - - const [displayLimit, setDisplayLimit] = useState(PAGE_SIZE); - const debouncedQuery = useDebouncedValue(draft.issuesQuery, 150); - const { search } = useHybridSearch(sortedTasks); - - // Reset pagination when search query changes - const [prevQuery, setPrevQuery] = useState(debouncedQuery); - if (prevQuery !== debouncedQuery) { - setPrevQuery(debouncedQuery); - setDisplayLimit(PAGE_SIZE); - } - - const allMatchingTasks = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return sortedTasks; - } - return search(query).map((result) => result.item); - }, [debouncedQuery, sortedTasks, search]); - - const visibleTasks = useMemo( - () => allMatchingTasks.slice(0, displayLimit), - [allMatchingTasks, displayLimit], - ); - const hasMore = allMatchingTasks.length > displayLimit; - - // Infinite scroll: load more when sentinel is visible - const sentinelRef = useRef(null); - useEffect(() => { - const el = sentinelRef.current; - if (!el || !hasMore) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - setDisplayLimit((prev) => prev + PAGE_SIZE); - } - }, - { threshold: 0 }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [hasMore]); - - 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(); - navigateToWorkspace(existingId, navigate); - return; - } - const storedAgent = localStorage.getItem("lastSelectedAgent"); - const agentType: StartableAgentType = - storedAgent && - (STARTABLE_AGENT_TYPES as readonly string[]).includes(storedAgent) - ? (storedAgent as StartableAgentType) - : "claude"; - const autoExecute = - localStorage.getItem("agentAutoRun") !== "false"; - const launchRequest = buildTaskLaunchRequest({ - task: { - id: task.id, - slug: task.slug, - title: task.title, - description: task.description, - priority: task.priority, - statusName: task.status.name, - labels: task.labels, - }, - workspaceId: "pending-workspace", - agentType, - source: "new-workspace", - autoExecute, - }); - void runAsyncAction( - createWorkspace.mutateAsyncWithPendingSetup( - { - projectId, - name: task.title, - branchName: task.slug.toLowerCase(), - }, - { agentLaunchRequest: launchRequest }, - ), - { - 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"}{" "} - ↵ - - - ))} - {hasMore && ( -
- )} - - ); -} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/IssuesGroup/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/IssuesGroup/index.ts deleted file mode 100644 index c0762c8495d..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/IssuesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IssuesGroup } from "./IssuesGroup"; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx index 4ed14456b28..daceab35aa8 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/NewWorkspaceModalContent/NewWorkspaceModalContent.tsx @@ -1,19 +1,7 @@ -import { Command, CommandInput, CommandList } from "@superset/ui/command"; -import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { useEffect, useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { - type NewWorkspaceModalTab, - useNewWorkspaceModalDraft, -} from "../../NewWorkspaceModalDraftContext"; -import { BranchesGroup } from "../BranchesGroup"; -import { IssuesGroup } from "../IssuesGroup"; -import { ProjectSelector } from "../ProjectSelector"; +import { useNewWorkspaceModalDraft } from "../../NewWorkspaceModalDraftContext"; import { PromptGroup } from "../PromptGroup"; -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 NewWorkspaceModalContentProps { isOpen: boolean; @@ -90,95 +78,19 @@ export function NewWorkspaceModalContent({ const selectedProject = recentProjects.find( (project) => project.id === draft.selectedProjectId, ); - const isListTab = draft.activeTab !== "prompt"; - 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: value as NewWorkspaceModalTab }) - } - > - - Prompt - Issues - Pull requests - Branches - - - - Boolean(project.id), - )} - onSelectProject={(selectedProjectId) => - updateDraft({ selectedProjectId }) - } - onImportRepo={onImportRepo} - onNewProject={onNewProject} - /> -
- - {isListTab ? ( - - - - - {draft.activeTab === "pull-requests" && ( - - )} - {draft.activeTab === "branches" && ( - - )} - {draft.activeTab === "issues" && ( - - )} - - - ) : ( -
- -
- )} - +
+ Boolean(project.id))} + onSelectProject={(selectedProjectId) => + updateDraft({ selectedProjectId }) + } + onImportRepo={onImportRepo} + onNewProject={onNewProject} + /> +
); } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ProjectSelector/ProjectSelector.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ProjectSelector/ProjectSelector.tsx deleted file mode 100644 index 5fdfdcba34b..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ProjectSelector/ProjectSelector.tsx +++ /dev/null @@ -1,127 +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 { useState } from "react"; -import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; -import { LuFolderGit, LuFolderOpen } from "react-icons/lu"; -import { ProjectThumbnail } from "renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail"; - -interface ProjectOption { - id: string; - name: string; - color: string; - githubOwner: string | null; - iconUrl: string | null; - hideImage: boolean | null; -} - -interface ProjectSelectorProps { - selectedProjectId: string | null; - selectedProjectName: string | null; - recentProjects: ProjectOption[]; - onSelectProject: (projectId: string) => void; - onImportRepo: () => void; - onNewProject: () => void; -} - -export function ProjectSelector({ - selectedProjectId, - selectedProjectName, - recentProjects, - onSelectProject, - onImportRepo, - onNewProject, -}: ProjectSelectorProps) { - const [open, setOpen] = useState(false); - - const selectedProject = recentProjects.find( - (p) => p.id === selectedProjectId, - ); - - return ( - - - - - - - - - No projects found. - - {recentProjects.map((project) => ( - { - onSelectProject(project.id); - setOpen(false); - }} - > - - {project.name} - {project.id === selectedProjectId && ( - - )} - - ))} - - - - { - setOpen(false); - onImportRepo(); - }} - > - - Open project - - { - setOpen(false); - onNewProject(); - }} - > - - New project - - - - - - - ); -} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ProjectSelector/index.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ProjectSelector/index.ts deleted file mode 100644 index a524b03c166..00000000000 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/ProjectSelector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectSelector } from "./ProjectSelector"; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 907d2849215..686775cf063 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -8,8 +8,36 @@ import { STARTABLE_AGENT_TYPES, type StartableAgentType, } from "@superset/shared/agent-launch"; -import { Button } from "@superset/ui/button"; -import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { + PromptInput, + PromptInputAttachment, + PromptInputAttachments, + PromptInputButton, + PromptInputFooter, + PromptInputProvider, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, + useProviderAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@superset/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Input } from "@superset/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Select, SelectContent, @@ -18,48 +46,379 @@ import { SelectValue, } from "@superset/ui/select"; 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 { cn } from "@superset/ui/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { + ArrowUpIcon, + Loader2Icon, + PaperclipIcon, + PlusIcon, +} from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; +import { LuFolderGit, LuFolderOpen, LuGitPullRequest } from "react-icons/lu"; +import { SiLinear } from "react-icons/si"; import { getPresetIcon, useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { resolveEffectiveWorkspaceBaseBranch } from "renderer/lib/workspaceBaseBranch"; +import { ProjectThumbnail } from "renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail"; +import { LinkedIssuePill } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; +import { IssueLinkCommand } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/IssueLinkCommand"; import { useHotkeysStore } from "renderer/stores/hotkeys/store"; -import { - resolveBranchPrefix, - sanitizeBranchNameWithMaxLength, -} from "shared/utils/branch"; +import { sanitizeBranchNameWithMaxLength } from "shared/utils/branch"; +import type { LinkedPR } from "../../NewWorkspaceModalDraftContext"; import { useNewWorkspaceModalDraft } from "../../NewWorkspaceModalDraftContext"; -import { PromptGroupAdvancedOptions } from "./components/PromptGroupAdvancedOptions"; +import { LinkedPRPill } from "./components/LinkedPRPill"; +import { PRLinkCommand } from "./components/PRLinkCommand"; type WorkspaceCreateAgent = StartableAgentType | "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; + color: string; + githubOwner: string | null; + iconUrl: string | null; + hideImage: boolean | null; +} + interface PromptGroupProps { projectId: string | null; + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; + onImportRepo: () => void; + onNewProject: () => void; +} + +export function PromptGroup(props: PromptGroupProps) { + return ( + + + + ); } -export function PromptGroup({ projectId }: PromptGroupProps) { - const navigate = useNavigate(); +const PlusMenu = forwardRef< + HTMLDivElement, + { onOpenIssueLink: () => void; onOpenPRLink: () => void } +>(function PlusMenu({ onOpenIssueLink, onOpenPRLink }, ref) { + const attachments = usePromptInputAttachments(); + + return ( +
+ + + + + + + + attachments.openFileDialog()}> + + Add attachment + + + + Link issue + + + + Link pull request + + + +
+ ); +}); + +function ProjectPickerPill({ + selectedProject, + recentProjects, + onSelectProject, + onImportRepo, + onNewProject, +}: { + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; + onImportRepo: () => void; + onNewProject: () => 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 && ( + + )} + + ))} + + + + { + setOpen(false); + onImportRepo(); + }} + > + + Open project + + { + setOpen(false); + onNewProject(); + }} + > + + New project + + + + + + + ); +} + +function BaseBranchPickerInline({ + effectiveBaseBranch, + defaultBranch, + isBranchesLoading, + isBranchesError, + branches, + worktreeBranches, + onSelectBaseBranch, +}: { + effectiveBaseBranch: string | null; + defaultBranch?: string; + isBranchesLoading: boolean; + isBranchesError: boolean; + branches: Array<{ name: string; lastCommitDate: number }>; + worktreeBranches: Set; + onSelectBaseBranch: (branchName: string) => void; +}) { + const [open, setOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); + const [filterMode, setFilterMode] = useState<"all" | "worktrees">("all"); + + const filteredBranches = useMemo(() => { + if (!branches.length) return []; + if (!branchSearch) return branches; + const searchLower = branchSearch.toLowerCase(); + return branches.filter((branch) => + branch.name.toLowerCase().includes(searchLower), + ); + }, [branches, branchSearch]); + + const displayBranches = useMemo(() => { + if (filterMode === "all") return filteredBranches; + return filteredBranches.filter((b) => worktreeBranches.has(b.name)); + }, [filteredBranches, filterMode, worktreeBranches]); + + if (isBranchesError) { + return ( + Failed to load branches + ); + } + + return ( + { + setOpen(v); + if (!v) { + setBranchSearch(""); + setFilterMode("all"); + } + }} + > + + + + event.stopPropagation()} + > + +
+ {(["all", "worktrees"] as const).map((value) => { + const count = + value === "all" + ? branches.length + : branches.filter((b) => worktreeBranches.has(b.name)).length; + return ( + + ); + })} +
+ + + No branches found + {displayBranches.map((branch) => ( + { + onSelectBaseBranch(branch.name); + setOpen(false); + }} + className="flex items-center justify-between" + > + + + {branch.name} + {branch.name === defaultBranch && ( + + default + + )} + + + {branch.lastCommitDate > 0 && ( + + {formatRelativeTime(branch.lastCommitDate)} + + )} + {effectiveBaseBranch === branch.name && ( + + )} + + + ))} + +
+
+
+ ); +} + +function PromptGroupInner({ + projectId, + selectedProject, + recentProjects, + onSelectProject, + onImportRepo, + onNewProject, +}: PromptGroupProps) { const platform = useHotkeysStore((state) => state.platform); const modKey = platform === "darwin" ? "⌘" : "Ctrl"; const isDark = useIsDarkTheme(); - const textareaRef = useRef(null); - const { closeModal, createWorkspace, draft, runAsyncAction, updateDraft } = + const { createWorkspace, createFromPr, draft, runAsyncAction, updateDraft } = useNewWorkspaceModalDraft(); - const [baseBranchOpen, setBaseBranchOpen] = useState(false); + const attachments = useProviderAttachments(); const { baseBranch, - branchName, - branchNameEdited, - branchSearch, prompt, runSetupScript, - showAdvanced, + workspaceName, + workspaceNameEdited, + branchName, + branchNameEdited, + linkedIssues, + linkedPR, } = draft; const runSetupScriptRef = useRef(runSetupScript); runSetupScriptRef.current = runSetupScript; @@ -74,7 +433,11 @@ export function PromptGroup({ projectId }: PromptGroupProps) { : "none"; }, ); + const [issueLinkOpen, setIssueLinkOpen] = useState(false); + const [prLinkOpen, setPRLinkOpen] = useState(false); + const plusMenuRef = useRef(null); const trimmedPrompt = prompt.trim(); + const firstIssueSlug = linkedIssues[0]?.slug ?? null; const { data: project } = electronTrpc.projects.get.useQuery( { id: projectId ?? "" }, @@ -82,7 +445,7 @@ export function PromptGroup({ projectId }: PromptGroupProps) { ); const { data: localBranchData, - isLoading: isBranchesLoading, + isLoading: isLocalBranchesLoading, isError: isBranchesError, } = electronTrpc.projects.getBranchesLocal.useQuery( { projectId: projectId ?? "" }, @@ -92,37 +455,29 @@ export function PromptGroup({ projectId }: PromptGroupProps) { { projectId: projectId ?? "" }, { enabled: !!projectId }, ); + // Show local data immediately (fast, no network), upgrade to remote when available const branchData = remoteBranchData ?? localBranchData; - const { data: gitAuthor } = electronTrpc.projects.getGitAuthor.useQuery( - { id: projectId ?? "" }, - { enabled: !!projectId }, - ); - 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]); + // Only show loading while waiting for the fast local query + const isBranchesLoading = isLocalBranchesLoading && !branchData; - 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), + const { data: externalWorktrees = [] } = + electronTrpc.workspaces.getExternalWorktrees.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId }, + ); + + const { data: trackedWorktrees = [] } = + electronTrpc.workspaces.getWorktreesByProject.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId }, ); - }, [branchData?.branches, branchSearch]); + + const worktreeBranches = useMemo(() => { + const set = new Set(); + for (const wt of externalWorktrees) set.add(wt.branch); + for (const wt of trackedWorktrees) set.add(wt.branch); + return set; + }, [externalWorktrees, trackedWorktrees]); const effectiveBaseBranch = resolveEffectiveWorkspaceBaseBranch({ explicitBaseBranch: baseBranch, @@ -131,18 +486,13 @@ export function PromptGroup({ projectId }: PromptGroupProps) { 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 branchSlug = sanitizeBranchNameWithMaxLength( + trimmedPrompt || + firstIssueSlug || + (linkedPR ? `pr-${linkedPR.prNumber}` : "") || + "", + 18, + ); const previousProjectIdRef = useRef(projectId); @@ -151,11 +501,7 @@ export function PromptGroup({ projectId }: PromptGroupProps) { return; } previousProjectIdRef.current = projectId; - updateDraft({ - baseBranch: null, - branchSearch: "", - }); - setBaseBranchOpen(false); + updateDraft({ baseBranch: null }); }, [projectId, updateDraft]); const handleAgentChange = (value: WorkspaceCreateAgent) => { @@ -163,59 +509,126 @@ export function PromptGroup({ projectId }: PromptGroupProps) { window.localStorage.setItem(AGENT_STORAGE_KEY, value); }; - const buildLaunchRequest = ( - trimmedPrompt: string, - ): AgentLaunchRequest | null => { - if (selectedAgent === "none") return null; + 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); + }); + }, + [], + ); + + const buildLaunchRequest = useCallback( + (prompt: string, files?: ConvertedFile[]): AgentLaunchRequest | null => { + if (selectedAgent === "none") return null; + + if (selectedAgent === "superset-chat") { + return { + kind: "chat", + workspaceId: "pending-workspace", + agentType: "superset-chat", + source: "new-workspace", + chat: { + initialPrompt: prompt || undefined, + initialFiles: files?.length ? files : undefined, + taskSlug: firstIssueSlug || undefined, + }, + }; + } + + const command = prompt + ? buildAgentPromptCommand({ + prompt, + randomId: window.crypto.randomUUID(), + agent: selectedAgent, + }) + : (AGENT_PRESET_COMMANDS[selectedAgent][0] ?? null); + + if (!command) return null; - if (selectedAgent === "superset-chat") { return { - kind: "chat", + kind: "terminal", workspaceId: "pending-workspace", - agentType: "superset-chat", + agentType: selectedAgent, source: "new-workspace", - chat: { - initialPrompt: trimmedPrompt || undefined, + terminal: { + command, + name: "Agent", }, }; - } - - const command = trimmedPrompt - ? buildAgentPromptCommand({ - prompt: trimmedPrompt, - randomId: window.crypto.randomUUID(), - agent: selectedAgent, - }) - : (AGENT_PRESET_COMMANDS[selectedAgent][0] ?? null); - - if (!command) return null; - - return { - kind: "terminal", - workspaceId: "pending-workspace", - agentType: selectedAgent, - source: "new-workspace", - terminal: { - command, - name: "Agent", - }, - }; - }; + }, + [selectedAgent, firstIssueSlug], + ); - const handleCreate = () => { + const handleCreate = useCallback(async () => { if (!projectId) { toast.error("Select a project first"); return; } - const launchRequest = buildLaunchRequest(trimmedPrompt); + + let convertedFiles: ConvertedFile[] | undefined; + if (attachments.files.length > 0) { + try { + convertedFiles = await Promise.all( + attachments.files.map(async (file) => ({ + data: await convertBlobUrlToDataUrl(file.url), + mediaType: file.mediaType, + filename: file.filename, + })), + ); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to process attachments", + ); + return; + } + } + + // If a PR is linked, use createFromPr instead of regular create + if (linkedPR) { + const launchRequest = buildLaunchRequest(trimmedPrompt, convertedFiles); + void runAsyncAction( + createFromPr.mutateAsyncWithSetup( + { projectId, prUrl: linkedPR.url }, + launchRequest ?? undefined, + ), + { + loading: `Creating workspace from PR #${linkedPR.prNumber}...`, + success: "Workspace created from PR", + error: (err) => + err instanceof Error + ? err.message + : "Failed to create workspace from PR", + }, + ); + return; + } + + const launchRequest = buildLaunchRequest(trimmedPrompt, convertedFiles); void runAsyncAction( createWorkspace.mutateAsyncWithPendingSetup( { projectId, + name: + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : undefined, prompt: trimmedPrompt || undefined, - branchName: branchSlug || undefined, + branchName: + (branchNameEdited && branchName.trim() + ? sanitizeBranchNameWithMaxLength(branchName.trim()) + : branchSlug) || undefined, baseBranch: baseBranch || undefined, - applyPrefix, }, { agentLaunchRequest: launchRequest ?? undefined, @@ -230,124 +643,301 @@ export function PromptGroup({ projectId }: PromptGroupProps) { err instanceof Error ? err.message : "Failed to create workspace", }, ); + }, [ + attachments.files, + baseBranch, + branchName, + branchNameEdited, + branchSlug, + buildLaunchRequest, + convertBlobUrlToDataUrl, + createFromPr, + createWorkspace, + linkedPR, + projectId, + runAsyncAction, + trimmedPrompt, + workspaceName, + workspaceNameEdited, + ]); + + const handlePromptSubmit = useCallback(() => { + void handleCreate(); + }, [handleCreate]); + + const handleBaseBranchSelect = (selectedBaseBranch: string) => { + updateDraft({ baseBranch: selectedBaseBranch }); }; - const handleBranchNameChange = (value: string) => { + const addLinkedIssue = (slug: string, title: string) => { + if (linkedIssues.some((issue) => issue.slug === slug)) return; + updateDraft({ linkedIssues: [...linkedIssues, { slug, title }] }); + }; + + const removeLinkedIssue = (slug: string) => { updateDraft({ - branchName: value, - branchNameEdited: true, + linkedIssues: linkedIssues.filter((issue) => issue.slug !== slug), }); }; - const handleBranchNameBlur = () => { - if (!branchName.trim()) { - updateDraft({ - branchName: "", - branchNameEdited: false, - }); - } + const setLinkedPR = (pr: LinkedPR) => { + updateDraft({ linkedPR: pr }); }; - const handleBaseBranchSelect = (selectedBaseBranch: string) => { - updateDraft({ - baseBranch: selectedBaseBranch, - branchSearch: "", - }); - setBaseBranchOpen(false); + const removeLinkedPR = () => { + updateDraft({ linkedPR: null }); }; + const agentIcon = + selectedAgent !== "none" ? getPresetIcon(selectedAgent, isDark) : null; + const agentLabel = + selectedAgent === "none" + ? "No agent" + : selectedAgent === "superset-chat" + ? "Superset" + : STARTABLE_AGENT_LABELS[selectedAgent]; + return ( -
- - -