diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 1f28701f2bd..0c3c81ac06e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -37,6 +37,7 @@ import { worktreeExists, } from "../utils/git"; import { resolveWorktreePath } from "../utils/resolve-worktree-path"; +import { selectExternalWorktreesForImport } from "../utils/select-external-worktrees-for-import"; import { copySupersetConfigToWorktree, loadSetupConfig } from "../utils/setup"; import { createWorkspaceFromExternalWorktree, @@ -869,14 +870,10 @@ export const createCreateProcedures = () => { ); const trackedPaths = new Set(projectWorktrees.map((wt) => wt.path)); - const externalWorktrees = allExternalWorktrees.filter((wt) => { - if (wt.path === project.mainRepoPath) return false; - if (wt.isBare) return false; - if (wt.isDetached) return false; - if (!wt.branch) return false; - if (trackedPaths.has(wt.path)) return false; - return true; - }); + const externalWorktrees = selectExternalWorktreesForImport( + allExternalWorktrees, + { mainRepoPath: project.mainRepoPath, trackedPaths }, + ); for (const ext of externalWorktrees) { // biome-ignore lint/style/noNonNullAssertion: filtered above @@ -933,6 +930,103 @@ export const createCreateProcedures = () => { }); } + return { imported }; + }), + importExternalWorktrees: publicProcedure + .input( + z.object({ + projectId: z.string(), + paths: z.array(z.string()).min(1), + }), + ) + .mutation(async ({ input }) => { + const project = getProject(input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + const knownBranches = await getKnownBranchesSafe(project.mainRepoPath); + const compareBaseBranch = resolveWorkspaceBaseBranch({ + workspaceBaseBranch: project.workspaceBaseBranch, + defaultBranch: project.defaultBranch, + knownBranches, + }); + + const projectWorktrees = localDb + .select({ path: worktrees.path }) + .from(worktrees) + .where(eq(worktrees.projectId, input.projectId)) + .all(); + const trackedPaths = new Set(projectWorktrees.map((wt) => wt.path)); + + const allExternalWorktrees = await listExternalWorktrees( + project.mainRepoPath, + ); + + const externalWorktrees = selectExternalWorktreesForImport( + allExternalWorktrees, + { + mainRepoPath: project.mainRepoPath, + trackedPaths, + requested: new Set(input.paths), + }, + ); + + let imported = 0; + for (const ext of externalWorktrees) { + // biome-ignore lint/style/noNonNullAssertion: filtered above + const branch = ext.branch!; + + const worktree = localDb + .insert(worktrees) + .values({ + projectId: input.projectId, + path: ext.path, + branch, + baseBranch: compareBaseBranch, + gitStatus: { + branch, + needsRebase: false, + ahead: 0, + behind: 0, + lastRefreshed: Date.now(), + }, + createdBySuperset: false, + }) + .returning() + .get(); + + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); + localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + worktreeId: worktree.id, + type: "worktree", + branch, + name: branch, + tabOrder: maxTabOrder + 1, + }) + .run(); + + await setBranchBaseConfig({ + repoPath: project.mainRepoPath, + branch, + compareBaseBranch, + isExplicit: false, + }); + + copySupersetConfigToWorktree(project.mainRepoPath, ext.path); + imported++; + } + + if (imported > 0) { + activateProject(project); + track("workspaces_bulk_imported", { + project_id: project.id, + imported_count: imported, + }); + } + return { imported }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index c3229e1dc1e..9370fa1ca53 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -25,6 +25,7 @@ import { type PullRequestCommentsTarget, resolveReviewThread, } from "../utils/github"; +import { selectExternalWorktreesForImport } from "../utils/select-external-worktrees-for-import"; import { getWorkspacePath } from "../utils/worktree"; const gitHubPRCommentsInputSchema = z.object({ @@ -354,20 +355,14 @@ export const createGitStatusProcedures = () => { .all(); const trackedPaths = new Set(trackedWorktrees.map((wt) => wt.path)); - return allWorktrees - .filter((wt) => { - if (wt.path === project.mainRepoPath) return false; - if (wt.isBare) return false; - if (wt.isDetached) return false; - if (!wt.branch) return false; - if (trackedPaths.has(wt.path)) return false; - return true; - }) - .map((wt) => ({ - path: wt.path, - // biome-ignore lint/style/noNonNullAssertion: filtered above - branch: wt.branch!, - })); + return selectExternalWorktreesForImport(allWorktrees, { + mainRepoPath: project.mainRepoPath, + trackedPaths, + }).map((wt) => ({ + path: wt.path, + // biome-ignore lint/style/noNonNullAssertion: filtered above + branch: wt.branch!, + })); }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.integration.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.integration.test.ts new file mode 100644 index 00000000000..93d2e1abdd1 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.integration.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listExternalWorktrees } from "./git"; +import { selectExternalWorktreesForImport } from "./select-external-worktrees-for-import"; + +const TEST_DIR = join( + realpathSync(tmpdir()), + `superset-test-select-import-${process.pid}`, +); + +function createTestRepo(name: string): string { + const repoPath = join(TEST_DIR, name); + mkdirSync(repoPath, { recursive: true }); + execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { cwd: repoPath, stdio: "ignore" }); + writeFileSync(join(repoPath, "README.md"), "# test\n"); + execSync("git add . && git commit -m 'init'", { + cwd: repoPath, + stdio: "ignore", + }); + return repoPath; +} + +function addWorktree( + mainRepoPath: string, + branch: string, + worktreePath: string, +): void { + mkdirSync(worktreePath, { recursive: true }); + execSync(`git worktree add "${worktreePath}" -b ${branch}`, { + cwd: mainRepoPath, + stdio: "ignore", + }); +} + +function addDetachedWorktree(mainRepoPath: string, worktreePath: string): void { + mkdirSync(worktreePath, { recursive: true }); + execSync(`git worktree add --detach "${worktreePath}" HEAD`, { + cwd: mainRepoPath, + stdio: "ignore", + }); +} + +describe("selectExternalWorktreesForImport (real git worktrees)", () => { + let mainRepoPath: string; + let wtA: string; + let wtB: string; + let wtC: string; + + beforeEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + mkdirSync(TEST_DIR, { recursive: true }); + mainRepoPath = createTestRepo("main-repo"); + wtA = join(TEST_DIR, "wt-a"); + wtB = join(TEST_DIR, "wt-b"); + wtC = join(TEST_DIR, "wt-c"); + addWorktree(mainRepoPath, "feat-a", wtA); + addWorktree(mainRepoPath, "feat-b", wtB); + addWorktree(mainRepoPath, "feat-c", wtC); + }); + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }); + } + }); + + test("with no requested filter, returns all three external worktrees and excludes main repo", async () => { + const all = await listExternalWorktrees(mainRepoPath); + expect(all.map((w) => w.path).sort()).toEqual( + [mainRepoPath, wtA, wtB, wtC].sort(), + ); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + }); + expect(result.map((w) => w.path).sort()).toEqual([wtA, wtB, wtC].sort()); + expect(result.map((w) => w.branch).sort()).toEqual( + ["feat-a", "feat-b", "feat-c"].sort(), + ); + }); + + test("with requested = {wtA, wtC}, returns only those two", async () => { + const all = await listExternalWorktrees(mainRepoPath); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([wtA, wtC]), + }); + expect(result.map((w) => w.path).sort()).toEqual([wtA, wtC].sort()); + }); + + test("paths already tracked in the DB are skipped even when requested", async () => { + const all = await listExternalWorktrees(mainRepoPath); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set([wtB]), + requested: new Set([wtA, wtB, wtC]), + }); + expect(result.map((w) => w.path).sort()).toEqual([wtA, wtC].sort()); + }); + + test("requested set containing a path that no longer exists is silently ignored", async () => { + const all = await listExternalWorktrees(mainRepoPath); + const ghostPath = join(TEST_DIR, "wt-ghost"); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([wtA, ghostPath]), + }); + expect(result.map((w) => w.path)).toEqual([wtA]); + }); + + test("detached HEAD worktrees are skipped even when requested", async () => { + const wtDetached = join(TEST_DIR, "wt-detached"); + addDetachedWorktree(mainRepoPath, wtDetached); + + const all = await listExternalWorktrees(mainRepoPath); + const detachedEntry = all.find((w) => w.path === wtDetached); + expect(detachedEntry).toBeDefined(); + expect(detachedEntry?.isDetached).toBe(true); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([wtA, wtDetached]), + }); + expect(result.map((w) => w.path)).toEqual([wtA]); + }); + + test("empty requested set returns no worktrees", async () => { + const all = await listExternalWorktrees(mainRepoPath); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set(), + }); + expect(result).toEqual([]); + }); + + test("main repo path in the requested set never gets imported", async () => { + const all = await listExternalWorktrees(mainRepoPath); + + const result = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([mainRepoPath, wtA]), + }); + expect(result.map((w) => w.path)).toEqual([wtA]); + }); + + test("re-running selection after one worktree is marked tracked converges", async () => { + const all = await listExternalWorktrees(mainRepoPath); + + // First pass: import wtA only + const firstPass = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([wtA]), + }); + expect(firstPass.map((w) => w.path)).toEqual([wtA]); + + // Simulate wtA now being tracked. A second pass with all three requested + // should return only wtB and wtC. + const secondPass = selectExternalWorktreesForImport(all, { + mainRepoPath, + trackedPaths: new Set([wtA]), + requested: new Set([wtA, wtB, wtC]), + }); + expect(secondPass.map((w) => w.path).sort()).toEqual([wtB, wtC].sort()); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.test.ts new file mode 100644 index 00000000000..777423abd3d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test"; +import type { ExternalWorktree } from "./git"; +import { selectExternalWorktreesForImport } from "./select-external-worktrees-for-import"; + +function wt(overrides: Partial): ExternalWorktree { + return { + path: "/tmp/wt", + branch: "feature", + isBare: false, + isDetached: false, + ...overrides, + }; +} + +describe("selectExternalWorktreesForImport", () => { + const mainRepoPath = "/repos/main"; + + test("returns all eligible worktrees when no requested filter", () => { + const worktrees = [ + wt({ path: "/repos/wt-a", branch: "feature-a" }), + wt({ path: "/repos/wt-b", branch: "feature-b" }), + ]; + const result = selectExternalWorktreesForImport(worktrees, { + mainRepoPath, + trackedPaths: new Set(), + }); + expect(result.map((w) => w.path)).toEqual(["/repos/wt-a", "/repos/wt-b"]); + }); + + test("filters to only requested paths", () => { + const worktrees = [ + wt({ path: "/repos/wt-a", branch: "feature-a" }), + wt({ path: "/repos/wt-b", branch: "feature-b" }), + wt({ path: "/repos/wt-c", branch: "feature-c" }), + ]; + const result = selectExternalWorktreesForImport(worktrees, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set(["/repos/wt-a", "/repos/wt-c"]), + }); + expect(result.map((w) => w.path)).toEqual(["/repos/wt-a", "/repos/wt-c"]); + }); + + test("requested paths that are already tracked are skipped", () => { + const worktrees = [ + wt({ path: "/repos/wt-a", branch: "feature-a" }), + wt({ path: "/repos/wt-b", branch: "feature-b" }), + ]; + const result = selectExternalWorktreesForImport(worktrees, { + mainRepoPath, + trackedPaths: new Set(["/repos/wt-b"]), + requested: new Set(["/repos/wt-a", "/repos/wt-b"]), + }); + expect(result.map((w) => w.path)).toEqual(["/repos/wt-a"]); + }); + + test("requested paths that are bare/detached/branchless are skipped", () => { + const worktrees = [ + wt({ path: "/repos/wt-a", branch: "feature-a" }), + wt({ path: "/repos/wt-bare", isBare: true }), + wt({ path: "/repos/wt-detached", isDetached: true, branch: null }), + wt({ path: "/repos/wt-no-branch", branch: null }), + ]; + const result = selectExternalWorktreesForImport(worktrees, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([ + "/repos/wt-a", + "/repos/wt-bare", + "/repos/wt-detached", + "/repos/wt-no-branch", + ]), + }); + expect(result.map((w) => w.path)).toEqual(["/repos/wt-a"]); + }); + + test("main repo path is never included even when requested", () => { + const worktrees = [ + wt({ path: mainRepoPath, branch: "main" }), + wt({ path: "/repos/wt-a", branch: "feature-a" }), + ]; + const result = selectExternalWorktreesForImport(worktrees, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set([mainRepoPath, "/repos/wt-a"]), + }); + expect(result.map((w) => w.path)).toEqual(["/repos/wt-a"]); + }); + + test("empty requested set returns no worktrees", () => { + const worktrees = [ + wt({ path: "/repos/wt-a", branch: "feature-a" }), + wt({ path: "/repos/wt-b", branch: "feature-b" }), + ]; + const result = selectExternalWorktreesForImport(worktrees, { + mainRepoPath, + trackedPaths: new Set(), + requested: new Set(), + }); + expect(result).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.ts new file mode 100644 index 00000000000..d8128d9a1a6 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/select-external-worktrees-for-import.ts @@ -0,0 +1,29 @@ +import type { ExternalWorktree } from "./git"; + +interface SelectArgs { + mainRepoPath: string; + trackedPaths: Set; + /** When provided, only worktrees whose path is in this set are returned. */ + requested?: Set; +} + +/** + * Apply the same filter rules used when bulk-importing external worktrees: + * skip the main repo, bare/detached worktrees, branch-less worktrees, and + * anything already tracked in the local DB. When `requested` is provided, + * also skip worktrees not in that set. + */ +export function selectExternalWorktreesForImport( + worktrees: ExternalWorktree[], + { mainRepoPath, trackedPaths, requested }: SelectArgs, +): ExternalWorktree[] { + return worktrees.filter((wt) => { + if (requested && !requested.has(wt.path)) return false; + if (wt.path === mainRepoPath) return false; + if (wt.isBare) return false; + if (wt.isDetached) return false; + if (!wt.branch) return false; + if (trackedPaths.has(wt.path)) return false; + return true; + }); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts index 2506adbbc6b..0a0ff33a4b3 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts @@ -13,6 +13,7 @@ function getErrorMessage(error: unknown, fallback: string): string { interface UseOpenAIOAuthParams { isModelSelectorOpen: boolean; onModelSelectorOpenChange: (open: boolean) => void; + onAuthStateChange?: () => Promise | void; } interface OpenAIOAuthDialogState { @@ -40,6 +41,7 @@ interface UseOpenAIOAuthResult { export function useOpenAIOAuth({ isModelSelectorOpen, onModelSelectorOpenChange, + onAuthStateChange, }: UseOpenAIOAuthParams): UseOpenAIOAuthResult { const [oauthDialogOpen, setOauthDialogOpen] = useState(false); const [oauthUrl, setOauthUrl] = useState(null); @@ -110,6 +112,7 @@ export function useOpenAIOAuth({ async (action: "complete" | "disconnect") => { try { await refetchOpenAIStatus(); + await onAuthStateChange?.(); } catch (error) { console.error( `[model-picker] OpenAI OAuth ${action} follow-up refresh failed:`, @@ -117,31 +120,57 @@ export function useOpenAIOAuth({ ); } }, - [refetchOpenAIStatus], + [onAuthStateChange, refetchOpenAIStatus], ); - const completeOpenAIOAuth = useCallback(async () => { - setOauthError(null); - try { - const code = oauthCode.trim(); - await completeOpenAIOAuthMutation.mutateAsync({ - code: code.length > 0 ? code : undefined, - }); - setHasPendingOAuthSession(false); - setOauthDialogOpen(false); - setOauthUrl(null); - setOauthCode(""); - onModelSelectorOpenChange(true); - } catch (error) { - setOauthError(getErrorMessage(error, "Failed to complete OpenAI OAuth")); - return; - } - await syncOpenAIAuthUi("complete"); + const completeOpenAIOAuth = useCallback( + async (codeOverride?: string) => { + setOauthError(null); + try { + const code = (codeOverride ?? oauthCode).trim(); + await completeOpenAIOAuthMutation.mutateAsync({ + code: code.length > 0 ? code : undefined, + }); + setHasPendingOAuthSession(false); + setOauthDialogOpen(false); + setOauthUrl(null); + setOauthCode(""); + onModelSelectorOpenChange(true); + } catch (error) { + setOauthError( + getErrorMessage(error, "Failed to complete OpenAI OAuth"), + ); + return; + } + await syncOpenAIAuthUi("complete"); + }, + [ + completeOpenAIOAuthMutation, + oauthCode, + onModelSelectorOpenChange, + syncOpenAIAuthUi, + ], + ); + + const { data: pendingLoopbackCallback } = + chatServiceTrpc.auth.consumeOpenAIOAuthCallback.useQuery(undefined, { + enabled: oauthDialogOpen && hasPendingOAuthSession, + refetchInterval: oauthDialogOpen && hasPendingOAuthSession ? 1500 : false, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + const callbackUrl = pendingLoopbackCallback?.callbackUrl; + if (!callbackUrl) return; + if (!oauthDialogOpen || !hasPendingOAuthSession) return; + if (completeOpenAIOAuthMutation.isPending) return; + void completeOpenAIOAuth(callbackUrl); }, [ - completeOpenAIOAuthMutation, - oauthCode, - onModelSelectorOpenChange, - syncOpenAIAuthUi, + pendingLoopbackCallback?.callbackUrl, + oauthDialogOpen, + hasPendingOAuthSession, + completeOpenAIOAuthMutation.isPending, + completeOpenAIOAuth, ]); const disconnectOpenAIOAuth = useCallback(async () => { diff --git a/apps/desktop/src/renderer/react-query/workspaces/useImportExternalWorktrees.ts b/apps/desktop/src/renderer/react-query/workspaces/useImportExternalWorktrees.ts new file mode 100644 index 00000000000..769b7f37c27 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useImportExternalWorktrees.ts @@ -0,0 +1,12 @@ +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function useImportExternalWorktrees() { + const utils = electronTrpc.useUtils(); + + return electronTrpc.workspaces.importExternalWorktrees.useMutation({ + onSuccess: async () => { + await utils.workspaces.invalidate(); + await utils.projects.getRecents.invalidate(); + }, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx index 906814ae69d..0952ee7ad75 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/page.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from "@superset/ui/checkbox"; import { toast } from "@superset/ui/sonner"; import { Spinner } from "@superset/ui/spinner"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; @@ -6,10 +7,17 @@ import { GoGitBranch } from "react-icons/go"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { track } from "renderer/lib/analytics"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useImportAllWorktrees } from "renderer/react-query/workspaces/useImportAllWorktrees"; +import { useImportExternalWorktrees } from "renderer/react-query/workspaces/useImportExternalWorktrees"; import { STEP_ROUTES, useOnboardingStore } from "renderer/stores/onboarding"; import { SetupButton } from "../components/SetupButton"; import { StepHeader, StepShell } from "../components/StepShell"; +import { + countSelected, + initializeProjectSelection, + type SelectionState, + togglePathInSelection, + toggleProjectInSelection, +} from "./utils/selection"; export const Route = createFileRoute("/_authenticated/setup/adopt-worktrees/")({ component: OnboardingAdoptWorktreesPage, @@ -39,13 +47,6 @@ function OnboardingAdoptWorktreesPage() { goTo("adopt-worktrees"); }, [goTo]); - // After onboarding, prefer the user's last-viewed workspace (or any worktree- - // type workspace) so they land in the workspace editor with a real pane - // layout. Route to the v2 workspace view when v2 is enabled. In v2, skip - // `branch` type workspaces (they're auto-created by ensureMainWorkspace and - // have no pane layout) and prefer the project page instead so the user can - // create their first worktree via v2's flow. If no workspaces exist yet, - // fall back to the project page. const navigateAfterFlow = useCallback( async (replace: boolean) => { try { @@ -121,8 +122,6 @@ function OnboardingAdoptWorktreesPage() { } if (!projects || projects.length === 0) { - // No projects → nothing to adopt. Skip this step entirely (even in walkthrough) - // since the page has no actionable content. return ; } @@ -147,6 +146,11 @@ function AutoAdvance({ onAdvance }: { onAdvance: () => void }) { ); } +interface ProjectResult { + worktrees: ExternalWorktree[]; + loaded: boolean; +} + interface AdoptWorktreesContentProps { projects: { id: string; name: string }[]; onSkip: () => void; @@ -160,10 +164,9 @@ function AdoptWorktreesContent({ onFinish, manualWalkthrough, }: AdoptWorktreesContentProps) { - const importAllWorktrees = useImportAllWorktrees(); - const [results, setResults] = useState< - Record - >({}); + const importExternalWorktrees = useImportExternalWorktrees(); + const [results, setResults] = useState>({}); + const [selected, setSelected] = useState({}); const allLoaded = projects.every((p) => results[p.id]?.loaded); const total = useMemo( @@ -174,21 +177,57 @@ function AdoptWorktreesContent({ ), [results], ); + const totalSelected = useMemo(() => countSelected(selected), [selected]); useEffect(() => { - // In walkthrough mode the user wants to see every step, including a - // "nothing to adopt" confirmation. Otherwise auto-advance when empty. if (allLoaded && total === 0 && !manualWalkthrough) onFinish(); }, [allLoaded, total, manualWalkthrough, onFinish]); - const handleImportAll = async () => { + const handleResult = useCallback( + (projectId: string, worktrees: ExternalWorktree[]) => { + setResults((prev) => ({ + ...prev, + [projectId]: { worktrees, loaded: true }, + })); + setSelected((prev) => + initializeProjectSelection( + prev, + projectId, + worktrees.map((wt) => wt.path), + ), + ); + }, + [], + ); + + const togglePath = useCallback((projectId: string, path: string) => { + setSelected((prev) => togglePathInSelection(prev, projectId, path)); + }, []); + + const toggleProject = useCallback( + (projectId: string) => { + setSelected((prev) => { + const projectResult = results[projectId]; + if (!projectResult) return prev; + return toggleProjectInSelection( + prev, + projectId, + projectResult.worktrees.map((wt) => wt.path), + ); + }); + }, + [results], + ); + + const handleImportSelected = async () => { let totalImported = 0; for (const project of projects) { - const projectResult = results[project.id]; - if (!projectResult || projectResult.worktrees.length === 0) continue; + const paths = Array.from(selected[project.id] ?? []); + if (paths.length === 0) continue; try { - const result = await importAllWorktrees.mutateAsync({ + const result = await importExternalWorktrees.mutateAsync({ projectId: project.id, + paths, }); totalImported += result.imported; } catch (err) { @@ -230,12 +269,10 @@ function AdoptWorktreesContent({ key={project.id} projectId={project.id} projectName={project.name} - onResult={(worktrees) => { - setResults((prev) => ({ - ...prev, - [project.id]: { worktrees, loaded: true }, - })); - }} + selectedPaths={selected[project.id]} + onResult={(worktrees) => handleResult(project.id, worktrees)} + onTogglePath={(path) => togglePath(project.id, path)} + onToggleAll={() => toggleProject(project.id)} /> ))} @@ -244,19 +281,32 @@ function AdoptWorktreesContent({
{nothingToAdopt ? ( - Continue + <> + Continue + + Skip for now + + ) : ( <> - {importAllWorktrees.isPending ? "Importing…" : "Import all"} + {importExternalWorktrees.isPending + ? "Importing…" + : totalSelected === 0 + ? "Select worktrees" + : `Import ${totalSelected} selected`} Skip for now @@ -270,19 +320,23 @@ function AdoptWorktreesContent({ interface ProjectWorktreesProps { projectId: string; projectName: string; + selectedPaths: Set | undefined; onResult: (worktrees: ExternalWorktree[]) => void; + onTogglePath: (path: string) => void; + onToggleAll: () => void; } function ProjectWorktrees({ projectId, projectName, + selectedPaths, onResult, + onTogglePath, + onToggleAll, }: ProjectWorktreesProps) { const { data, isPending, isError, error } = electronTrpc.workspaces.getExternalWorktrees.useQuery({ projectId }); - // Keep the latest callback in a ref so we don't refire the effect when the - // parent passes a fresh inline closure each render. const onResultRef = useRef(onResult); useEffect(() => { onResultRef.current = onResult; @@ -313,26 +367,47 @@ function ProjectWorktrees({ if (!data || data.length === 0) return null; + const selectedCount = data.filter((wt) => selectedPaths?.has(wt.path)).length; + const allSelected = selectedCount === data.length; + return (

{projectName}

-

- {data.length} worktree{data.length === 1 ? "" : "s"} -

+
-
- {data.map((wt) => ( - - - {wt.branch} - - ))} +
+ {data.map((wt) => { + const isSelected = selectedPaths?.has(wt.path) ?? false; + const checkboxId = `worktree-${projectId}-${wt.path}`; + return ( + + ); + })}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/utils/selection.test.ts b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/utils/selection.test.ts new file mode 100644 index 00000000000..a871fde31bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/utils/selection.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { + countSelected, + initializeProjectSelection, + type SelectionState, + togglePathInSelection, + toggleProjectInSelection, +} from "./selection"; + +describe("togglePathInSelection", () => { + test("adds path to an empty project entry", () => { + const next = togglePathInSelection({}, "p1", "/wt/a"); + expect(Array.from(next.p1)).toEqual(["/wt/a"]); + }); + + test("adds path to an existing project entry without disturbing others", () => { + const prev: SelectionState = { + p1: new Set(["/wt/a"]), + p2: new Set(["/wt/x", "/wt/y"]), + }; + const next = togglePathInSelection(prev, "p1", "/wt/b"); + expect(Array.from(next.p1).sort()).toEqual(["/wt/a", "/wt/b"]); + expect(Array.from(next.p2).sort()).toEqual(["/wt/x", "/wt/y"]); + }); + + test("removes path that's already in the set", () => { + const prev: SelectionState = { p1: new Set(["/wt/a", "/wt/b"]) }; + const next = togglePathInSelection(prev, "p1", "/wt/a"); + expect(Array.from(next.p1)).toEqual(["/wt/b"]); + }); + + test("does not mutate the previous state", () => { + const prev: SelectionState = { p1: new Set(["/wt/a"]) }; + const next = togglePathInSelection(prev, "p1", "/wt/b"); + expect(Array.from(prev.p1)).toEqual(["/wt/a"]); + expect(next).not.toBe(prev); + expect(next.p1).not.toBe(prev.p1); + }); + + test("toggling the same path twice returns to the original size", () => { + let state: SelectionState = { p1: new Set(["/wt/a"]) }; + state = togglePathInSelection(state, "p1", "/wt/b"); + state = togglePathInSelection(state, "p1", "/wt/b"); + expect(Array.from(state.p1)).toEqual(["/wt/a"]); + }); +}); + +describe("toggleProjectInSelection", () => { + test("selects all when current is empty", () => { + const next = toggleProjectInSelection({}, "p1", [ + "/wt/a", + "/wt/b", + "/wt/c", + ]); + expect(Array.from(next.p1).sort()).toEqual(["/wt/a", "/wt/b", "/wt/c"]); + }); + + test("selects all when current is partially selected", () => { + const prev: SelectionState = { p1: new Set(["/wt/a"]) }; + const next = toggleProjectInSelection(prev, "p1", [ + "/wt/a", + "/wt/b", + "/wt/c", + ]); + expect(Array.from(next.p1).sort()).toEqual(["/wt/a", "/wt/b", "/wt/c"]); + }); + + test("deselects all when every path is currently selected", () => { + const prev: SelectionState = { + p1: new Set(["/wt/a", "/wt/b", "/wt/c"]), + }; + const next = toggleProjectInSelection(prev, "p1", [ + "/wt/a", + "/wt/b", + "/wt/c", + ]); + expect(Array.from(next.p1)).toEqual([]); + }); + + test("does not affect other projects", () => { + const prev: SelectionState = { + p1: new Set(["/wt/a"]), + p2: new Set(["/wt/x", "/wt/y"]), + }; + const next = toggleProjectInSelection(prev, "p1", ["/wt/a", "/wt/b"]); + expect(Array.from(next.p2).sort()).toEqual(["/wt/x", "/wt/y"]); + }); + + test("with empty path list, treats as 'select all' (i.e. clears the entry to a fresh empty set)", () => { + const prev: SelectionState = { p1: new Set(["/wt/a"]) }; + const next = toggleProjectInSelection(prev, "p1", []); + expect(Array.from(next.p1)).toEqual([]); + }); +}); + +describe("countSelected", () => { + test("returns 0 for empty state", () => { + expect(countSelected({})).toBe(0); + }); + + test("sums sizes across projects", () => { + const state: SelectionState = { + p1: new Set(["/wt/a", "/wt/b"]), + p2: new Set(["/wt/x"]), + p3: new Set(), + }; + expect(countSelected(state)).toBe(3); + }); +}); + +describe("initializeProjectSelection", () => { + test("creates a new entry with all provided paths", () => { + const next = initializeProjectSelection({}, "p1", ["/wt/a", "/wt/b"]); + expect(Array.from(next.p1).sort()).toEqual(["/wt/a", "/wt/b"]); + }); + + test("does not overwrite an existing entry (idempotent for re-fires)", () => { + const prev: SelectionState = { p1: new Set(["/wt/a"]) }; + const next = initializeProjectSelection(prev, "p1", [ + "/wt/a", + "/wt/b", + "/wt/c", + ]); + expect(next).toBe(prev); + expect(Array.from(next.p1)).toEqual(["/wt/a"]); + }); + + test("initializing with no paths creates an empty set", () => { + const next = initializeProjectSelection({}, "p1", []); + expect(next.p1).toBeDefined(); + expect(next.p1.size).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/utils/selection.ts b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/utils/selection.ts new file mode 100644 index 00000000000..2b1cae8eafc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/adopt-worktrees/utils/selection.ts @@ -0,0 +1,41 @@ +export type SelectionState = Record>; + +export function togglePathInSelection( + prev: SelectionState, + projectId: string, + path: string, +): SelectionState { + const current = new Set(prev[projectId] ?? []); + if (current.has(path)) current.delete(path); + else current.add(path); + return { ...prev, [projectId]: current }; +} + +export function toggleProjectInSelection( + prev: SelectionState, + projectId: string, + allPaths: string[], +): SelectionState { + const current = prev[projectId] ?? new Set(); + const allSelected = + allPaths.length > 0 && allPaths.every((p) => current.has(p)); + return { + ...prev, + [projectId]: allSelected ? new Set() : new Set(allPaths), + }; +} + +export function countSelected(state: SelectionState): number { + let total = 0; + for (const set of Object.values(state)) total += set.size; + return total; +} + +export function initializeProjectSelection( + prev: SelectionState, + projectId: string, + paths: string[], +): SelectionState { + if (prev[projectId]) return prev; + return { ...prev, [projectId]: new Set(paths) }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/components/OnboardingProgress/OnboardingProgress.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/components/OnboardingProgress/OnboardingProgress.tsx index da836ec96e9..7998ec60cd0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/components/OnboardingProgress/OnboardingProgress.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/components/OnboardingProgress/OnboardingProgress.tsx @@ -23,15 +23,9 @@ export function OnboardingProgress() { const currentStep = useOnboardingStore((s) => s.currentStep); const completed = useOnboardingStore((s) => s.completed); const skipped = useOnboardingStore((s) => s.skipped); - const skipAll = useOnboardingStore((s) => s.skipAll); const backTo = useSetupChromeStore((s) => s.backTo); const currentIdx = ONBOARDING_STEP_ORDER.indexOf(currentStep); - const handleSkipAll = () => { - skipAll(); - navigate({ to: "/welcome", replace: true }); - }; - const pillBase = "inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-[12px] font-medium transition-colors"; @@ -106,18 +100,7 @@ export function OnboardingProgress() { })}
-
- -
+
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/components/StepShell/StepShell.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/components/StepShell/StepShell.tsx index ac4164685bc..e7b1c5d8fe6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/components/StepShell/StepShell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/components/StepShell/StepShell.tsx @@ -78,7 +78,7 @@ interface SupersetPillProps { export function SupersetPill({ children }: SupersetPillProps) { return ( -
+
{children}
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx index d5b03f6e899..0adbe361492 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/gh-cli/page.tsx @@ -92,6 +92,9 @@ function OnboardingGhCliPage() {
Continue + + Skip for now +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx index 4da6c694274..9392e36d0f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/project/page.tsx @@ -139,8 +139,8 @@ function OnboardingProjectPage() { const supersetIcon = ( -
- +
+
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/claude-code/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/claude-code/page.tsx index 1e576793562..430065584a4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/claude-code/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/claude-code/page.tsx @@ -1,6 +1,6 @@ import { chatServiceTrpc } from "@superset/chat/client"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { AnthropicOAuthDialog } from "renderer/components/Chat/ChatInterface/components/ModelPicker/components/AnthropicOAuthDialog"; import { useAnthropicOAuth } from "renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth"; import { track } from "renderer/lib/analytics"; @@ -32,33 +32,23 @@ function ConnectClaudeCodePage() { isModelSelectorOpen: true, onModelSelectorOpenChange: () => {}, onAuthStateChange: async () => { - await refetch(); + const result = await refetch(); + if (result.data?.authenticated && !result.data.issue) { + track("onboarding_provider_connected", { + provider: "anthropic", + method: "oauth", + }); + navigate({ to: "/setup/providers", replace: true }); + } }, }); const isAuthenticated = !!status?.authenticated && !status.issue; - const wasAuthedOnMount = useRef(null); - useEffect(() => { - if (status !== undefined && wasAuthedOnMount.current === null) { - wasAuthedOnMount.current = isAuthenticated; - } - }, [status, isAuthenticated]); - useEffect(() => { goTo("providers"); }, [goTo]); - useEffect(() => { - if (wasAuthedOnMount.current === false && isAuthenticated) { - track("onboarding_provider_connected", { - provider: "anthropic", - method: "oauth", - }); - navigate({ to: "/setup/providers", replace: true }); - } - }, [isAuthenticated, navigate]); - const handleConnect = () => { void startAnthropicOAuth(); }; @@ -72,12 +62,12 @@ function ConnectClaudeCodePage() { -
- +
+
} diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/codex/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/codex/page.tsx index ba582e73588..663e6711d34 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/codex/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/codex/page.tsx @@ -1,6 +1,6 @@ import { chatServiceTrpc } from "@superset/chat/client"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { OpenAIOAuthDialog } from "renderer/components/Chat/ChatInterface/components/ModelPicker/components/OpenAIOAuthDialog"; import { useOpenAIOAuth } from "renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth"; import { track } from "renderer/lib/analytics"; @@ -22,35 +22,29 @@ function ConnectCodexPage() { const navigate = useNavigate(); const goTo = useOnboardingStore((s) => s.goTo); - const { data: status } = chatServiceTrpc.auth.getOpenAIStatus.useQuery(); + const { data: status, refetch } = + chatServiceTrpc.auth.getOpenAIStatus.useQuery(); const { isStartingOAuth, startOpenAIOAuth, oauthDialog } = useOpenAIOAuth({ isModelSelectorOpen: true, onModelSelectorOpenChange: () => {}, + onAuthStateChange: async () => { + const result = await refetch(); + if (result.data?.authenticated && !result.data.issue) { + track("onboarding_provider_connected", { + provider: "openai", + method: "oauth", + }); + navigate({ to: "/setup/providers", replace: true }); + } + }, }); const isAuthenticated = !!status?.authenticated && !status.issue; - const wasAuthedOnMount = useRef(null); - useEffect(() => { - if (status !== undefined && wasAuthedOnMount.current === null) { - wasAuthedOnMount.current = isAuthenticated; - } - }, [status, isAuthenticated]); - useEffect(() => { goTo("providers"); }, [goTo]); - useEffect(() => { - if (wasAuthedOnMount.current === false && isAuthenticated) { - track("onboarding_provider_connected", { - provider: "openai", - method: "oauth", - }); - navigate({ to: "/setup/providers", replace: true }); - } - }, [isAuthenticated, navigate]); - const handleConnect = () => { void startOpenAIOAuth(); }; @@ -64,11 +58,11 @@ function ConnectCodexPage() { -
- +
+
diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/components/SupersetIcon/SupersetIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/components/SupersetIcon/SupersetIcon.tsx index 5e0492a17c4..3d36da80fd1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/components/SupersetIcon/SupersetIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/components/SupersetIcon/SupersetIcon.tsx @@ -7,19 +7,15 @@ interface SupersetIconProps { export function SupersetIcon({ className }: SupersetIconProps) { return ( Superset - diff --git a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx index 5ed9f49e029..827f619647f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/setup/providers/page.tsx @@ -1,8 +1,7 @@ import { chatServiceTrpc } from "@superset/chat/client"; import { Spinner } from "@superset/ui/spinner"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { LuKeyRound, LuSettings } from "react-icons/lu"; import { STEP_ROUTES, useOnboardingStore } from "renderer/stores/onboarding"; import { SetupButton } from "../components/SetupButton"; @@ -11,7 +10,6 @@ import { ClaudeBrandIcon } from "./components/ClaudeBrandIcon"; import { CodexBrandIcon } from "./components/CodexBrandIcon"; import { ProviderOptionCard } from "./components/ProviderOptionCard"; -type Provider = "claude-code" | "codex"; type ConnectionMethod = "oauth" | "api-key" | "custom"; export const Route = createFileRoute("/_authenticated/setup/providers/")({ @@ -45,7 +43,6 @@ function OnboardingProvidersPage() { } }, [isStatusPending, atLeastOneConnected]); - const [provider, setProvider] = useState("claude-code"); const [claudeMethod, setClaudeMethod] = useState("oauth"); const [codexMethod, setCodexMethod] = useState("oauth"); const [reconfiguringClaude, setReconfiguringClaude] = useState(false); @@ -83,206 +80,199 @@ function OnboardingProvidersPage() { navigate({ to: STEP_ROUTES["gh-cli"] }); }; - const handleConnectSelected = () => { - const method = provider === "claude-code" ? claudeMethod : codexMethod; - const base = - provider === "claude-code" - ? "/setup/providers/claude-code" - : "/setup/providers/codex"; - if (method === "api-key") { - navigate({ to: `${base}/api-key` }); - } else if (method === "custom") { - navigate({ to: `${base}/custom` }); - } else { - navigate({ to: base }); - } + const handleConnect = ( + base: "/setup/providers/claude-code" | "/setup/providers/codex", + method: ConnectionMethod, + ) => { + if (method === "api-key") navigate({ to: `${base}/api-key` }); + else if (method === "custom") navigate({ to: `${base}/custom` }); + else navigate({ to: base }); }; const subtitle = atLeastOneConnected ? "Add another provider or continue to the next step." - : "Choose how you'd like to connect your provider."; + : "Connect Claude Code, Codex, or both to get started."; return ( - setProvider(value as Provider)} - > - - - - Claude Code - {claudeConnected && ( - - )} - - - - - Codex - {codexConnected && ( - - )} - - - - - - {claudeConnected && !reconfiguringClaude ? ( - + handleConnect("/setup/providers/claude-code", claudeMethod) + } + onReconfigure={() => setReconfiguringClaude(true)} + onCancelReconfigure={() => setReconfiguringClaude(false)} + connectedPanel={ + + } + title="Claude Code is connected" + /> + } + options={ + <> + + } - title="Claude Code is connected" - onReconfigure={() => setReconfiguringClaude(true)} + title="Claude Pro/Max" + description="Use your Claude subscription for unlimited access." + recommended + selected={claudeMethod === "oauth"} + onSelect={() => setClaudeMethod("oauth")} /> - ) : ( - <> - - } - title="Claude Pro/Max" - description="Use your Claude subscription for unlimited access." - recommended - selected={claudeMethod === "oauth"} - onSelect={() => setClaudeMethod("oauth")} - /> - } />} - title="Anthropic API Key" - description="Pay-as-you-go with your own API key." - selected={claudeMethod === "api-key"} - onSelect={() => setClaudeMethod("api-key")} - /> - } />} - title="Custom Model" - description="Use a custom base URL and model." - selected={claudeMethod === "custom"} - onSelect={() => setClaudeMethod("custom")} - /> - - )} - + } />} + title="Anthropic API Key" + description="Pay-as-you-go with your own API key." + selected={claudeMethod === "api-key"} + onSelect={() => setClaudeMethod("api-key")} + /> + } />} + title="Custom Model" + description="Use a custom base URL and model." + selected={claudeMethod === "custom"} + onSelect={() => setClaudeMethod("custom")} + /> + + } + /> - - {codexConnected && !reconfiguringCodex ? ( - handleConnect("/setup/providers/codex", codexMethod)} + onReconfigure={() => setReconfiguringCodex(true)} + onCancelReconfigure={() => setReconfiguringCodex(false)} + connectedPanel={ + + } + title="Codex is connected" + /> + } + options={ + <> + } - title="Codex is connected" - onReconfigure={() => setReconfiguringCodex(true)} + title="ChatGPT Plus/Pro" + description="Use your ChatGPT subscription via Codex." + recommended + selected={codexMethod === "oauth"} + onSelect={() => setCodexMethod("oauth")} /> - ) : ( - <> - - } - title="ChatGPT Plus/Pro" - description="Use your ChatGPT subscription via Codex." - recommended - selected={codexMethod === "oauth"} - onSelect={() => setCodexMethod("oauth")} - /> - } />} - title="OpenAI API Key" - description="Pay-as-you-go with your own API key." - selected={codexMethod === "api-key"} - onSelect={() => setCodexMethod("api-key")} - /> - } />} - title="Custom Model" - description="Use a custom base URL and model." - selected={codexMethod === "custom"} - onSelect={() => setCodexMethod("custom")} - /> - - )} - - + } />} + title="OpenAI API Key" + description="Pay-as-you-go with your own API key." + selected={codexMethod === "api-key"} + onSelect={() => setCodexMethod("api-key")} + /> + } />} + title="Custom Model" + description="Use a custom base URL and model." + selected={codexMethod === "custom"} + onSelect={() => setCodexMethod("custom")} + /> + + } + />
- {(() => { - const activeTabConnected = - provider === "claude-code" ? claudeConnected : codexConnected; - const isReconfiguring = - provider === "claude-code" - ? reconfiguringClaude - : reconfiguringCodex; + {atLeastOneConnected && ( + Continue + )} + + Skip for now + +
+
+ ); +} + +interface ProviderSectionProps { + label: string; + connected: boolean; + reconfiguring: boolean; + onConnect: () => void; + onReconfigure: () => void; + onCancelReconfigure: () => void; + connectedPanel: ReactNode; + options: ReactNode; +} + +function ProviderSection({ + label, + connected, + reconfiguring, + onConnect, + onReconfigure, + onCancelReconfigure, + connectedPanel, + options, +}: ProviderSectionProps) { + const showConnectedPanel = connected && !reconfiguring; - if (activeTabConnected && !isReconfiguring) { - return ( - - Continue - - ); - } + const headerAction = connected ? ( + + ) : null; - const providerLabel = - provider === "claude-code" ? "Claude Code" : "Codex"; - return ( - <> - - {isReconfiguring - ? `Reconfigure ${providerLabel}` - : `Connect ${providerLabel}`} - - { - if (isReconfiguring) { - if (provider === "claude-code") - setReconfiguringClaude(false); - else setReconfiguringCodex(false); - } else if (atLeastOneConnected) { - handleContinueToNextStep(); - } else { - handleSkipStep(); - } - }} - > - {isReconfiguring - ? "Cancel — keep current" - : atLeastOneConnected - ? "Continue to next step" - : "Skip for now"} - - - ); - })()} + return ( +
+
+
+

+ {label} +

+ {connected && ( + + )} +
+ {headerAction}
- + + {showConnectedPanel ? ( + connectedPanel + ) : ( + <> +
{options}
+
+ + {reconfiguring ? `Reconfigure ${label}` : `Connect ${label}`} + +
+ + )} +
); } @@ -297,31 +287,19 @@ function MutedIcon({ icon }: { icon: React.ReactNode }) { function ConnectedPanel({ icon, title, - onReconfigure, + subtitle, }: { icon: React.ReactNode; title: string; - onReconfigure: () => void; + subtitle?: string; }) { return (
{icon}
-
-

{title}

- -
-

- You can also reconfigure this provider. -

+

{title}

+ {subtitle &&

{subtitle}

}
-
); } diff --git a/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts b/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts index 859cbfa3dbd..2fbffdfa528 100644 --- a/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts +++ b/apps/desktop/src/renderer/stores/onboarding/onboardingStore.ts @@ -55,7 +55,6 @@ interface OnboardingState { interface OnboardingActions { markComplete: (step: OnboardingStep) => void; markSkipped: (step: OnboardingStep) => void; - skipAll: () => void; goTo: (step: OnboardingStep) => void; next: () => OnboardingStep | null; back: () => OnboardingStep | null; @@ -119,29 +118,6 @@ export const useOnboardingStore = create()( completedAt: allDone ? Date.now() : prev.completedAt, }); }, - skipAll: () => { - const prev = get(); - const startedAt = prev.startedAt ?? Date.now(); - track("onboarding_finished", { - outcome: "skipped_all", - duration_ms: prev.startedAt ? Date.now() - prev.startedAt : null, - }); - const skipped = { ...prev.skipped }; - for (const step of ONBOARDING_STEP_ORDER) { - if (!prev.completed[step]) { - if (!skipped[step]) { - track("onboarding_step_skipped", { step }); - } - skipped[step] = true; - } - } - set({ - skipped, - startedAt, - completedAt: Date.now(), - manualWalkthrough: false, - }); - }, goTo: (step) => { const prev = get(); if (prev.currentStep === step && prev.startedAt !== null) return; diff --git a/packages/chat/src/server/desktop/chat-service/chat-service.ts b/packages/chat/src/server/desktop/chat-service/chat-service.ts index 9f0e34aa02e..57dd4a15fcd 100644 --- a/packages/chat/src/server/desktop/chat-service/chat-service.ts +++ b/packages/chat/src/server/desktop/chat-service/chat-service.ts @@ -36,6 +36,10 @@ import { OAuthFlowController, type OAuthFlowOptions, } from "./oauth-flow-controller"; +import { + OpenAIOAuthLoopback, + parseLoopbackTargetFromAuthUrl, +} from "./openai-oauth-loopback"; type OpenAIAuthStorage = ReturnType; @@ -64,6 +68,8 @@ export class ChatService { private readonly oauthFlowController = new OAuthFlowController(() => this.getAuthStorage(), ); + private openAIOAuthLoopback: OpenAIOAuthLoopback | null = null; + private pendingOpenAIOAuthCallbackUrl: string | null = null; private readonly anthropicEnvConfigPath: string | undefined; private currentAnthropicRuntimeEnv: AnthropicRuntimeEnv = {}; private static readonly ANTHROPIC_AUTH_SESSION_TTL_MS = 10 * 60 * 1000; @@ -371,13 +377,57 @@ export class ChatService { } async startOpenAIOAuth(): Promise<{ url: string; instructions: string }> { - return this.oauthFlowController.start(this.getOpenAIOAuthFlowOptions()); + this.stopOpenAIOAuthLoopback(); + this.pendingOpenAIOAuthCallbackUrl = null; + const result = await this.oauthFlowController.start( + this.getOpenAIOAuthFlowOptions(), + ); + + const target = parseLoopbackTargetFromAuthUrl(result.url); + if (target) { + const loopback = new OpenAIOAuthLoopback(); + try { + await loopback.start({ + host: target.host, + port: target.port, + path: target.path, + onCallback: (callbackUrl) => { + // Stash the callback URL so the renderer can consume it on its + // next poll. The renderer drives completion through the same + // completeOpenAIOAuth mutation as the manual-paste flow, so + // the dialog dismissal + navigation behavior stays consistent. + this.pendingOpenAIOAuthCallbackUrl = callbackUrl; + }, + }); + this.openAIOAuthLoopback = loopback; + } catch { + // Port unavailable or other bind failure — fall back to manual paste. + loopback.stop(); + } + } + + return result; + } + + consumeOpenAIOAuthCallback(): { callbackUrl: string | null } { + const callbackUrl = this.pendingOpenAIOAuthCallbackUrl; + this.pendingOpenAIOAuthCallbackUrl = null; + return { callbackUrl }; } cancelOpenAIOAuth(): { success: true } { + this.stopOpenAIOAuthLoopback(); + this.pendingOpenAIOAuthCallbackUrl = null; return this.oauthFlowController.cancel(this.getOpenAIOAuthFlowOptions()); } + private stopOpenAIOAuthLoopback(): void { + if (this.openAIOAuthLoopback) { + this.openAIOAuthLoopback.stop(); + this.openAIOAuthLoopback = null; + } + } + async disconnectOpenAIOAuth(): Promise<{ success: true }> { const authStorage = this.getAuthStorage(); authStorage.reload(); @@ -406,10 +456,15 @@ export class ChatService { for (const providerId of OPENAI_AUTH_PROVIDER_IDS) { backupApiKeyBeforeOAuth(this.getAuthStorage(), providerId); } - await this.oauthFlowController.complete( - this.getOpenAIOAuthFlowOptions(), - input.code, - ); + try { + await this.oauthFlowController.complete( + this.getOpenAIOAuthFlowOptions(), + input.code, + ); + } finally { + this.stopOpenAIOAuthLoopback(); + this.pendingOpenAIOAuthCallbackUrl = null; + } return { success: true }; } diff --git a/packages/chat/src/server/desktop/chat-service/openai-oauth-loopback.ts b/packages/chat/src/server/desktop/chat-service/openai-oauth-loopback.ts new file mode 100644 index 00000000000..8966b9c7e0f --- /dev/null +++ b/packages/chat/src/server/desktop/chat-service/openai-oauth-loopback.ts @@ -0,0 +1,162 @@ +import { createServer, type Server } from "node:http"; + +interface LoopbackOptions { + host: string; + port: number; + path: string; + onCallback: (callbackUrl: string) => void; + onError?: (error: Error) => void; +} + +export interface LoopbackTarget { + host: string; + port: number; + path: string; +} + +export function parseLoopbackTargetFromAuthUrl( + authUrl: string, +): LoopbackTarget | null { + try { + const parsed = new URL(authUrl); + const redirectUriRaw = parsed.searchParams.get("redirect_uri"); + if (!redirectUriRaw) return null; + const redirectUri = new URL(redirectUriRaw); + // `URL.hostname` keeps the brackets on IPv6 literals (e.g. "[::1]"); + // strip them for `server.listen`, which expects the bare address. + const rawHostname = redirectUri.hostname; + const host = + rawHostname.startsWith("[") && rawHostname.endsWith("]") + ? rawHostname.slice(1, -1) + : rawHostname; + const isLoopback = + host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (!isLoopback) return null; + const port = Number(redirectUri.port); + if (!Number.isFinite(port) || port <= 0) return null; + return { host, port, path: redirectUri.pathname || "/" }; + } catch { + return null; + } +} + +export class OpenAIOAuthLoopback { + private server: Server | null = null; + + async start(options: LoopbackOptions): Promise { + return new Promise((resolve, reject) => { + // Bracket IPv6 literals when building the URL base so the parser + // accepts them; the bare `options.host` may be "::1". + const urlHost = options.host.includes(":") + ? `[${options.host}]` + : options.host; + const server = createServer((req, res) => { + try { + const requestUrl = new URL( + req.url ?? "/", + `http://${urlHost}:${options.port}`, + ); + if (requestUrl.pathname !== options.path) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Not found"); + return; + } + + const error = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code"); + if (error || !code) { + const message = error ?? "Missing authorization code"; + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); + res.end(renderErrorPage(message)); + options.onError?.(new Error(message)); + return; + } + + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(SUCCESS_PAGE); + options.onCallback(requestUrl.toString()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + try { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(message); + } catch { + // Response may have already been sent — ignore. + } + options.onError?.(err instanceof Error ? err : new Error(message)); + } + }); + + const onListenError = (err: Error) => reject(err); + server.once("error", onListenError); + server.listen(options.port, options.host, () => { + server.off("error", onListenError); + this.server = server; + resolve(); + }); + }); + } + + stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + } + } +} + +function escapeHtml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const PAGE_STYLES = ` +html, body { margin: 0; height: 100%; } +body { + display: flex; align-items: center; justify-content: center; + font-family: -apple-system, system-ui, sans-serif; + background: #151110; color: #eae8e6; +} +.card { + max-width: 380px; padding: 32px; border-radius: 12px; + border: 1px solid #2a2827; background: #201e1c; text-align: center; +} +h1 { font-size: 16px; margin: 0 0 8px; font-weight: 600; } +p { font-size: 13px; color: #a8a5a3; margin: 0; } +`; + +const SUCCESS_PAGE = ` + + + +Superset · Connected + + + +
+

Connected to OpenAI

+

You can close this tab and return to Superset.

+
+ +`; + +function renderErrorPage(message: string): string { + return ` + + + +Superset · Connection failed + + + +
+

Connection failed

+

${escapeHtml(message)}

+
+ +`; +} diff --git a/packages/chat/src/server/desktop/router/router.ts b/packages/chat/src/server/desktop/router/router.ts index 9ca143abf73..1c5a71c64cc 100644 --- a/packages/chat/src/server/desktop/router/router.ts +++ b/packages/chat/src/server/desktop/router/router.ts @@ -110,6 +110,9 @@ export function createChatServiceRouter(service: ChatService) { cancelOpenAIOAuth: t.procedure.mutation(() => { return service.cancelOpenAIOAuth(); }), + consumeOpenAIOAuthCallback: t.procedure.query(() => { + return service.consumeOpenAIOAuthCallback(); + }), disconnectOpenAIOAuth: t.procedure.mutation(() => { return service.disconnectOpenAIOAuth(); }),