diff --git a/apps/api/src/app/api/cli/create-code/route.ts b/apps/api/src/app/api/cli/create-code/route.ts new file mode 100644 index 0000000000..37cb2c305e --- /dev/null +++ b/apps/api/src/app/api/cli/create-code/route.ts @@ -0,0 +1,47 @@ +import { randomBytes } from "node:crypto"; +import { auth } from "@superset/auth/server"; +import { db } from "@superset/db/client"; +import { members } from "@superset/db/schema"; +import { Redis } from "@upstash/redis"; +import { and, eq } from "drizzle-orm"; +import { env } from "@/env"; + +const redis = new Redis({ + url: env.KV_REST_API_URL, + token: env.KV_REST_API_TOKEN, +}); + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const body = (await request.json()) as { organizationId?: string }; + const { organizationId } = body; + if (!organizationId) { + return Response.json({ error: "organizationId required" }, { status: 400 }); + } + + const membership = await db.query.members.findFirst({ + where: and( + eq(members.userId, session.user.id), + eq(members.organizationId, organizationId), + ), + }); + if (!membership) { + return Response.json( + { error: "Not a member of this organization" }, + { status: 403 }, + ); + } + + const code = randomBytes(24).toString("base64url"); + await redis.set( + `cli:code:${code}`, + { userId: session.user.id, organizationId }, + { ex: 300 }, + ); + + return Response.json({ code }); +} diff --git a/apps/api/src/app/api/cli/exchange/route.ts b/apps/api/src/app/api/cli/exchange/route.ts new file mode 100644 index 0000000000..7270274acc --- /dev/null +++ b/apps/api/src/app/api/cli/exchange/route.ts @@ -0,0 +1,51 @@ +import { auth } from "@superset/auth/server"; +import { Redis } from "@upstash/redis"; +import { env } from "@/env"; + +const redis = new Redis({ + url: env.KV_REST_API_URL, + token: env.KV_REST_API_TOKEN, +}); + +interface CodePayload { + userId: string; + organizationId: string; +} + +export async function POST(request: Request) { + const body = (await request.json()) as { code?: string }; + const { code } = body; + if (!code) { + return Response.json({ error: "code required" }, { status: 400 }); + } + + const key = `cli:code:${code}`; + const payload = await redis.get(key); + if (!payload) { + return Response.json({ error: "Invalid or expired code" }, { status: 400 }); + } + + await redis.del(key); + + if (!payload.userId || !payload.organizationId) { + return Response.json({ error: "Malformed code data" }, { status: 500 }); + } + + const context = await auth.$context; + const session = await context.internalAdapter.createSession( + payload.userId, + false, + { activeOrganizationId: payload.organizationId }, + ); + if (!session) { + return Response.json( + { error: "Failed to create session" }, + { status: 500 }, + ); + } + + return Response.json({ + token: session.token, + expiresAt: session.expiresAt.toISOString(), + }); +} diff --git a/apps/desktop/plans/v1-create-scenario-analysis.md b/apps/desktop/plans/v1-create-scenario-analysis.md new file mode 100644 index 0000000000..8fd4af6637 --- /dev/null +++ b/apps/desktop/plans/v1-create-scenario-analysis.md @@ -0,0 +1,198 @@ +# V1 Workspace Creation — Scenario Analysis + +Walks through every user scenario in the V1 create flow. Identifies what works, what's wrong, and what V2 should do differently. + +--- + +## Scenario 1: Prompt only (most common) + +**User action:** Types "fix the login bug", hits Cmd+Enter. No workspace name, no branch name. + +**Renderer** (`PromptGroup.tsx:740-806`): +1. `displayName = "fix the login bug"` (from `trimmedPrompt`) +2. `willGenerateAIName = true` (no branchNameEdited, has prompt, no PR) +3. Shows pending workspace with "generating-branch" status +4. Calls `generateBranchNameMutation.mutateAsync({ prompt, projectId })` with 30s timeout +5. **AI succeeds** → `aiBranchName = "fix-login-bug"` +6. Sends to server: `{ name: undefined, prompt: "fix the login bug", branchName: "fix-login-bug" }` + +**Server** (`create.ts:369-374`): +1. `input.branchName` is set → `branch = sanitizeBranchNameWithMaxLength(withPrefix("fix-login-bug"))` → e.g. `"kiet/fix-login-bug"` +2. Collision check runs (line 382): `input.branchName?.trim()` is truthy +3. No existing workspace on that branch → creates new worktree + workspace +4. `workspace.name = input.name ?? branch = "kiet/fix-login-bug"` (since `name` is undefined) +5. `isUnnamed: true` + +**Post-create** (`useCreateWorkspace.ts:79-100`): +1. `wasExisting = false` → sets up pending terminal, runs setup script +2. Navigates to workspace + +**UX issues:** +- ✅ Works well when AI succeeds +- ❌ **Workspace name becomes the branch name** (`"kiet/fix-login-bug"`) because `input.name` was undefined. User sees a slash-separated branch string as their workspace title instead of their prompt. +- The `isUnnamed: true` flag triggers a post-create auto-rename via `attemptWorkspaceAutoRenameFromPrompt` in `initializeWorkspaceWorktree` (`create.ts:523`), which is ANOTHER AI call. So there are TWO serial AI calls: one for branch name, one for workspace display name. + +--- + +## Scenario 2: Prompt only, AI branch gen fails + +**User action:** Same as Scenario 1 but AI times out or auth fails. + +**Renderer** (`PromptGroup.tsx:780-806`): +1. Catches error, shows `"Using random branch name"` toast +2. `aiBranchName = null` +3. Sends to server: `{ name: undefined, prompt: "fix the login bug", branchName: undefined }` + +**Server** (`create.ts:376-380`): +1. `input.branchName` is undefined → hits the `else` branch +2. `branch = generateBranchName({ existingBranches, authorPrefix })` → e.g. `"kiet/cheerful-umbrella"` +3. Collision check at line 382: `input.branchName?.trim()` is **falsy** → **collision check SKIPPED entirely** +4. Creates new worktree + workspace +5. `workspace.name = "kiet/cheerful-umbrella"` + +**UX issues:** +- ✅ Always creates a new workspace (random name can't collide) +- ❌ Workspace name is `"kiet/cheerful-umbrella"` — meaningless to the user +- ❌ Post-create auto-rename (another AI call) may also fail, leaving the random name permanently + +--- + +## Scenario 3: Explicit workspace name, no branch name + +**User action:** Types workspace name "Login Fix", types prompt, no branch name. + +**Renderer** (`PromptGroup.tsx:984-989`): +1. `workspaceNameEdited = true`, `workspaceName = "Login Fix"` +2. `branchNameEdited = false` +3. AI branch gen runs (same as Scenario 1) +4. Sends: `{ name: "Login Fix", prompt: "...", branchName: "fix-login-bug" }` + +**Server**: +1. Branch from AI name → `"kiet/fix-login-bug"` +2. Collision check runs (branchName was set) +3. Creates workspace with `name: "Login Fix"` (input.name is set) +4. `isUnnamed: false` + +**UX:** ✅ Works correctly. User sees "Login Fix" as workspace name. + +--- + +## Scenario 4: Explicit branch name, no workspace name + +**User action:** Types branch name "feature/auth-fix" in the branch input, types prompt. + +**Renderer** (`PromptGroup.tsx:990-999`): +1. `branchNameEdited = true`, `branchName = "feature/auth-fix"` +2. `willGenerateAIName = false` (branchNameEdited is true) +3. AI branch gen does NOT run +4. Sends: `{ name: undefined, prompt: "...", branchName: "feature/auth-fix" }` + +**Server** (`create.ts:369-374`): +1. `branch = sanitizeBranchNameWithMaxLength(withPrefix("feature/auth-fix"))` → `"kiet/feature/auth-fix"` +2. Collision check runs (branchName was set) +3. If branch already has a workspace → returns `{ wasExisting: true }`, navigates to existing +4. If no collision → creates new, `workspace.name = "kiet/feature/auth-fix"`, `isUnnamed: true` + +**UX issues:** +- ❌ Collision check fires because `input.branchName?.trim()` is truthy — **even though the user might not intend to open an existing workspace**. They typed a branch name for a NEW workspace and it silently opens something else. +- ❌ Workspace name is the prefixed branch string, not the prompt +- ❌ No user confirmation: "This branch already has a workspace, open it?" — just silently navigates + +--- + +## Scenario 5: No prompt, no name, no branch (empty create) + +**User action:** Hits Cmd+Enter with nothing filled in. + +**Renderer** (`PromptGroup.tsx:740-746`): +1. `displayName = "New workspace"` +2. `willGenerateAIName = false` (no trimmedPrompt) +3. No AI branch gen +4. Sends: `{ name: undefined, prompt: undefined, branchName: undefined }` + +**Server** (`create.ts:376-380`): +1. All undefined → `branch = generateBranchName(...)` → random `"kiet/cheerful-umbrella"` +2. Collision check skipped (branchName wasn't set) +3. Creates workspace with `name: "kiet/cheerful-umbrella"`, `isUnnamed: true` + +**UX issues:** +- ✅ Always works (random name) +- ❌ Meaningless workspace name +- ❌ Post-create rename has no prompt to derive from, so the auto-rename AI call has nothing to work with → stays as random name + +--- + +## Scenario 6: PR link (create from PR) + +**User action:** Links a PR, types a prompt, hits create. + +**Renderer** (`PromptGroup.tsx:960-978`): +1. `linkedPR` is set → takes a completely different code path +2. Calls `createFromPr.mutateAsyncWithSetup({ projectId, prUrl }, launchRequest)` +3. Does NOT call `createWorkspace` at all + +**V1 `createFromPr`** (`useCreateFromPr.ts`): +1. Calls `electronTrpc.workspaces.createFromPr.mutateAsync({ projectId, prUrl })` +2. Server clones the PR's head branch, creates worktree +3. Workspace name = PR title +4. Branch name = PR head branch + +**UX:** ✅ Works well. PR provides all naming context. + +--- + +## Scenario 7: Branch selected from base-branch picker, then create + +**User action:** Opens base-branch picker, selects `feature/existing`, then hits create with a prompt. + +**Renderer**: +1. `compareBaseBranch = "feature/existing"` is set +2. This is the BASE branch (what the new branch forks from), NOT the workspace branch +3. AI branch gen runs normally, creates a new branch from `feature/existing` + +**UX:** ✅ Works correctly. The base-branch picker only sets the fork point. + +--- + +## Scenario 8: Branch selected from base-branch picker, "Open" action on existing workspace + +**User action:** Opens base-branch picker, sees a branch with an active workspace, clicks "Open". + +**Renderer** (`PromptGroup.tsx:1111-1117`): +1. Calls `handleOpenActiveWorkspace(workspaceId)` +2. Closes modal, navigates to existing workspace +3. Does NOT call create at all + +**UX:** ✅ Works correctly. Clear intent from user action. + +--- + +## Summary of V1 issues + +### Naming +1. **Workspace display name is the branch name when user didn't type a name.** The user typed a prompt but the workspace gets named `"kiet/fix-login-bug"` or `"kiet/cheerful-umbrella"` instead of their prompt or a human-friendly derivative. +2. **Two serial AI calls** — one for branch name (renderer), one for auto-rename (server). Both can fail independently, and the auto-rename runs after create, so the user sees the branch name flash then change. +3. **Random names are meaningless** when AI fails — `"cheerful-umbrella"` tells you nothing about the workspace. + +### Collision behavior +4. **Silent open on branch collision** — when user types a branch name that already has a workspace, V1 silently navigates to the existing one with `wasExisting: true` and toast still says "Workspace created." No confirmation dialog, no visual indication that the user's prompt/attachments/agent selection were all ignored. +5. **Collision check gate is fragile** — it's based on `input.branchName?.trim()`, which means collision check only runs for user-typed branch names. But the USER might have typed a branch name intending to create a new workspace on that branch. The condition conflates "user provided a name" with "check for collisions." + +### Architecture +6. **Branch name generation split across renderer + server** — the renderer does AI generation, the server does random fallback. The server also does prefix application. Two different processes own parts of the branch name logic, making it hard to reason about what name you'll get. +7. **`useExistingBranch` boolean is a separate code path** — adds complexity to the input schema and collision logic for what could be a single `behavior.onExistingBranch: "use" | "error"` flag. +8. **`sourceWorkspaceId` adds another code path** for forking from an existing workspace's branch — but none of this is exercised by the modal UI. It's dead surface area in the create endpoint. + +--- + +## What V2 should do differently + +| V1 problem | V2 approach | +|------------|-------------| +| Workspace name = branch name | `workspaceName = input.prompt \|\| branchName` — prompt is always preferred for display name | +| Two serial AI calls | Single AI call (renderer) for branch name; workspace display name derived from prompt synchronously (no second AI call) | +| Silent open on collision | When branch collision detected and user provided explicit branch: return `opened_existing_workspace` outcome + renderer shows distinct toast "Opened existing workspace" (not "Workspace created") | +| Random names when AI fails | Derive from prompt slug (`sanitizeBranchNameWithMaxLength(prompt)`) before falling back to random. Random is last resort, not first fallback. | +| Collision check gate tied to `input.branchName` | Gate on a semantic flag: was the branch name auto-generated or user-provided? Only run collision check on user-provided names. | +| Branch name logic split renderer/server | Server owns all branch name resolution. Renderer sends `prompt` + optional `branchName`. Server derives branch from prompt, applies deduplication, skips collision check on auto-generated names. | +| `useExistingBranch` / `sourceWorkspaceId` dead paths | Not in V2 schema. Single `behavior.onExistingWorkspace` / `behavior.onExistingWorktree` flags. | diff --git a/apps/desktop/plans/v2-create-decisions-final.md b/apps/desktop/plans/v2-create-decisions-final.md new file mode 100644 index 0000000000..367e272ce0 --- /dev/null +++ b/apps/desktop/plans/v2-create-decisions-final.md @@ -0,0 +1,124 @@ +# V2 Create Workspace — Final Decisions + +## 1. Branch name generation + +**Owner:** Renderer. + +**Phase 1 flow:** +1. User typed a branch name → use it +2. No branch name → derive from prompt slug (`sanitizeBranchNameWithMaxLength(prompt)` → `fix-the-login-bug`) +3. No prompt either → `workspace-${crypto.randomUUID().slice(0, 8)}` + +Host-service receives a branch name every time. Never `undefined`. Host-service deduplicates it (decision #8). + +**Phase 2:** AI branch gen runs async in parallel — fire at submit, don't block create. If AI returns before the host-service call, swap in the better name. Requires `workspaceCreation.generateBranchName` on host-service (decision #3). + +## 2. Workspace display name + +**Owner:** Renderer. + +Renderer sends `workspaceName` explicitly: +- User typed a name → use it +- No name → use prompt text (truncated) +- No prompt → use branch name + +No post-create AI rename. No flash. What you see in the modal is what you get. + +## 3. AI branch name generation + +**Behavior:** Not in Phase 1. Use prompt slug (`sanitizeBranchNameWithMaxLength(prompt)`) — purely client-side, no backend call, no project ID needed. + +**No `electronTrpc` calls from V2 modal for workspace operations.** The existing `electronTrpc.workspaces.generateBranchName` is a V1 endpoint that requires a V1 local project ID. The V2 modal must not call it — that's a boundary violation. + +**Phase 2 migration:** Add `workspaceCreation.generateBranchName({ projectId, prompt })` to host-service. It has the repo (for branch dedup) and needs a model provider for the AI call. The gap is the host-service doesn't have access to the user's AI credentials today (`callSmallModel` reads from Electron settings). Either extend the host-service model provider for utility calls, or proxy through it to the user's config. + +**How V1's AI branch gen works** (for reference): +1. `callSmallModel` picks user's configured provider (OpenAI/Anthropic) +2. Sends prompt with instruction: "Generate a concise git branch name (2-4 words, kebab-case)" +3. Sanitizes + deduplicates against existing branches +4. Returns name without prefix (server applies prefix) +5. Needs: repo path (for branch list), git author config (for prefix), AI credentials + +**File:** `apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts` + +## 4. Collision detection + +**None.** No collision detection. No `opened_existing_workspace` outcome from the create modal flow. + +The host-service receives a branch name and deduplicates it against existing branches. If `fix-the-login-bug` exists, it becomes `fix-the-login-bug-2`. Always creates a new workspace. Never silently opens an existing one. + +If the user wants to open an existing workspace, they use the sidebar. + +## 5. Collision UX + +**N/A.** There is no collision — the branch name is always unique after dedup. The create modal always creates. + +Remove the `opened_existing_workspace`, `opened_worktree`, and `adopted_external_worktree` outcome paths from the create flow. The only outcome is `created_workspace`. + +## 6. Modal close timing + +**Behavior:** Close immediately on submit. Show pending workspace skeleton in sidebar. Navigate when create succeeds. + +**Draft preservation:** Stash a snapshot of the draft into a zustand atom before closing. Close + reset the modal normally. On create failure, restore the stash and reopen the modal so the user can retry. On create success, clear the stash. + +This doesn't depend on the context provider staying mounted — the zustand atom survives route changes and component lifecycle. + +## 7. Pending workspace phases + +**Single phase:** `creating`. That's it. + +No `generating-branch` (no blocking AI), no `preparing` (no renderer-side prep between close and API call). Skeleton appears, host-service call runs, skeleton resolves to workspace or error. + +## 8. Worktree creation always creates a new branch + +**Behavior:** Always `git worktree add -b branchName worktreePath baseBranch`. If the branch name already exists (despite dedup), fall back to a new deduplicated name — never check out the existing branch. + +Checking out an existing branch into a worktree is a separate intent (e.g. "import existing branch" or `createFromPr`). The create flow should never silently check out someone else's branch. If the `-b` fails because the branch exists, append a suffix and retry — don't switch to a checkout. + +V1's try/catch pattern (`worktree add` then fallback to `worktree add -b`) conflates "create" and "checkout" intents. V2 keeps them separate. + +## 9. Branch dedup + +**Owner:** Host-service (at create time). + +Host-service has the authoritative branch list from git at create time. It deduplicates the incoming branch name against existing branches. `fix-the-login-bug` → `fix-the-login-bug-2` if it exists. Renderer sends its best-effort name; host-service guarantees uniqueness. + +## 9. `sanitizeBranchNameWithMaxLength` location + +**Decision:** Copy into host-service for dedup. Renderer keeps its copy for the branch name preview in the UI. Both need it — renderer for preview, host-service for dedup + sanitization of the final name. + +## 10. Host-service input schema + +`names.branchName` is always provided by renderer. `names.workspaceName` is always provided by renderer. Host-service sanitizes + deduplicates `branchName` before creating the worktree. Uses `workspaceName` as-is for the cloud row display name. + +## 11. Return shape + +Simplified. No `outcome` field — create always creates. + +```ts +{ workspace: { id, branch, ... }, warnings: string[] } +``` + +Remove `opened_existing_workspace`, `opened_worktree`, `adopted_external_worktree` outcomes and all their code paths from the host-service. The create endpoint does one thing: create a workspace on a deduplicated branch name. + +## 12. PR create path + +**Separate endpoint:** `workspaceCreation.createFromPr({ projectId, prUrl })`. + +The PR flow is fundamentally different from normal create — it parses a PR URL, fetches metadata (title, head branch, fork info), checks out the PR's branch with remote tracking, and handles cross-repo fork PRs. None of this overlaps with the normal branch-name-from-prompt flow. + +V1 does this as a separate `createFromPr` mutation that takes just `{ projectId, prUrl }` and the server does all resolution. V2 should do the same, using Octokit instead of `gh` CLI. + +**Not in Phase 1.** For now, when user links a PR, the renderer uses the PR's head branch as `branchName` and PR title as `workspaceName`, then calls the normal `create` endpoint. This creates a worktree on a new local branch — it doesn't check out the actual PR with remote tracking. Good enough for Phase 1, but loses fork PR support and proper remote setup. + +Phase 2 adds `workspaceCreation.createFromPr` with full PR checkout semantics. + +## 13. Init/setup flow + +**Streamlined.** After worktree + cloud row creation, if `runSetupScript` is true, the host-service runs the setup script (`.superset/setup.sh` or equivalent) inside the worktree before returning. No background job manager, no progress events, no separate init system. + +Create blocks until setup is done. The workspace is fully ready when the renderer navigates to it. + +V1's complexity (workspaceInitManager, initializeWorkspaceWorktree, async AI rename, progress tracking) is unnecessary. The only post-create work is running a shell script in the worktree directory. + +If setup scripts turn out to be too slow (>10s), we can split later: return immediately, run setup async, show "running setup..." in the workspace view. But start simple. diff --git a/apps/desktop/plans/v2-workspace-init-status.md b/apps/desktop/plans/v2-workspace-init-status.md new file mode 100644 index 0000000000..e8921938d4 --- /dev/null +++ b/apps/desktop/plans/v2-workspace-init-status.md @@ -0,0 +1,298 @@ +# V2 Workspace Creation Status — Design + +## Concept + +When the user creates a workspace, navigate immediately to a pending workspace page. The host-service writes progress to an in-memory map that the pending page polls for step-by-step detail. The create promise runs independently of component lifecycle (fire-and-forget from PromptGroup) and updates the `pendingWorkspaces` collection on resolve/reject. + +Multiple workspaces can be creating simultaneously. Each has its own sidebar skeleton, clickable to view progress. + +## Ownership + +Three actors, each with a clear responsibility: + +1. **PromptGroup** (fires create, owns the promise): inserts pending row into collection, fires `void createWorkspace(...)`, updates collection to `succeeded`/`failed` when the promise resolves/rejects. Runs independently of component lifecycle — the async closure keeps running after the modal unmounts. Only touches the collection (not React state), so no stale-component issues. + +2. **Sidebar** (always visible): reads `pendingWorkspaces` via `useLiveQuery`. Shows skeleton + status. Clickable. Does not poll — just reacts to collection changes. + +3. **Pending page** (optional progress viewer): polls `workspaceCreation.getProgress({ pendingId })` every 500ms for step-by-step detail. Only runs while the page is mounted. If user navigates away, polling stops but create continues. On `succeeded`, auto-navigates to real workspace. + +## Data model: `pendingWorkspaces` local collection + +Backed by `localStorageCollectionOptions` from `@tanstack/react-db`, same as `v2SidebarProjects`, `v2WorkspaceLocalState`, etc. Persists to localStorage, survives app restart. + +```ts +export const pendingWorkspaceSchema = z.object({ + // Identity + id: z.string().uuid(), // renderer-generated, NOT the eventual workspace ID + projectId: z.string().uuid(), + + // Draft data (preserved for retry on failure) + name: z.string(), // resolved workspace display name + branchName: z.string(), // resolved branch name + prompt: z.string(), + compareBaseBranch: z.string().nullable(), + runSetupScript: z.boolean(), + linkedIssues: z.array(z.unknown()), + linkedPR: z.unknown().nullable(), + hostTarget: z.unknown(), // WorkspaceHostTarget + + // Status (updated by PromptGroup's create promise) + status: z.enum(["creating", "failed", "succeeded"]), + error: z.string().nullable(), // set when status === "failed" + workspaceId: z.string().nullable(),// set when status === "succeeded" + + createdAt: persistedDateSchema, +}); +``` + +**Lifecycle:** +1. On submit: insert row with `status: "creating"` +2. Promise resolves: set `status: "succeeded"`, `workspaceId: realId` +3. Promise rejects: set `status: "failed"`, `error: message` +4. On navigate to real workspace: delete the pending row +5. On retry (from failed page): reset `status: "creating"`, re-fire create +6. On dismiss: delete the pending row + +## Progress polling + +### Host-service: in-memory progress map + +```ts +// Module-level (not persisted, not in DB) +const createProgress = new Map(); +``` + +The create mutation writes its current step as it progresses: + +```ts +createProgress.set(input.pendingId, { step: "ensuring_repo" }); +// ... clone/resolve ... +createProgress.set(input.pendingId, { step: "creating_worktree" }); +// ... git worktree add ... +createProgress.set(input.pendingId, { step: "registering" }); +// ... cloud API ... +createProgress.delete(input.pendingId); // done — tRPC response carries the result +``` + +### Host-service: query endpoint + +```ts +workspaceCreation.getProgress({ pendingId }) +// → { step: "creating_worktree" } | null (not found / already done) +``` + +### Pending page: polls with react-query + +```ts +const { data: progress } = useQuery({ + queryKey: ["workspaceCreation", "getProgress", pendingId], + queryFn: () => client.workspaceCreation.getProgress.query({ pendingId }), + refetchInterval: 500, + enabled: pendingWorkspace?.status === "creating", +}); +``` + +~10 small queries over 5 seconds. Polling stops when status changes to `succeeded` or `failed` (detected via `useLiveQuery` on the collection). + +## Flow + +``` +User clicks Create + ↓ +PromptGroup: + 1. Compute names (branch, workspace) + 2. Insert into pendingWorkspaces collection (status: "creating") + 3. Store attachments in IndexedDB + 4. Close modal + 5. Navigate to /v2-workspace/pending/$pendingId + 6. Fire void createWorkspace(...) — runs independently + ↓ +Pending page mounts, starts polling: +┌──────────────────────────────────────────┐ +│ fix the login bug │ +│ ⑂ fix-the-login-bug │ +│ │ +│ Creating workspace... │ +│ ├─ Ensuring local repository ✓ │ +│ ├─ Creating worktree ✓ │ +│ ├─ Registering workspace ● │ +│ │ +└──────────────────────────────────────────┘ + ↓ +PromptGroup's create promise resolves: + → Updates collection: status: "succeeded", workspaceId: realId + ↓ +Pending page detects succeeded (via useLiveQuery): + → Stops polling + → Navigates to /v2-workspace/$workspaceId + → Dispatches initialCommands to terminal pane + ↓ +Normal workspace UI (setup running in terminal) +``` + +**On failure:** +``` +PromptGroup's create promise rejects: + → Updates collection: status: "failed", error: message + ↓ +Pending page detects failed (via useLiveQuery): + → Stops polling + → Shows error + Retry + Dismiss +``` + +**If user navigates away from pending page:** +- Polling stops (component unmounts) +- Create promise still runs (it's a closure, not tied to React lifecycle) +- Collection still gets updated on resolve/reject +- Sidebar skeleton still reflects current status +- User can click skeleton to return to pending page + +## Sidebar behavior + +The sidebar renders pending workspaces from the `pendingWorkspaces` collection alongside real workspaces from `v2Workspaces`: + +- **Creating:** workspace name + spinner + "Creating..." label +- **Failed:** workspace name + error badge +- **Succeeded:** brief flash, then replaced by the real workspace from collections + +All states are clickable — navigate to `/v2-workspace/pending/$id`. + +## Input schema update + +Add `pendingId` to create input: + +```ts +workspaceCreation.create({ + pendingId: z.string(), // renderer-generated UUID for progress polling correlation + projectId: z.string(), + names: { ... }, + composer: { ... }, + linkedContext: { ... }, +}) +``` + +## Return shape update + +Add `initialCommands`: + +```ts +{ + workspace: { id, branch, ... }, + initialCommands: string[] | null, + warnings: string[], +} +``` + +Host-service reads setup config, returns commands, does not execute them. Renderer dispatches to terminal pane. + +## Pending workspace route + +**Route:** `/v2-workspace/pending/$pendingId` + +- Reads pending workspace from `pendingWorkspaces` collection via `useLiveQuery` +- Polls `workspaceCreation.getProgress` for step detail while `status === "creating"` +- Shows workspace name + branch name + step progress +- On `succeeded` (detected via collection): auto-navigate to `/v2-workspace/$workspaceId` +- On `failed` (detected via collection): error message + Retry + Dismiss + +## Retry flow + +From the failed pending page: +1. User clicks Retry +2. Update the pending row: `status: "creating"`, clear `error` +3. Re-fire `createWorkspace` with the same data from the pending row + attachments from IndexedDB +4. Same polling + collection-update flow as initial create + +## Replaces + +| Old | New | +|-----|-----| +| `pendingWorkspace` in zustand store (single item) | `pendingWorkspaces` local collection (multiple) | +| `stashedDraft` zustand atom | Draft data lives in the pending row itself | +| `setPendingWorkspace` / `clearPendingWorkspace` / `setPendingWorkspaceStatus` | Collection insert / update / delete | +| `restoreStashedDraft` (reopen modal) | Retry from pending page (no modal reopen) | + +## Files to change + +### Host-service +| File | Change | +|------|--------| +| `.../workspace-creation/workspace-creation.ts` | Accept `pendingId`, write to in-memory progress map, add `getProgress` query, remove `execSync`, return `initialCommands` | + +### Renderer — data +| File | Change | +|------|--------| +| `.../CollectionsProvider/dashboardSidebarLocal/schema.ts` | Add `pendingWorkspaceSchema` | +| `.../CollectionsProvider/collections.ts` | Add `pendingWorkspaces` collection | +| `renderer/stores/new-workspace-modal.ts` | Remove `pendingWorkspace`, `stashedDraft` and related actions (moved to collection) | +| **New:** `renderer/lib/pending-attachment-store.ts` | IndexedDB wrapper for attachment blobs | + +### Renderer — UI +| File | Change | +|------|--------| +| **New:** `.../v2-workspace/pending/$pendingId/page.tsx` | Pending workspace progress page (polls getProgress, navigates on success) | +| `.../PromptGroup/PromptGroup.tsx` | Insert into collection, store attachments in IndexedDB, fire-and-forget create, update collection on resolve/reject | +| `.../DashboardSidebar/...` | Query `pendingWorkspaces` collection, render skeletons | + +## Attachments: IndexedDB blob storage + +Attachments (images, PDFs, markdown files) can't go in the localStorage-backed collection — they're too large. Store raw blobs in IndexedDB alongside the pending workspace metadata. + +### Storage pattern + +```ts +// Key scheme: "pending-attachments/${pendingId}/${index}-${filename}" + +// On import (user adds file in modal): +const blob = await fetch(blobUrl).then(r => r.blob()); +await idb.put("pending-attachments", { + blob, + mediaType: file.mediaType, + filename: file.filename, +}, `${pendingId}/${index}-${file.filename}`); + +// On submit: +// Read blobs from IndexedDB → convert to data URLs → send in API payload + +// On retry: +// Read same blobs → convert again + +// On success or dismiss: +// Delete all entries matching pendingId prefix +``` + +### No compression + +Images and PDFs are already compressed — gzipping saves 0-2%. IndexedDB has no practical size limit. These blobs are ephemeral (seconds to minutes). Not worth the CPU cost. + +### Pending workspace row stores metadata only + +The `pendingWorkspaces` collection row holds attachment metadata (not data): + +```ts +attachments: z.array(z.object({ + filename: z.string(), + mediaType: z.string(), + size: z.number(), +})).default([]), +``` + +The actual blobs are in IndexedDB, keyed by `pendingId`. + +### Files + +| File | Change | +|------|--------| +| **New:** `renderer/lib/pending-attachment-store.ts` | IndexedDB wrapper: `storeAttachments(pendingId, files)`, `loadAttachments(pendingId)`, `clearAttachments(pendingId)` | +| `.../PromptGroup/PromptGroup.tsx` | Store attachments to IndexedDB on submit, load on retry | + +## Not in scope + +- Attachment compression (not needed — IndexedDB has no size limit, most files already compressed) +- Agent launch (Phase 2) +- AI workspace rename (dropped) +- Streaming setup output (setup runs in terminal pane — user sees it live) + +## TODO: cleanup + +- **Clean up module-level `createProgress` Map.** Entries are deleted on create completion, but if the process crashes mid-create or the promise is abandoned, stale entries leak. Add a TTL sweep (e.g. delete entries older than 5 minutes on each `getProgress` call) or use a `WeakRef`-based approach. Not urgent — the map holds tiny objects and the host-service restarts clear it. 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 27f6d5807a..ea296b5c34 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -726,174 +726,177 @@ function PromptGroupInner({ [], ); - const handleCreate = useCallback(async () => { - if (!projectId) { - toast.error("Select a project first"); - return; - } + const handleCreate = useCallback( + async (preConvertedFiles?: ConvertedFile[]) => { + if (!projectId) { + toast.error("Select a project first"); + return; + } - if (submitStartedRef.current) { - return; - } - submitStartedRef.current = true; - - const displayName = - workspaceNameEdited && workspaceName.trim() - ? workspaceName.trim() - : trimmedPrompt || "New workspace"; - const willGenerateAIName = - !branchNameEdited && !!trimmedPrompt && !linkedPR; - const pendingWorkspaceId = crypto.randomUUID(); - const detachedFiles = attachments.takeFiles(); - - setPendingWorkspace({ - id: pendingWorkspaceId, - projectId, - name: displayName, - status: willGenerateAIName ? "generating-branch" : "preparing", - }); - closeAndResetDraft(); + if (submitStartedRef.current) { + return; + } + submitStartedRef.current = true; + + const displayName = + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : trimmedPrompt || "New workspace"; + const willGenerateAIName = + !branchNameEdited && !!trimmedPrompt && !linkedPR; + const pendingWorkspaceId = crypto.randomUUID(); + const detachedFiles = preConvertedFiles ? [] : attachments.takeFiles(); + + setPendingWorkspace({ + id: pendingWorkspaceId, + projectId, + name: displayName, + status: willGenerateAIName ? "generating-branch" : "preparing", + }); + closeAndResetDraft(); - try { - let aiBranchName: string | null = null; - if (willGenerateAIName) { - let timeoutId: NodeJS.Timeout | null = null; - try { - const AI_GENERATION_TIMEOUT_MS = 30000; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error("AI generation timeout")), - AI_GENERATION_TIMEOUT_MS, - ); - }); + try { + let aiBranchName: string | null = null; + if (willGenerateAIName) { + let timeoutId: NodeJS.Timeout | null = null; + try { + const AI_GENERATION_TIMEOUT_MS = 30000; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("AI generation timeout")), + AI_GENERATION_TIMEOUT_MS, + ); + }); + + const result = await Promise.race([ + generateBranchNameMutation.mutateAsync({ + prompt: trimmedPrompt, + projectId, + }), + timeoutPromise, + ]); - const result = await Promise.race([ - generateBranchNameMutation.mutateAsync({ - prompt: trimmedPrompt, - projectId, - }), - timeoutPromise, - ]); + if (timeoutId) clearTimeout(timeoutId); + aiBranchName = result.branchName; + } catch (error) { + if (timeoutId) clearTimeout(timeoutId); + + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes("timeout")) { + console.warn("[PromptGroup] AI generation timeout"); + toast.info("Using random branch name (AI generation timed out)"); + } else if ( + errorMessage.toLowerCase().includes("auth") || + errorMessage.includes("401") || + errorMessage.includes("403") + ) { + console.error("[PromptGroup] AI auth error:", error); + toast.error( + "AI authentication failed. Please check your AI settings.", + ); + clearPendingWorkspace(pendingWorkspaceId); + return; + } else { + console.warn("[PromptGroup] AI generation failed:", error); + toast.info( + "Using random branch name (AI generation unavailable)", + ); + } + } finally { + setPendingWorkspaceStatus(pendingWorkspaceId, "preparing"); + } + } - if (timeoutId) clearTimeout(timeoutId); - aiBranchName = result.branchName; - } catch (error) { - if (timeoutId) clearTimeout(timeoutId); - - const errorMessage = - error instanceof Error ? error.message : String(error); - if (errorMessage.includes("timeout")) { - console.warn("[PromptGroup] AI generation timeout"); - toast.info("Using random branch name (AI generation timed out)"); - } else if ( - errorMessage.toLowerCase().includes("auth") || - errorMessage.includes("401") || - errorMessage.includes("403") - ) { - console.error("[PromptGroup] AI auth error:", error); - toast.error( - "AI authentication failed. Please check your AI settings.", + let convertedFiles: ConvertedFile[] = preConvertedFiles ?? []; + if (!preConvertedFiles && detachedFiles.length > 0) { + try { + convertedFiles = await Promise.all( + detachedFiles.map(async (file) => ({ + data: await convertBlobUrlToDataUrl(file.url), + mediaType: file.mediaType, + filename: file.filename, + })), ); + } catch (err) { clearPendingWorkspace(pendingWorkspaceId); + toast.error( + err instanceof Error + ? err.message + : "Failed to process attachments", + ); return; - } else { - console.warn("[PromptGroup] AI generation failed:", error); - toast.info("Using random branch name (AI generation unavailable)"); } - } finally { - setPendingWorkspaceStatus(pendingWorkspaceId, "preparing"); } - } - let convertedFiles: ConvertedFile[] = []; - if (detachedFiles.length > 0) { - try { - convertedFiles = await Promise.all( - detachedFiles.map(async (file) => ({ - data: await convertBlobUrlToDataUrl(file.url), - mediaType: file.mediaType, - filename: file.filename, - })), - ); - } catch (err) { - clearPendingWorkspace(pendingWorkspaceId); - toast.error( - err instanceof Error - ? err.message - : "Failed to process attachments", - ); - return; - } - } - - // Fetch and attach GitHub issue content - const githubIssues = linkedIssues.filter( - (issue): issue is typeof issue & { number: number } => - issue.source === "github" && typeof issue.number === "number", - ); - if (githubIssues.length > 0 && projectId) { - try { - // Helper to add timeout to promises - const fetchWithTimeout = ( - promise: Promise, - timeoutMs: number, - ): Promise => { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Request timeout")), - timeoutMs, + // Fetch and attach GitHub issue content + const githubIssues = linkedIssues.filter( + (issue): issue is typeof issue & { number: number } => + issue.source === "github" && typeof issue.number === "number", + ); + if (githubIssues.length > 0 && projectId) { + try { + // Helper to add timeout to promises + const fetchWithTimeout = ( + promise: Promise, + timeoutMs: number, + ): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Request timeout")), + timeoutMs, + ), ), - ), - ]); - }; - - const issueContents = await Promise.all( - githubIssues.map(async (issue) => { - try { - const content = await fetchWithTimeout( - utils.client.projects.getIssueContent.query({ - projectId, - issueNumber: issue.number, - }), - 10000, // 10 second timeout per issue - ); - - // Sanitize user-generated content to prevent injection - const sanitizeText = (str: string) => - str.replace(/[&<>"']/g, (char) => { - const entities: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return entities[char] || char; - }); - - const sanitizeUrl = (url: string) => { - try { - const parsed = new URL(url); - // Only allow http/https protocols - if (!["http:", "https:"].includes(parsed.protocol)) { + ]); + }; + + const issueContents = await Promise.all( + githubIssues.map(async (issue) => { + try { + const content = await fetchWithTimeout( + utils.client.projects.getIssueContent.query({ + projectId, + issueNumber: issue.number, + }), + 10000, // 10 second timeout per issue + ); + + // Sanitize user-generated content to prevent injection + const sanitizeText = (str: string) => + str.replace(/[&<>"']/g, (char) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char] || char; + }); + + const sanitizeUrl = (url: string) => { + try { + const parsed = new URL(url); + // Only allow http/https protocols + if (!["http:", "https:"].includes(parsed.protocol)) { + return "#invalid-url"; + } + return url; + } catch { return "#invalid-url"; } - return url; - } catch { - return "#invalid-url"; - } - }; + }; - // Limit body size to prevent memory issues - const MAX_BODY_LENGTH = 50000; // 50KB - const truncatedBody = - content.body.length > MAX_BODY_LENGTH - ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` - : content.body; + // Limit body size to prevent memory issues + const MAX_BODY_LENGTH = 50000; // 50KB + const truncatedBody = + content.body.length > MAX_BODY_LENGTH + ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` + : content.body; - const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} + const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} **URL:** ${sanitizeUrl(content.url)} **State:** ${content.state} @@ -905,152 +908,166 @@ function PromptGroupInner({ ${sanitizeText(truncatedBody)}`; - // Convert markdown to base64 data URL - const base64 = btoa( - encodeURIComponent(markdown).replace( - /%([0-9A-F]{2})/g, - (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), - ), - ); - - return { - data: `data:text/markdown;base64,${base64}`, - mediaType: "text/markdown", - filename: `github-issue-${content.number}.md`, - }; - } catch (err) { - console.warn( - `Failed to fetch GitHub issue #${issue.number}:`, - err, - ); - return null; - } - }), - ); + // Convert markdown to base64 data URL + const base64 = btoa( + encodeURIComponent(markdown).replace( + /%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), + ), + ); + + return { + data: `data:text/markdown;base64,${base64}`, + mediaType: "text/markdown", + filename: `github-issue-${content.number}.md`, + }; + } catch (err) { + console.warn( + `Failed to fetch GitHub issue #${issue.number}:`, + err, + ); + return null; + } + }), + ); - // Add successfully fetched issues to convertedFiles - const validIssueFiles = issueContents.filter( - (file) => file !== null, - ) as ConvertedFile[]; - convertedFiles = [...convertedFiles, ...validIssueFiles]; - } catch (err) { - console.warn("Failed to fetch GitHub issue contents:", err); - // Don't block workspace creation if issue fetching fails + // Add successfully fetched issues to convertedFiles + const validIssueFiles = issueContents.filter( + (file) => file !== null, + ) as ConvertedFile[]; + convertedFiles = [...convertedFiles, ...validIssueFiles]; + } catch (err) { + console.warn("Failed to fetch GitHub issue contents:", err); + // Don't block workspace creation if issue fetching fails + } } - } - let launchRequest: AgentLaunchRequest | null = null; - try { - launchRequest = buildLaunchRequest( - trimmedPrompt, - convertedFiles.length > 0 ? convertedFiles : undefined, - ); - } catch (error) { - clearPendingWorkspace(pendingWorkspaceId); - toast.error( - error instanceof Error - ? error.message - : "Failed to prepare agent launch", - ); - return; - } + let launchRequest: AgentLaunchRequest | null = null; + try { + launchRequest = buildLaunchRequest( + trimmedPrompt, + convertedFiles.length > 0 ? convertedFiles : undefined, + ); + } catch (error) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error( + error instanceof Error + ? error.message + : "Failed to prepare agent launch", + ); + return; + } - setPendingWorkspaceStatus(pendingWorkspaceId, "creating"); + setPendingWorkspaceStatus(pendingWorkspaceId, "creating"); + + if (linkedPR) { + 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", + }, + { closeAndReset: false }, + ).finally(() => { + clearPendingWorkspace(pendingWorkspaceId); + }); + return; + } - if (linkedPR) { void runAsyncAction( - createFromPr.mutateAsyncWithSetup( - { projectId, prUrl: linkedPR.url }, - launchRequest ?? undefined, + createWorkspace.mutateAsyncWithPendingSetup( + { + projectId, + name: + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : undefined, + prompt: trimmedPrompt || undefined, + branchName: + (branchNameEdited && branchName.trim() + ? sanitizeBranchNameWithMaxLength( + branchName.trim(), + undefined, + { + preserveCase: true, + }, + ) + : aiBranchName) || undefined, + compareBaseBranch: compareBaseBranch || undefined, + }, + { + agentLaunchRequest: launchRequest ?? undefined, + resolveInitialCommands: runSetupScript + ? (commands) => commands + : () => null, + }, ), { - loading: `Creating workspace from PR #${linkedPR.prNumber}...`, - success: "Workspace created from PR", + loading: "Creating workspace...", + success: "Workspace created", error: (err) => - err instanceof Error - ? err.message - : "Failed to create workspace from PR", + err instanceof Error ? err.message : "Failed to create workspace", }, { closeAndReset: false }, ).finally(() => { clearPendingWorkspace(pendingWorkspaceId); }); - return; - } - - void runAsyncAction( - createWorkspace.mutateAsyncWithPendingSetup( - { - projectId, - name: - workspaceNameEdited && workspaceName.trim() - ? workspaceName.trim() - : undefined, - prompt: trimmedPrompt || undefined, - branchName: - (branchNameEdited && branchName.trim() - ? sanitizeBranchNameWithMaxLength( - branchName.trim(), - undefined, - { - preserveCase: true, - }, - ) - : aiBranchName) || undefined, - compareBaseBranch: compareBaseBranch || undefined, - }, - { - agentLaunchRequest: launchRequest ?? undefined, - resolveInitialCommands: runSetupScript - ? (commands) => commands - : () => null, - }, - ), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - { closeAndReset: false }, - ).finally(() => { - clearPendingWorkspace(pendingWorkspaceId); - }); - } finally { - for (const file of detachedFiles) { - if (file.url?.startsWith("blob:")) { - URL.revokeObjectURL(file.url); + } finally { + for (const file of detachedFiles) { + if (file.url?.startsWith("blob:")) { + URL.revokeObjectURL(file.url); + } } } - } - }, [ - attachments, - compareBaseBranch, - branchName, - branchNameEdited, - buildLaunchRequest, - closeAndResetDraft, - clearPendingWorkspace, - convertBlobUrlToDataUrl, - createFromPr, - createWorkspace, - generateBranchNameMutation, - linkedIssues, - linkedPR, - projectId, - runAsyncAction, - runSetupScript, - setPendingWorkspace, - setPendingWorkspaceStatus, - trimmedPrompt, - utils, - workspaceName, - workspaceNameEdited, - ]); + }, + [ + attachments, + compareBaseBranch, + branchName, + branchNameEdited, + buildLaunchRequest, + closeAndResetDraft, + clearPendingWorkspace, + convertBlobUrlToDataUrl, + createFromPr, + createWorkspace, + generateBranchNameMutation, + linkedIssues, + linkedPR, + projectId, + runAsyncAction, + runSetupScript, + setPendingWorkspace, + setPendingWorkspaceStatus, + trimmedPrompt, + utils, + workspaceName, + workspaceNameEdited, + ], + ); - const handlePromptSubmit = useCallback(() => { - void handleCreate(); - }, [handleCreate]); + const handlePromptSubmit = useCallback( + (message: { + files: Array<{ url: string; mediaType: string; filename?: string }>; + }) => { + const converted: ConvertedFile[] = message.files + .filter((f) => f.url) + .map((f) => ({ + data: f.url, + mediaType: f.mediaType, + filename: f.filename, + })); + void handleCreate(converted.length > 0 ? converted : undefined); + }, + [handleCreate], + ); useEffect(() => { if (!isNewWorkspaceModalOpen) return; diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index d46ec7490a..1362218549 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -54,11 +54,6 @@ --sidebar-ring: #3a3837; --highlight-match: rgba(224, 120, 80, 0.2); --highlight-active: rgba(224, 120, 80, 0.5); - --diff-added: oklch(0.77 0.2 155); - --diff-modified: oklch(0.82 0.2 95); - --diff-deleted: oklch(0.65 0.2 25); - --diff-renamed: oklch(0.7 0.17 260); - --diff-copied: oklch(0.7 0.2 300); } /* Light theme fallback values - applied before hydration if user has light theme saved */ @@ -99,11 +94,6 @@ --sidebar-ring: oklch(0.708 0 0); --highlight-match: rgba(255, 211, 61, 0.35); --highlight-active: rgba(255, 150, 50, 0.55); - --diff-added: oklch(0.55 0.18 155); - --diff-modified: oklch(0.65 0.18 85); - --diff-deleted: oklch(0.52 0.22 25); - --diff-renamed: oklch(0.52 0.2 260); - --diff-copied: oklch(0.5 0.22 300); } @theme inline { @@ -145,11 +135,6 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - --color-diff-added: var(--diff-added); - --color-diff-modified: var(--diff-modified); - --color-diff-deleted: var(--diff-deleted); - --color-diff-renamed: var(--diff-renamed); - --color-diff-copied: var(--diff-copied); } @layer base { diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/index.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/index.ts new file mode 100644 index 0000000000..e57f7d0e00 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/index.ts @@ -0,0 +1 @@ +export { useGitStatus } from "./useGitStatus"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts new file mode 100644 index 0000000000..6d23f8f470 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -0,0 +1,37 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; + +/** + * Fetches workspace git status and keeps it live against server events. + * + * Single owner of the `git.getStatus` query + `git:changed` subscription for + * a workspace. Consumers (Changes tab UI, file tree decoration, anything + * else) receive the query result as data and do not re-fetch. + * + * `git:changed` is already debounced server-side in `GitWatcher` and covers + * both `.git/` metadata writes and worktree file edits — no client-side + * debounce needed. + */ +export function useGitStatus(workspaceId: string) { + const collections = useCollections(); + const baseBranch: string | null = + collections.v2WorkspaceLocalState.get(workspaceId)?.sidebarState + ?.baseBranch ?? null; + + const utils = workspaceTrpc.useUtils(); + + const query = workspaceTrpc.git.getStatus.useQuery( + { workspaceId, baseBranch: baseBranch ?? undefined }, + { refetchOnWindowFocus: true, enabled: Boolean(workspaceId) }, + ); + + const invalidate = useCallback(() => { + void utils.git.getStatus.invalidate({ workspaceId }); + }, [utils, workspaceId]); + + useWorkspaceEvent("git:changed", workspaceId, invalidate); + + return query; +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/index.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/index.ts new file mode 100644 index 0000000000..b37377bddb --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/index.ts @@ -0,0 +1,5 @@ +export { + type FileStatus, + type UseGitStatusMapResult, + useGitStatusMap, +} from "./useGitStatusMap"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/useGitStatusMap.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/useGitStatusMap.ts new file mode 100644 index 0000000000..c4f0bee881 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/useGitStatusMap.ts @@ -0,0 +1,103 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useMemo } from "react"; + +type GitStatusData = inferRouterOutputs["git"]["getStatus"]; +type ChangedFile = GitStatusData["againstBase"][number]; +export type FileStatus = ChangedFile["status"]; + +export interface UseGitStatusMapResult { + /** Changed files keyed by repo-relative POSIX path. */ + fileStatusByPath: Map; + /** + * Folder decoration status keyed by repo-relative POSIX path. For each + * folder that (transitively) contains a changed file, the value is the + * highest-severity status among its descendants — used to color the + * roll-up dot in the file tree. + */ + folderStatusByPath: Map; + /** Repo-relative POSIX paths reported as gitignored, normalized. */ + ignoredPaths: Set; +} + +/** + * Status severity used when rolling up a folder's decoration from its + * descendants — the folder takes the "worst" status under it. + */ +const STATUS_SEVERITY: Record = { + deleted: 5, + modified: 4, + changed: 4, + added: 3, + untracked: 2, + renamed: 1, + copied: 0, +}; + +function emptyResult(): UseGitStatusMapResult { + return { + fileStatusByPath: new Map(), + folderStatusByPath: new Map(), + ignoredPaths: new Set(), + }; +} + +/** + * Pure derivation over `git.getStatus` data. Returns lookup maps for + * decorating the file tree with git status + gitignored muting. + */ +export function useGitStatusMap( + status: GitStatusData | undefined, +): UseGitStatusMapResult { + return useMemo(() => { + if (!status) return emptyResult(); + + // Union of all changes — later writes win so uncommitted state + // overrides committed state. Same pattern as useChangesTab's "all" filter. + const fileStatusByPath = new Map(); + for (const file of status.againstBase) { + fileStatusByPath.set(normalizePath(file.path), file.status); + } + for (const file of status.staged) { + fileStatusByPath.set(normalizePath(file.path), file.status); + } + for (const file of status.unstaged) { + fileStatusByPath.set(normalizePath(file.path), file.status); + } + + const folderStatusByPath = new Map(); + for (const [path, fileStatus] of fileStatusByPath) { + // Deleted files don't appear in the tree, so propagating a dot to + // ancestor folders is misleading — users expand the folder expecting + // to find something but there's nothing there. + if (fileStatus === "deleted") continue; + + const segments = path.split("/"); + for (let i = 1; i < segments.length; i++) { + const ancestor = segments.slice(0, i).join("/"); + const existing = folderStatusByPath.get(ancestor); + if ( + !existing || + STATUS_SEVERITY[fileStatus] > STATUS_SEVERITY[existing] + ) { + folderStatusByPath.set(ancestor, fileStatus); + } + } + } + + const ignoredPaths = new Set(); + for (const entry of status.ignoredPaths) { + ignoredPaths.add(normalizePath(entry).replace(/\/$/, "")); + } + + return { + fileStatusByPath, + folderStatusByPath, + ignoredPaths, + }; + }, [status]); +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/"); +} diff --git a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts new file mode 100644 index 0000000000..11ca110051 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts @@ -0,0 +1,20 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; + +/** + * Returns effective v2 state: remote PostHog flag AND local override. + * Also returns the raw remote flag so the toggle can be shown conditionally. + */ +export function useIsV2CloudEnabled() { + const remoteV2Enabled = + useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; + const forceV1 = useV2LocalOverrideStore((s) => s.forceV1); + + return { + /** The effective value — use this wherever you previously checked the flag directly. */ + isV2CloudEnabled: remoteV2Enabled && !forceV1, + /** Whether the remote PostHog flag is on (for showing the toggle). */ + isRemoteV2Enabled: remoteV2Enabled, + }; +} diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts index 7424e33e99..3787653bac 100644 --- a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts @@ -16,11 +16,18 @@ describe("formatRelativeTime", () => { Date.now = originalDateNow; }; - it('returns "now" for timestamps less than 1 minute ago', () => { + it('returns "now" for timestamps less than 5 seconds ago', () => { mockNow(); expect(formatRelativeTime(NOW)).toBe("now"); - expect(formatRelativeTime(NOW - 30 * 1000)).toBe("now"); // 30 seconds ago - expect(formatRelativeTime(NOW - 59 * 1000)).toBe("now"); // 59 seconds ago + expect(formatRelativeTime(NOW - 4 * 1000)).toBe("now"); + restoreNow(); + }); + + it("returns seconds for timestamps between 5-59 seconds ago", () => { + mockNow(); + expect(formatRelativeTime(NOW - 5 * 1000)).toBe("5s"); + expect(formatRelativeTime(NOW - 30 * 1000)).toBe("30s"); + expect(formatRelativeTime(NOW - 59 * 1000)).toBe("59s"); restoreNow(); }); diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts index a55ed489e2..27bed8ba71 100644 --- a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts @@ -1,12 +1,14 @@ export function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diffMs = now - timestamp; + const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); const diffMonths = Math.floor(diffDays / 30); - if (diffMinutes < 1) return "now"; + if (diffSeconds < 5) return "now"; + if (diffMinutes < 1) return `${diffSeconds}s`; if (diffMinutes < 60) return `${diffMinutes}m`; if (diffHours < 24) return `${diffHours}h`; if (diffDays < 30) return `${diffDays}d`; diff --git a/apps/desktop/src/renderer/lib/pending-attachment-store.ts b/apps/desktop/src/renderer/lib/pending-attachment-store.ts new file mode 100644 index 0000000000..aeb450811f --- /dev/null +++ b/apps/desktop/src/renderer/lib/pending-attachment-store.ts @@ -0,0 +1,138 @@ +/** + * IndexedDB store for pending workspace attachment blobs. + * Blobs are keyed by `${pendingId}/${index}` and stored raw (no compression). + */ + +const DB_NAME = "superset-pending-attachments"; +const STORE_NAME = "blobs"; +const DB_VERSION = 1; + +interface StoredAttachment { + blob: Blob; + mediaType: string; + filename: string; +} + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** + * Store attachment blobs from the PromptInput into IndexedDB. + * Call before closing the modal so blobs survive for retry. + */ +export async function storeAttachments( + pendingId: string, + files: Array<{ url: string; mediaType: string; filename?: string }>, +): Promise { + if (files.length === 0) return; + + const db = await openDb(); + const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME); + + await Promise.all( + files.map(async (file) => { + const blobId = crypto.randomUUID(); + const response = await fetch(file.url); + const blob = await response.blob(); + const value: StoredAttachment = { + blob, + mediaType: file.mediaType, + filename: file.filename ?? "attachment", + }; + return new Promise((resolve, reject) => { + const request = store.put(value, `${pendingId}/${blobId}`); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + }), + ); + + db.close(); +} + +/** + * Load stored attachment blobs and convert them to data URLs + * for the API payload. Used on retry. + */ +export async function loadAttachments( + pendingId: string, +): Promise> { + const db = await openDb(); + const store = db.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME); + + const entries: StoredAttachment[] = await new Promise((resolve, reject) => { + const prefix = `${pendingId}/`; + const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`); + const request = store.openCursor(range); + const results: StoredAttachment[] = []; + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(results); + return; + } + results.push(cursor.value as StoredAttachment); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + + db.close(); + + return Promise.all( + entries.map(async (entry) => ({ + data: await blobToDataUrl(entry.blob), + mediaType: entry.mediaType, + filename: entry.filename, + })), + ); +} + +/** + * Delete all stored attachments for a pending workspace. + * Call on create success or dismiss. + */ +export async function clearAttachments(pendingId: string): Promise { + const db = await openDb(); + const store = db.transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME); + + await new Promise((resolve, reject) => { + const prefix = `${pendingId}/`; + const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`); + const request = store.openCursor(range); + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; + } + cursor.delete(); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + + db.close(); +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read blob")); + reader.readAsDataURL(blob); + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 1fc60f6f72..a825932302 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from "@tanstack/react-router"; import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; @@ -56,7 +57,15 @@ export function DashboardSidebarWorkspaceItem({ workspaceName: name, }); - const isCreating = !!creationStatus; + const navigate = useNavigate(); + const isPending = !!creationStatus; + const handlePendingClick = isPending + ? () => { + void navigate({ + to: `/pending/${id}` as string, + }); + } + : undefined; if (isCollapsed) { const content = ( @@ -72,9 +81,9 @@ export function DashboardSidebarWorkspaceItem({ - {isCreating ? ( + {isPending ? ( content ) : ( )} - {!isCreating && ( + {!isPending && ( setIsDeleteDialogOpen(true)} onRenameValueChange={setRenameValue} onSubmitRename={submitRename} @@ -146,7 +155,7 @@ export function DashboardSidebarWorkspaceItem({ return ( <> - {isCreating ? ( + {isPending ? ( expandedContent ) : ( )} - {!isCreating && ( + {!isPending && ( {creationStatusText ? ( - + {creationStatusText} ) : ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx index c87f5536e6..8524858235 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx @@ -1,4 +1,5 @@ import { cn } from "@superset/ui/utils"; +import { HiExclamationTriangle } from "react-icons/hi2"; import { LuCloud, LuFolderGit2, LuLaptop } from "react-icons/lu"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; @@ -10,7 +11,7 @@ interface DashboardSidebarWorkspaceIconProps { isActive: boolean; variant: "collapsed" | "expanded"; workspaceStatus?: ActivePaneStatus | null; - creationStatus?: "preparing" | "generating-branch" | "creating"; + creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; } const OVERLAY_POSITION = { @@ -29,7 +30,9 @@ export function DashboardSidebarWorkspaceIcon({ return ( <> - {creationStatus || workspaceStatus === "working" ? ( + {creationStatus === "failed" ? ( + + ) : creationStatus || workspaceStatus === "working" ? ( ) : hostType === "cloud" ? ( + q.from({ pw: collections.pendingWorkspaces }).select(({ pw }) => ({ + id: pw.id, + projectId: pw.projectId, + name: pw.name, + branchName: pw.branchName, + status: pw.status, + })), + [collections], + ); const activeOrganizationId = env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : (session?.session?.activeOrganizationId ?? null); @@ -268,45 +279,41 @@ export function useDashboardSidebarData() { }); } - // Inject pending workspace if it exists - if (pendingWorkspace && machineId) { - const project = projectsById.get(pendingWorkspace.projectId); - if (!project) { - // Log warning if pending workspace references non-existent project - console.warn( - `Pending workspace ${pendingWorkspace.id} references non-existent project ${pendingWorkspace.projectId}`, - ); - } else { - const pendingItem: DashboardSidebarWorkspace = { - id: pendingWorkspace.id, - projectId: pendingWorkspace.projectId, - hostId: "", - hostType: "local-device", - accentColor: null, - name: pendingWorkspace.name, - branch: "", - pullRequest: null, - repoUrl: - project.githubOwner && project.githubRepoName - ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` - : null, - branchExistsOnRemote: false, - previewUrl: null, - needsRebase: null, - behindCount: null, - createdAt: new Date(), - updatedAt: new Date(), - creationStatus: pendingWorkspace.status, - }; + // Inject pending workspaces (creating / failed) + for (const pw of pendingWorkspaces) { + if (pw.status === "succeeded") continue; // will appear as a real workspace + const project = projectsById.get(pw.projectId); + if (!project) continue; - project.childEntries.push({ - tabOrder: PENDING_WORKSPACE_TAB_ORDER, - child: { - type: "workspace", - workspace: pendingItem, - }, - }); - } + const pendingItem: DashboardSidebarWorkspace = { + id: pw.id, + projectId: pw.projectId, + hostId: "", + hostType: "local-device", + accentColor: null, + name: pw.name, + branch: pw.branchName, + pullRequest: null, + repoUrl: + project.githubOwner && project.githubRepoName + ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` + : null, + branchExistsOnRemote: false, + previewUrl: null, + needsRebase: null, + behindCount: null, + createdAt: new Date(), + updatedAt: new Date(), + creationStatus: pw.status, + }; + + project.childEntries.push({ + tabOrder: PENDING_WORKSPACE_TAB_ORDER, + child: { + type: "workspace", + workspace: pendingItem, + }, + }); } return sidebarProjects.flatMap((project) => { @@ -325,7 +332,7 @@ export function useDashboardSidebarData() { }, [ machineId, localPullRequestsByWorkspaceId, - pendingWorkspace, + pendingWorkspaces, sidebarProjects, sidebarSections, sidebarWorkspaces, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index a8a5233114..329d90bd70 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -36,7 +36,7 @@ export interface DashboardSidebarWorkspace { behindCount: number | null; createdAt: Date; updatedAt: Date; - creationStatus?: "preparing" | "generating-branch" | "creating"; + creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; } export interface DashboardSidebarSection { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx index eafd1f4cf4..53c28ccd9e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx @@ -1,7 +1,6 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; import { useMatchRoute, useParams } from "@tanstack/react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; import { HiOutlineWifi } from "react-icons/hi2"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOnlineStatus } from "renderer/hooks/useOnlineStatus"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; @@ -14,6 +13,7 @@ import { SearchBarTrigger } from "./components/SearchBarTrigger"; import { SidebarToggle } from "./components/SidebarToggle"; import { V2WorkspaceOpenInButton } from "./components/V2WorkspaceOpenInButton"; import { V2WorkspaceSearchBarTrigger } from "./components/V2WorkspaceSearchBarTrigger"; +import { VersionToggle } from "./components/VersionToggle"; import { WindowControls } from "./components/WindowControls"; export function TopBar() { @@ -31,8 +31,7 @@ export function TopBar() { { enabled: !!workspaceId && !isV2WorkspaceRoute }, ); const isOnline = useOnlineStatus(); - const isV2CloudEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; + const { isV2CloudEnabled, isRemoteV2Enabled } = useIsV2CloudEnabled(); // Default to Mac layout while loading to avoid overlap with traffic lights const isMac = platform === undefined || platform === "darwin"; @@ -47,6 +46,7 @@ export function TopBar() { + {isRemoteV2Enabled && } {isV2WorkspaceRoute ? ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/VersionToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/VersionToggle.tsx new file mode 100644 index 0000000000..3346210887 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/VersionToggle.tsx @@ -0,0 +1,46 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; + +export function VersionToggle() { + const { forceV1, toggle } = useV2LocalOverrideStore(); + const activeVersion = forceV1 ? "v1" : "v2"; + + return ( + + + + + + {forceV1 + ? "Early Access: Switch to Superset V2" + : "Switch to Superset V1"} + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/index.ts new file mode 100644 index 0000000000..3e06c734dd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/VersionToggle/index.ts @@ -0,0 +1 @@ +export { VersionToggle } from "./VersionToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 544af05d72..413656fadf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -1,10 +1,8 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; import { createFileRoute, useMatchRoute, useNavigate, } from "@tanstack/react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; import { useState } from "react"; import { useBrowserFullscreenHandler } from "renderer/hooks/useBrowserFullscreenHandler"; import { useBrowserNewWindowHandler } from "renderer/hooks/useBrowserNewWindowHandler"; @@ -13,6 +11,7 @@ import { useReturnedTabListener, useTearoffInit, } from "renderer/hooks/useTearoffInit"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; @@ -36,8 +35,7 @@ export const Route = createFileRoute("/_authenticated/_dashboard")({ function DashboardLayout() { const navigate = useNavigate(); const openNewWorkspaceModal = useOpenNewWorkspaceModal(); - const isV2CloudEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; + const { isV2CloudEnabled } = useIsV2CloudEnabled(); // Get current workspace from route to pre-select project in new workspace modal const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx new file mode 100644 index 0000000000..f4ab5c46b1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx @@ -0,0 +1,326 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiExclamationTriangle } from "react-icons/hi2"; +import { env } from "renderer/env.renderer"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + clearAttachments, + loadAttachments, +} from "renderer/lib/pending-attachment-store"; +import { useCreateDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +/** + * Pending workspace progress page. + * + * Lives at /_dashboard/pending/$pendingId (NOT under /v2-workspace/) because + * the v2-workspace layout wraps children in WorkspaceTrpcProvider. During route + * transitions away from a real workspace, the layout would strip the provider + * while the old workspace's TerminalPane is still mounted — causing a crash. + * Keeping this route outside v2-workspace avoids that entirely. + */ +export const Route = createFileRoute( + "/_authenticated/_dashboard/pending/$pendingId/", +)({ + component: PendingWorkspacePage, +}); + +function useRetryCreate( + pendingId: string, + pending: { + projectId: string; + name: string; + branchName: string; + prompt: string; + baseBranch: string | null; + runSetupScript: boolean; + linkedIssues: unknown[]; + linkedPR: unknown; + hostTarget: unknown; + attachmentCount: number; + } | null, +) { + const collections = useCollections(); + const createWorkspace = useCreateDashboardWorkspace(); + + return useCallback(async () => { + if (!pending) return; + + collections.pendingWorkspaces.update(pendingId, (draft) => { + draft.status = "creating"; + draft.error = null; + }); + + const internalIssueIds = ( + pending.linkedIssues as Array<{ source?: string; taskId?: string }> + ) + .filter((i) => i.source === "internal" && i.taskId) + .map((i) => i.taskId as string); + const githubIssueUrls = ( + pending.linkedIssues as Array<{ source?: string; url?: string }> + ) + .filter((i) => i.source === "github" && i.url) + .map((i) => i.url as string); + const linkedPR = pending.linkedPR as { url?: string } | null; + + let attachmentPayload: + | Array<{ data: string; mediaType: string; filename: string }> + | undefined; + if (pending.attachmentCount > 0) { + try { + attachmentPayload = await loadAttachments(pendingId); + } catch { + // proceed without + } + } + + try { + const result = await createWorkspace({ + pendingId, + projectId: pending.projectId, + hostTarget: pending.hostTarget as + | { kind: "local" } + | { kind: "host"; hostId: string }, + names: { + workspaceName: pending.name, + branchName: pending.branchName, + }, + composer: { + prompt: pending.prompt || undefined, + baseBranch: pending.baseBranch || undefined, + runSetupScript: pending.runSetupScript, + }, + linkedContext: { + internalIssueIds: + internalIssueIds.length > 0 ? internalIssueIds : undefined, + githubIssueUrls: + githubIssueUrls.length > 0 ? githubIssueUrls : undefined, + linkedPrUrl: linkedPR?.url, + attachments: attachmentPayload, + }, + }); + + collections.pendingWorkspaces.update(pendingId, (draft) => { + draft.status = "succeeded"; + draft.workspaceId = result.workspace?.id ?? null; + draft.initialCommands = result.initialCommands ?? null; + }); + void clearAttachments(pendingId); + } catch (err) { + collections.pendingWorkspaces.update(pendingId, (draft) => { + draft.status = "failed"; + draft.error = + err instanceof Error ? err.message : "Failed to create workspace"; + }); + } + }, [collections, createWorkspace, pending, pendingId]); +} + +function PendingWorkspacePage() { + const { pendingId } = Route.useParams(); + const navigate = useNavigate(); + const collections = useCollections(); + const { activeHostUrl } = useLocalHostService(); + const navigatedRef = useRef(false); + + // Read pending workspace from collection (declared early for useRetryCreate) + const { data: pendingRows } = useLiveQuery( + (q) => + q + .from({ pw: collections.pendingWorkspaces }) + .where(({ pw }) => eq(pw.id, pendingId)) + .select(({ pw }) => ({ ...pw })), + [collections, pendingId], + ); + const pending = pendingRows?.[0] ?? null; + const retryCreate = useRetryCreate(pendingId, pending); + + // Poll host-service for step-by-step progress + const hostUrl = + pending?.hostTarget && + typeof pending.hostTarget === "object" && + "kind" in (pending.hostTarget as Record) + ? (pending.hostTarget as { kind: string; hostId?: string }).kind === + "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${(pending.hostTarget as { hostId: string }).hostId}` + : activeHostUrl; + + const { data: progress } = useQuery({ + queryKey: ["workspaceCreation", "getProgress", pendingId, hostUrl], + queryFn: async () => { + if (!hostUrl) return null; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.getProgress.query({ + pendingId, + }); + }, + refetchInterval: 500, + enabled: pending?.status === "creating" && !!hostUrl, + }); + + const steps = progress?.steps ?? []; + + // Elapsed timer + staleness detection + const STALE_THRESHOLD_MS = 2 * 60 * 1000; + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (pending?.status !== "creating") return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [pending?.status]); + + const createdAtMs = pending?.createdAt + ? new Date(pending.createdAt).getTime() + : now; + const elapsedMs = Math.max(0, now - createdAtMs); + const elapsedLabel = formatRelativeTime(createdAtMs); + const isStale = + pending?.status === "creating" && elapsedMs > STALE_THRESHOLD_MS; + + // Auto-navigate to real workspace on success + useEffect(() => { + if ( + pending?.status === "succeeded" && + pending.workspaceId && + !navigatedRef.current + ) { + navigatedRef.current = true; + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: pending.workspaceId }, + }); + // Clean up the pending row after a short delay + setTimeout(() => { + collections.pendingWorkspaces.delete(pendingId); + }, 1000); + } + }, [collections, navigate, pending, pendingId]); + + if (!pending) { + return ( +
+ Workspace not found +
+ ); + } + + return ( +
+
+ {/* Header */} +
+

{pending.name}

+
+ + {pending.branchName} +
+
+ + {/* Status */} + {pending.status === "creating" && ( +
+
+

+ {isStale + ? "This is taking longer than expected..." + : "Creating workspace..."} +

+ + {elapsedLabel} + +
+ {steps.length > 0 && ( +
+ {steps.map((step) => ( +
+ {step.status === "done" ? ( + + ) : step.status === "active" ? ( +
+
+
+ ) : ( +
+
+
+ )} + + {step.label} + +
+ ))} +
+ )} +
+ +
+
+ )} + + {pending.status === "succeeded" && ( +
+ + Workspace created — opening... +
+ )} + + {pending.status === "failed" && ( +
+
+ + {pending.error ?? "Failed to create workspace"} +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 4f1a011169..f8a83f43f2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -2,6 +2,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { Search } from "lucide-react"; import { useMemo, useState } from "react"; +import { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; import { FilesTab } from "./components/FilesTab"; import { SidebarHeader } from "./components/SidebarHeader"; import { useChangesTab } from "./hooks/useChangesTab"; @@ -55,8 +56,11 @@ export function WorkspaceSidebar({ }: WorkspaceSidebarProps) { const [activeTab, setActiveTab] = useState("files"); + const gitStatus = useGitStatus(workspaceId); + const changesTab = useChangesTab({ workspaceId, + gitStatus, onSelectFile: onSelectDiffFile, }); @@ -71,10 +75,18 @@ export function WorkspaceSidebar({ selectedFilePath={selectedFilePath} workspaceId={workspaceId} workspaceName={workspaceName} + gitStatus={gitStatus.data} /> ), }), - [onSearch, onSelectFile, selectedFilePath, workspaceId, workspaceName], + [ + gitStatus.data, + onSearch, + onSelectFile, + selectedFilePath, + workspaceId, + workspaceName, + ], ); const checksTab: SidebarTabDefinition = useMemo( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index c36c4056b6..849cc99d8f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -1,14 +1,20 @@ +import type { AppRouter } from "@superset/host-service"; import { alert } from "@superset/ui/atoms/Alert"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { workspaceTrpc } from "@superset/workspace-client"; +import type { inferRouterOutputs } from "@trpc/server"; import { FilePlus, FolderPlus, FoldVertical, RefreshCw } from "lucide-react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { type FileTreeNode, useFileTree, } from "renderer/hooks/host-service/useFileTree"; +import { + type FileStatus, + useGitStatusMap, +} from "renderer/hooks/host-service/useGitStatusMap"; import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; import { ROW_HEIGHT, @@ -17,6 +23,8 @@ import { import { NewItemInput } from "./components/NewItemInput"; import { WorkspaceFilesTreeItem } from "./components/WorkspaceFilesTreeItem"; +type GitStatusData = inferRouterOutputs["git"]["getStatus"]; + type InlineEditState = | { kind: "create"; mode: "file" | "folder"; parentPath: string } | { kind: "rename"; absolutePath: string; name: string; isDirectory: boolean } @@ -27,6 +35,11 @@ interface FilesTabProps { selectedFilePath?: string; workspaceId: string; workspaceName?: string; + gitStatus: GitStatusData | undefined; +} + +function toPosix(path: string): string { + return path.replace(/\\/g, "/"); } function TreeNode({ @@ -37,6 +50,10 @@ function TreeNode({ selectedFilePath, hoveredPath, inlineEdit, + isMuted, + fileStatusByPath, + folderStatusByPath, + ignoredPaths, onSelectFile, onToggleDirectory, onInlineEditSubmit, @@ -53,6 +70,10 @@ function TreeNode({ selectedFilePath?: string; hoveredPath?: string | null; inlineEdit: InlineEditState; + isMuted: boolean; + fileStatusByPath: Map; + folderStatusByPath: Map; + ignoredPaths: Set; onSelectFile: (absolutePath: string) => void; onToggleDirectory: (absolutePath: string) => void; onInlineEditSubmit: (name: string) => void; @@ -73,6 +94,18 @@ function TreeNode({ (n) => n.kind === "directory", ); + // Resolve decoration once per node. Muted wins over change status so + // gitignored paths stay quiet even in the `git add -f` edge case. + const posixRelativePath = toPosix(node.relativePath); + const isFolder = node.kind === "directory"; + const fileStatus = !isFolder + ? fileStatusByPath.get(posixRelativePath) + : undefined; + const folderStatus = isFolder + ? folderStatusByPath.get(posixRelativePath) + : undefined; + const decoration = isMuted ? undefined : (fileStatus ?? folderStatus); + return (
{isRenaming ? ( @@ -91,6 +124,8 @@ function TreeNode({ rowHeight={rowHeight} selectedFilePath={selectedFilePath} isHovered={hoveredPath === node.absolutePath} + decoration={decoration} + isMuted={isMuted} onSelectFile={onSelectFile} onToggleDirectory={onToggleDirectory} onNewFile={onNewFile} @@ -109,35 +144,43 @@ function TreeNode({ onCancel={onInlineEditCancel} /> )} - {node.children.map((child, index) => ( - - - {isCreatingFile && index === lastFolderIndex && ( - { + const childIsMuted = + isMuted || ignoredPaths.has(toPosix(child.relativePath)); + return ( + + - )} - - ))} + {isCreatingFile && index === lastFolderIndex && ( + + )} + + ); + })} {isCreatingFile && lastFolderIndex === -1 && ( (null); @@ -174,6 +218,9 @@ export function FilesTab({ const fileTree = useFileTree({ workspaceId, rootPath }); + const { fileStatusByPath, folderStatusByPath, ignoredPaths } = + useGitStatusMap(gitStatus); + useWorkspaceEvent( "fs:events", workspaceId, @@ -486,11 +533,9 @@ export function FilesTab({
- {fileTree.isLoadingRoot && fileTree.rootEntries.length === 0 ? ( -
- Loading files... -
- ) : fileTree.rootEntries.length === 0 && !isCreatingAtRoot ? ( + {fileTree.rootEntries.length === 0 && + !fileTree.isLoadingRoot && + !isCreatingAtRoot ? (
No files found
@@ -504,43 +549,50 @@ export function FilesTab({ onCancel={handleInlineEditCancel} /> )} - {fileTree.rootEntries.map((node, index) => ( - - - void fileTree.toggle(absolutePath) - } - onInlineEditSubmit={handleInlineEditSubmit} - onInlineEditCancel={handleInlineEditCancel} - onNewFile={(parentPath) => - void startCreating("file", parentPath) - } - onNewFolder={(parentPath) => - void startCreating("folder", parentPath) - } - onRename={(absolutePath, name, isDirectory) => - startRenaming(absolutePath, name, isDirectory) - } - onDelete={handleDelete} - /> - {isCreatingFileAtRoot && index === rootLastFolderIndex && ( - { + const nodeIsMuted = ignoredPaths.has(toPosix(node.relativePath)); + return ( + + + void fileTree.toggle(absolutePath) + } + onInlineEditSubmit={handleInlineEditSubmit} + onInlineEditCancel={handleInlineEditCancel} + onNewFile={(parentPath) => + void startCreating("file", parentPath) + } + onNewFolder={(parentPath) => + void startCreating("folder", parentPath) + } + onRename={(absolutePath, name, isDirectory) => + startRenaming(absolutePath, name, isDirectory) + } + onDelete={handleDelete} /> - )} - - ))} + {isCreatingFileAtRoot && index === rootLastFolderIndex && ( + + )} + + ); + })} {isCreatingFileAtRoot && rootLastFolderIndex === -1 && ( = { + added: "text-green-700 dark:text-green-400", + copied: "text-purple-700 dark:text-purple-400", + changed: "text-yellow-600 dark:text-yellow-400", + deleted: "text-red-700 dark:text-red-500", + modified: "text-yellow-600 dark:text-yellow-400", + renamed: "text-blue-600 dark:text-blue-400", + untracked: "text-green-700 dark:text-green-400", +}; + +// Single-letter badge shown on the right of changed file rows, VS Code style. +const STATUS_LETTER: Record = { + added: "A", + copied: "C", + changed: "M", + deleted: "D", + modified: "M", + renamed: "R", + untracked: "U", +}; + interface WorkspaceFilesTreeItemProps { node: FileTreeNode; depth: number; @@ -14,6 +37,8 @@ interface WorkspaceFilesTreeItemProps { indent: number; selectedFilePath?: string; isHovered?: boolean; + decoration?: FileStatus; + isMuted?: boolean; onSelectFile: (absolutePath: string) => void; onToggleDirectory: (absolutePath: string) => void; onNewFile: (parentPath: string) => void; @@ -22,13 +47,15 @@ interface WorkspaceFilesTreeItemProps { onDelete: (absolutePath: string, name: string, isDirectory: boolean) => void; } -export function WorkspaceFilesTreeItem({ +function WorkspaceFilesTreeItemComponent({ node, depth, rowHeight, indent, selectedFilePath, isHovered, + decoration, + isMuted, onSelectFile, onToggleDirectory, onNewFile, @@ -39,6 +66,12 @@ export function WorkspaceFilesTreeItem({ const isFolder = node.kind === "directory"; const isSelected = selectedFilePath === node.absolutePath; + const nameColorClass = isMuted + ? "text-muted-foreground" + : decoration + ? STATUS_TEXT_CLASS[decoration] + : undefined; + return ( @@ -46,7 +79,7 @@ export function WorkspaceFilesTreeItem({ data-filepath={node.absolutePath} aria-expanded={isFolder ? node.isExpanded : undefined} className={cn( - "flex w-full cursor-pointer select-none items-center gap-1 pr-2 text-left transition-colors", + "flex w-full cursor-pointer select-none items-center gap-1 pr-4 text-left transition-colors", isFolder ? "bg-background" : undefined, isHovered && !isSelected ? isFolder @@ -62,12 +95,12 @@ export function WorkspaceFilesTreeItem({ } style={{ height: rowHeight, - paddingLeft: 4 + depth * indent, + paddingLeft: 8 + (depth - 1) * indent, ...(isFolder ? { position: "sticky" as const, - top: depth * rowHeight, - zIndex: 10 - depth, + top: (depth - 1) * rowHeight, + zIndex: Math.max(1, 50 - depth), } : {}), }} @@ -90,7 +123,26 @@ export function WorkspaceFilesTreeItem({ isOpen={node.isExpanded} /> - {node.name} + + {node.name} + + + {decoration && !isMuted && ( + + {isFolder ? ( + + ) : ( + STATUS_LETTER[decoration] + )} + + )} {isFolder ? ( @@ -113,3 +165,5 @@ export function WorkspaceFilesTreeItem({ ); } + +export const WorkspaceFilesTreeItem = memo(WorkspaceFilesTreeItemComponent); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx index 3c2ca0b5cd..21a6db7b42 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -17,13 +17,13 @@ type FileStatus = ChangedFile["status"]; type ChangeCategory = "against-base" | "staged" | "unstaged"; const STATUS_COLORS: Record = { - added: "text-diff-added", - copied: "text-diff-copied", - changed: "text-diff-modified", - deleted: "text-diff-deleted", - modified: "text-diff-modified", - renamed: "text-diff-renamed", - untracked: "text-diff-added", + added: "text-green-700 dark:text-green-400", + copied: "text-purple-700 dark:text-purple-400", + changed: "text-yellow-600 dark:text-yellow-400", + deleted: "text-red-700 dark:text-red-500", + modified: "text-yellow-600 dark:text-yellow-400", + renamed: "text-blue-600 dark:text-blue-400", + untracked: "text-green-700 dark:text-green-400", }; function getStatusIcon(status: FileStatus): ReactNode { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx index 219f326571..906a2d55db 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -1,7 +1,7 @@ import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { useCallback, useMemo } from "react"; +import type { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; import type { SidebarTabDefinition } from "../../types"; @@ -11,6 +11,7 @@ export type { ChangesFilter }; interface UseChangesTabParams { workspaceId: string; + gitStatus: ReturnType; onSelectFile?: ( path: string, category: "against-base" | "staged" | "unstaged", @@ -19,6 +20,7 @@ interface UseChangesTabParams { export function useChangesTab({ workspaceId, + gitStatus: status, onSelectFile, }: UseChangesTabParams): SidebarTabDefinition { const collections = useCollections(); @@ -49,13 +51,6 @@ export function useChangesTab({ [collections, workspaceId], ); - const statusUtils = workspaceTrpc.useUtils(); - - const status = workspaceTrpc.git.getStatus.useQuery( - { workspaceId, baseBranch: baseBranch ?? undefined }, - { refetchOnWindowFocus: true }, - ); - const commits = workspaceTrpc.git.listCommits.useQuery( { workspaceId, baseBranch: baseBranch ?? undefined }, { refetchOnWindowFocus: true }, @@ -66,31 +61,6 @@ export function useChangesTab({ { refetchInterval: 30_000, refetchOnWindowFocus: true }, ); - const invalidateGitQueries = useCallback(() => { - void statusUtils.git.getStatus.invalidate({ workspaceId }); - void statusUtils.git.listCommits.invalidate({ workspaceId }); - }, [statusUtils, workspaceId]); - - // Shared debounce for git:changed and fs:events — batches rapid events - // from either source into a single git status refresh. - const debounceRef = useRef | null>(null); - const debouncedInvalidate = useCallback(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - debounceRef.current = null; - invalidateGitQueries(); - }, 300); - }, [invalidateGitQueries]); - // biome-ignore lint/correctness/useExhaustiveDependencies: clear pending timer on workspace change - useEffect(() => { - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [workspaceId]); - - useWorkspaceEvent("git:changed", workspaceId, debouncedInvalidate); - useWorkspaceEvent("fs:events", workspaceId, debouncedInvalidate); - const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); const handleRenameBranch = useCallback( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx index 01b09b6edc..22da979ff6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx @@ -87,8 +87,7 @@ export function useDefaultContextMenuActions(): ContextMenuActionConfig, +): { id: string; data: BrowserPaneData } | null { + const paneIds = Object.keys(tab.panes); + if (paneIds.length !== 1) return null; + const pane = tab.panes[paneIds[0]]; + if (pane.kind !== "browser") return null; + return { id: pane.id, data: pane.data as BrowserPaneData }; +} + +export function getBrowserTabTitle( + tab: Tab, +): string | undefined { + const browser = getSingleBrowserPane(tab); + if (!browser) return undefined; + if (browser.data.pageTitle) return browser.data.pageTitle; + if (browser.data.url && browser.data.url !== "about:blank") { + try { + return new URL(browser.data.url).hostname; + } catch {} + } + return undefined; +} + +export function renderBrowserTabIcon(tab: Tab) { + const browser = getSingleBrowserPane(tab); + if (!browser?.data.faviconUrl) return null; + return ( + + ); +} + +interface BrowserPaneProps { + ctx: RendererContext; +} + +function useBrowserState(paneId: string) { + return useSyncExternalStore( + useCallback( + (cb) => browserRuntimeRegistry.onStateChange(paneId, cb), + [paneId], + ), + useCallback(() => browserRuntimeRegistry.getState(paneId), [paneId]), + ); +} + +export function BrowserPane({ ctx }: BrowserPaneProps) { + const paneId = ctx.pane.id; + const state = useBrowserState(paneId); + const { placeholderRef, reload } = usePersistentWebview({ paneId, ctx }); + + const isBlankPage = !state.currentUrl || state.currentUrl === "about:blank"; + + return ( +
+
+ {state.error && !state.isLoading && ( + + )} + {isBlankPage && !state.isLoading && !state.error && ( +
+ +
+

+ Browser +

+

+ Enter a URL above, or instruct an agent to navigate +
+ and use the browser +

+
+
+ )} +
+ ); +} + +interface BrowserPaneToolbarProps { + ctx: RendererContext; +} + +export function BrowserPaneToolbar({ ctx }: BrowserPaneToolbarProps) { + const paneId = ctx.pane.id; + const state = useBrowserState(paneId); + + const handleOpenDevTools = useCallback(() => { + electronTrpcClient.browser.openDevTools.mutate({ paneId }).catch(() => {}); + }, [paneId]); + + const handleGoBack = useCallback(() => { + browserRuntimeRegistry.goBack(paneId); + }, [paneId]); + + const handleGoForward = useCallback(() => { + browserRuntimeRegistry.goForward(paneId); + }, [paneId]); + + const handleReload = useCallback(() => { + browserRuntimeRegistry.reload(paneId); + }, [paneId]); + + const handleNavigate = useCallback( + (url: string) => { + browserRuntimeRegistry.navigate(paneId, url); + }, + [paneId], + ); + + const isBlankPage = !state.currentUrl || state.currentUrl === "about:blank"; + const PaneHeaderActions = ctx.components.PaneHeaderActions; + + return ( +
+ +
+
+ + + + + + Open DevTools + + + +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts new file mode 100644 index 0000000000..bba11c1868 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -0,0 +1,419 @@ +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { BrowserLoadError } from "shared/tabs-types"; +import { sanitizeUrl } from "./sanitizeUrl"; + +export interface BrowserRuntimeState { + currentUrl: string; + pageTitle: string; + faviconUrl: string | null; + isLoading: boolean; + error: BrowserLoadError | null; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface PersistableBrowserState { + url: string; + pageTitle: string; + faviconUrl: string | null; +} + +interface RegistryEntry { + webview: Electron.WebviewTag; + state: BrowserRuntimeState; + onPersist: ((state: PersistableBrowserState) => void) | null; + webContentsId: number | null; + detachHandlers: () => void; + placeholder: HTMLElement | null; + resizeObserver: ResizeObserver | null; + visible: boolean; +} + +const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ + currentUrl: "about:blank", + pageTitle: "", + faviconUrl: null, + isLoading: false, + error: null, + canGoBack: false, + canGoForward: false, +}); + +const ROOT_CONTAINER_ID = "browser-runtime-root"; + +class BrowserRuntimeRegistryImpl { + private entries = new Map(); + private listenersByPaneId = new Map void>>(); + private rootContainer: HTMLDivElement | null = null; + private globalListenersInstalled = false; + + private getListeners(paneId: string): Set<() => void> { + let set = this.listenersByPaneId.get(paneId); + if (!set) { + set = new Set(); + this.listenersByPaneId.set(paneId, set); + } + return set; + } + + private ensureRootContainer(): HTMLDivElement { + if (this.rootContainer?.isConnected) return this.rootContainer; + const existing = document.getElementById( + ROOT_CONTAINER_ID, + ) as HTMLDivElement | null; + if (existing) { + this.rootContainer = existing; + return existing; + } + const root = document.createElement("div"); + root.id = ROOT_CONTAINER_ID; + root.style.position = "fixed"; + root.style.top = "0"; + root.style.left = "0"; + root.style.width = "0"; + root.style.height = "0"; + root.style.pointerEvents = "none"; + root.style.zIndex = "0"; + document.body.appendChild(root); + this.rootContainer = root; + this.installGlobalListeners(); + return root; + } + + private installGlobalListeners() { + if (this.globalListenersInstalled) return; + this.globalListenersInstalled = true; + + const setPassthrough = (passthrough: boolean) => { + for (const entry of this.entries.values()) { + if (!entry.visible) continue; + entry.webview.style.pointerEvents = passthrough ? "none" : "auto"; + } + }; + window.addEventListener("dragstart", () => setPassthrough(true), true); + window.addEventListener("dragend", () => setPassthrough(false), true); + window.addEventListener("drop", () => setPassthrough(false), true); + + window.addEventListener("resize", () => { + for (const entry of this.entries.values()) { + if (entry.placeholder) this.updateLayout(entry); + } + }); + } + + private updateLayout(entry: RegistryEntry) { + if (!entry.placeholder) return; + const rect = entry.placeholder.getBoundingClientRect(); + const w = entry.webview; + w.style.top = `${rect.top}px`; + w.style.left = `${rect.left}px`; + w.style.width = `${rect.width}px`; + w.style.height = `${rect.height}px`; + } + + private notify(paneId: string) { + const listeners = this.listenersByPaneId.get(paneId); + if (!listeners) return; + for (const listener of listeners) listener(); + } + + private setState(paneId: string, patch: Partial) { + const entry = this.entries.get(paneId); + if (!entry) return; + let changed = false; + for (const key in patch) { + const k = key as keyof BrowserRuntimeState; + if (entry.state[k] !== patch[k]) { + changed = true; + break; + } + } + if (!changed) return; + entry.state = { ...entry.state, ...patch }; + this.notify(paneId); + } + + private refreshNavState(paneId: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + let canGoBack = false; + let canGoForward = false; + try { + canGoBack = entry.webview.canGoBack(); + canGoForward = entry.webview.canGoForward(); + } catch {} + this.setState(paneId, { canGoBack, canGoForward }); + } + + private createEntry(paneId: string, initialUrl: string): RegistryEntry { + const webview = document.createElement("webview") as Electron.WebviewTag; + webview.setAttribute("partition", "persist:superset"); + webview.setAttribute("allowpopups", ""); + webview.style.position = "fixed"; + webview.style.top = "0"; + webview.style.left = "0"; + webview.style.width = "0"; + webview.style.height = "0"; + webview.style.margin = "0"; + webview.style.padding = "0"; + webview.style.border = "none"; + webview.style.visibility = "hidden"; + webview.style.pointerEvents = "auto"; + webview.src = sanitizeUrl(initialUrl); + + const entry: RegistryEntry = { + webview, + state: { ...EMPTY_STATE, currentUrl: initialUrl }, + onPersist: null, + webContentsId: null, + detachHandlers: () => {}, + placeholder: null, + resizeObserver: null, + visible: false, + }; + + const firePersist = () => { + entry.onPersist?.({ + url: entry.state.currentUrl, + pageTitle: entry.state.pageTitle, + faviconUrl: entry.state.faviconUrl, + }); + }; + + const handleDomReady = () => { + const webContentsId = webview.getWebContentsId(); + if (entry.webContentsId !== webContentsId) { + entry.webContentsId = webContentsId; + electronTrpcClient.browser.register + .mutate({ paneId, webContentsId }) + .catch((err) => { + console.error("[browserRuntimeRegistry] register failed:", err); + }); + } + }; + + const handleDidStartLoading = () => { + this.setState(paneId, { + isLoading: true, + error: null, + faviconUrl: null, + }); + }; + + const handleDidStopLoading = () => { + const url = webview.getURL() ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { + isLoading: false, + currentUrl: url, + pageTitle: title, + }); + this.refreshNavState(paneId); + if (url && url !== "about:blank") { + electronTrpcClient.browserHistory.upsert + .mutate({ url, title, faviconUrl: entry.state.faviconUrl }) + .catch((err) => { + console.error("[browserRuntimeRegistry] upsert history:", err); + }); + } + firePersist(); + }; + + const handleDidNavigate = (e: Electron.DidNavigateEvent) => { + const url = e.url ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { + currentUrl: url, + pageTitle: title, + isLoading: false, + }); + this.refreshNavState(paneId); + }; + + const handleDidNavigateInPage = (e: Electron.DidNavigateInPageEvent) => { + const url = e.url ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { currentUrl: url, pageTitle: title }); + this.refreshNavState(paneId); + }; + + const handlePageTitleUpdated = (e: Electron.PageTitleUpdatedEvent) => { + this.setState(paneId, { pageTitle: e.title ?? "" }); + }; + + const handlePageFaviconUpdated = (e: Electron.PageFaviconUpdatedEvent) => { + const favicon = e.favicons?.[0]; + if (!favicon || favicon === entry.state.faviconUrl) return; + this.setState(paneId, { faviconUrl: favicon }); + const { currentUrl, pageTitle } = entry.state; + if (currentUrl && currentUrl !== "about:blank") { + electronTrpcClient.browserHistory.upsert + .mutate({ url: currentUrl, title: pageTitle, faviconUrl: favicon }) + .catch((err) => { + console.error("[browserRuntimeRegistry] upsert favicon:", err); + }); + } + firePersist(); + }; + + const handleDidFailLoad = (e: Electron.DidFailLoadEvent) => { + if (e.errorCode === -3) return; // ERR_ABORTED + this.setState(paneId, { + isLoading: false, + error: { + code: e.errorCode ?? 0, + description: e.errorDescription ?? "", + url: e.validatedURL ?? "", + }, + }); + }; + + webview.addEventListener("dom-ready", handleDomReady); + webview.addEventListener("did-start-loading", handleDidStartLoading); + webview.addEventListener("did-stop-loading", handleDidStopLoading); + webview.addEventListener( + "did-navigate", + handleDidNavigate as EventListener, + ); + webview.addEventListener( + "did-navigate-in-page", + handleDidNavigateInPage as EventListener, + ); + webview.addEventListener( + "page-title-updated", + handlePageTitleUpdated as EventListener, + ); + webview.addEventListener( + "page-favicon-updated", + handlePageFaviconUpdated as EventListener, + ); + webview.addEventListener( + "did-fail-load", + handleDidFailLoad as EventListener, + ); + + entry.detachHandlers = () => { + webview.removeEventListener("dom-ready", handleDomReady); + webview.removeEventListener("did-start-loading", handleDidStartLoading); + webview.removeEventListener("did-stop-loading", handleDidStopLoading); + webview.removeEventListener( + "did-navigate", + handleDidNavigate as EventListener, + ); + webview.removeEventListener( + "did-navigate-in-page", + handleDidNavigateInPage as EventListener, + ); + webview.removeEventListener( + "page-title-updated", + handlePageTitleUpdated as EventListener, + ); + webview.removeEventListener( + "page-favicon-updated", + handlePageFaviconUpdated as EventListener, + ); + webview.removeEventListener( + "did-fail-load", + handleDidFailLoad as EventListener, + ); + }; + + return entry; + } + + attach( + paneId: string, + placeholder: HTMLElement, + initialUrl: string, + onPersist: (state: PersistableBrowserState) => void, + ): void { + const root = this.ensureRootContainer(); + let entry = this.entries.get(paneId); + if (!entry) { + entry = this.createEntry(paneId, initialUrl); + this.entries.set(paneId, entry); + root.appendChild(entry.webview); + } else { + this.refreshNavState(paneId); + } + entry.onPersist = onPersist; + entry.placeholder = placeholder; + entry.visible = true; + + entry.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + if (entry) this.updateLayout(entry); + }); + observer.observe(placeholder); + entry.resizeObserver = observer; + + this.updateLayout(entry); + entry.webview.style.visibility = "visible"; + } + + detach(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.onPersist = null; + entry.placeholder = null; + entry.resizeObserver?.disconnect(); + entry.resizeObserver = null; + entry.visible = false; + entry.webview.style.visibility = "hidden"; + } + + destroy(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.resizeObserver?.disconnect(); + entry.detachHandlers(); + entry.webview.remove(); + this.entries.delete(paneId); + this.listenersByPaneId.delete(paneId); + electronTrpcClient.browser.unregister.mutate({ paneId }).catch(() => {}); + } + + navigate(paneId: string, url: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.webview.loadURL(sanitizeUrl(url)).catch((err) => { + console.error("[browserRuntimeRegistry] loadURL failed:", err); + }); + } + + goBack(paneId: string): void { + const entry = this.entries.get(paneId); + if (entry?.webview.canGoBack()) entry.webview.goBack(); + } + + goForward(paneId: string): void { + const entry = this.entries.get(paneId); + if (entry?.webview.canGoForward()) entry.webview.goForward(); + } + + reload(paneId: string): void { + const entry = this.entries.get(paneId); + entry?.webview.reload(); + } + + getState(paneId: string): BrowserRuntimeState { + return this.entries.get(paneId)?.state ?? EMPTY_STATE; + } + + onStateChange(paneId: string, listener: () => void): () => void { + const listeners = this.getListeners(paneId); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } +} + +export const browserRuntimeRegistry: BrowserRuntimeRegistryImpl = + (import.meta.hot?.data?.browserRegistry as + | BrowserRuntimeRegistryImpl + | undefined) ?? new BrowserRuntimeRegistryImpl(); + +if (import.meta.hot) { + import.meta.hot.data.browserRegistry = browserRuntimeRegistry; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx new file mode 100644 index 0000000000..ff97cc277b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx @@ -0,0 +1,109 @@ +import { Button } from "@superset/ui/button"; +import { GlobeIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { TbCopy } from "react-icons/tb"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import type { BrowserLoadError } from "shared/tabs-types"; + +const ERROR_LABELS: Record = { + [-2]: "Network Changed", + [-6]: "Connection Refused", + [-7]: "Connection Timed Out", + [-21]: "Network Changed", + [-100]: "Connection Closed", + [-102]: "Connection Refused", + [-105]: "Name Not Resolved", + [-106]: "Internet Disconnected", + [-109]: "Address Unreachable", + [-118]: "Connection Timed Out", + [-137]: "Name Not Resolved", + [-200]: "Certificate Error", + [-201]: "Certificate Date Invalid", + [-202]: "Certificate Authority Invalid", +}; + +const FRIENDLY_MESSAGES: Record = { + [-2]: "The network connection changed", + [-6]: "Browser Connection was refused", + [-7]: "The connection timed out", + [-21]: "The network connection changed", + [-100]: "The connection was closed", + [-102]: "Browser Connection was refused", + [-105]: "The server could not be found", + [-106]: "You appear to be offline", + [-109]: "The address is unreachable", + [-118]: "The connection timed out", + [-137]: "The server could not be found", + [-200]: "The site's certificate is invalid", + [-201]: "The site's certificate has expired", + [-202]: "The site's certificate authority is not trusted", +}; + +interface BrowserErrorOverlayProps { + error: BrowserLoadError; + onRetry: () => void; +} + +export function BrowserErrorOverlay({ + error, + onRetry, +}: BrowserErrorOverlayProps) { + const [showDetails, setShowDetails] = useState(false); + const label = ERROR_LABELS[error.code] ?? "Page Load Failed"; + const friendlyMessage = + FRIENDLY_MESSAGES[error.code] ?? "The page could not be loaded"; + const detailsText = `Error Code: ${error.code} URL: ${error.url}`; + + const toggleDetails = useCallback(() => { + setShowDetails((prev) => !prev); + }, []); + + const { copyToClipboard } = useCopyToClipboard(); + const copyDetails = useCallback(() => { + copyToClipboard(detailsText); + }, [detailsText, copyToClipboard]); + + return ( +
+
+ +
+

+ {label} +

+

+ {friendlyMessage} +

+

+ {error.description} + {" · "} + +

+
+ {showDetails && ( +
+ + {detailsText} + + +
+ )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts new file mode 100644 index 0000000000..3d3140770b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts @@ -0,0 +1 @@ +export { BrowserErrorOverlay } from "./BrowserErrorOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx new file mode 100644 index 0000000000..596aef63d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx @@ -0,0 +1,115 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { + TbCamera, + TbClock, + TbCopy, + TbDots, + TbReload, + TbTrash, +} from "react-icons/tb"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +interface BrowserOverflowMenuProps { + paneId: string; + currentUrl: string; + hasPage: boolean; +} + +export function BrowserOverflowMenu({ + paneId, + currentUrl, + hasPage, +}: BrowserOverflowMenuProps) { + const { copyToClipboard } = useCopyToClipboard(); + + const handleScreenshot = () => { + electronTrpcClient.browser.screenshot.mutate({ paneId }).catch(() => {}); + }; + + const handleHardReload = () => { + electronTrpcClient.browser.reload + .mutate({ paneId, hard: true }) + .catch(() => {}); + }; + + const handleCopyUrl = () => { + if (currentUrl) { + copyToClipboard(currentUrl); + } + }; + + const handleClearCookies = () => { + electronTrpcClient.browser.clearBrowsingData + .mutate({ type: "cookies" }) + .catch(() => {}); + }; + + const handleClearHistory = () => { + electronTrpcClient.browserHistory.clear.mutate().catch(() => {}); + }; + + const handleClearAllData = () => { + electronTrpcClient.browser.clearBrowsingData + .mutate({ type: "all" }) + .catch(() => {}); + }; + + return ( + + + + + + + + Take Screenshot + + + + Hard Reload + + + + Copy URL + + + + + Clear Browsing History + + + + Clear Cookies + + + + Clear All Data + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts new file mode 100644 index 0000000000..abd17b406e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts @@ -0,0 +1 @@ +export { BrowserOverflowMenu } from "./BrowserOverflowMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx new file mode 100644 index 0000000000..1c6cff146d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx @@ -0,0 +1,215 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + TbArrowLeft, + TbArrowRight, + TbLoader2, + TbRefresh, +} from "react-icons/tb"; +import { UrlSuggestions } from "./components/UrlSuggestions"; +import { useUrlAutocomplete } from "./hooks/useUrlAutocomplete"; + +function displayUrl(url: string): string { + if (url === "about:blank") return ""; + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +interface BrowserToolbarProps { + currentUrl: string; + pageTitle: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + onGoBack: () => void; + onGoForward: () => void; + onReload: () => void; + onNavigate: (url: string) => void; +} + +export function BrowserToolbar({ + currentUrl, + pageTitle, + isLoading, + canGoBack, + canGoForward, + onGoBack, + onGoForward, + onReload, + onNavigate, +}: BrowserToolbarProps) { + const [isEditing, setIsEditing] = useState(false); + const [urlInputValue, setUrlInputValue] = useState(""); + const inputRef = useRef(null); + + const url = displayUrl(currentUrl); + const isBlank = !url; + + const autocomplete = useUrlAutocomplete({ + onSelect: (selectedUrl) => { + onNavigate(selectedUrl); + setIsEditing(false); + }, + }); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [isEditing]); + + const enterEditMode = useCallback(() => { + setUrlInputValue(url); + setIsEditing(true); + autocomplete.open(); + autocomplete.updateQuery(url); + }, [url, autocomplete]); + + const exitEditMode = useCallback(() => { + setIsEditing(false); + autocomplete.close(); + }, [autocomplete]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = urlInputValue.trim(); + if (trimmed) { + onNavigate(trimmed); + setIsEditing(false); + autocomplete.close(); + } + }, + [urlInputValue, onNavigate, autocomplete], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setUrlInputValue(value); + autocomplete.updateQuery(value); + if (!autocomplete.isOpen) { + autocomplete.open(); + } + }, + [autocomplete], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const handled = autocomplete.handleKeyDown(e); + if (handled) return; + if (e.key === "Escape") { + setIsEditing(false); + } + }, + [autocomplete], + ); + + return ( +
+
+ + + + + + Go Back + + + + + + + + Go Forward + + + + + + + + {isLoading ? "Loading..." : "Reload"} + + +
+
+
+ {isEditing ? ( +
+ +
+ ) : ( + + )} + {isEditing && autocomplete.isOpen && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx new file mode 100644 index 0000000000..af33567639 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { TbGlobe } from "react-icons/tb"; +import type { HistorySuggestion } from "../../hooks/useUrlAutocomplete"; + +interface UrlSuggestionsProps { + suggestions: HistorySuggestion[]; + highlightedIndex: number; + onSelect: (url: string) => void; +} + +export function UrlSuggestions({ + suggestions, + highlightedIndex, + onSelect, +}: UrlSuggestionsProps) { + const listRef = useRef(null); + + useEffect(() => { + if (highlightedIndex < 0 || !listRef.current) return; + const items = listRef.current.children; + const item = items[highlightedIndex] as HTMLElement | undefined; + item?.scrollIntoView({ block: "nearest" }); + }, [highlightedIndex]); + + if (suggestions.length === 0) return null; + + return ( +
+ {suggestions.map((item, index) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts new file mode 100644 index 0000000000..482fccd364 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts @@ -0,0 +1 @@ +export { UrlSuggestions } from "./UrlSuggestions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts new file mode 100644 index 0000000000..0530813da5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts @@ -0,0 +1,4 @@ +export { + type HistorySuggestion, + useUrlAutocomplete, +} from "./useUrlAutocomplete"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts new file mode 100644 index 0000000000..185f39af07 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +export interface HistorySuggestion { + url: string; + title: string; + faviconUrl: string | null; + lastVisitedAt: number; + visitCount: number; +} + +interface UseUrlAutocompleteOptions { + onSelect: (url: string) => void; +} + +export function useUrlAutocomplete({ onSelect }: UseUrlAutocompleteOptions) { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [query, setQuery] = useState(""); + const [allHistory, setAllHistory] = useState([]); + const suggestionsRef = useRef([]); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + electronTrpcClient.browserHistory.getAll + .query() + .then((items) => { + if (!cancelled) setAllHistory(items as HistorySuggestion[]); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [isOpen]); + + const suggestions = useMemo(() => { + if (!query.trim()) { + return allHistory.slice(0, 15); + } + const lower = query.toLowerCase(); + return allHistory + .filter( + (item) => + item.url.toLowerCase().includes(lower) || + item.title.toLowerCase().includes(lower), + ) + .slice(0, 8); + }, [allHistory, query]); + + suggestionsRef.current = suggestions; + + const open = useCallback(() => { + setIsOpen(true); + setHighlightedIndex(-1); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + setHighlightedIndex(-1); + }, []); + + const updateQuery = useCallback((value: string) => { + setQuery(value); + setHighlightedIndex(-1); + }, []); + + const selectSuggestion = useCallback( + (url: string) => { + onSelect(url); + close(); + }, + [onSelect, close], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): boolean => { + if (!isOpen || suggestions.length === 0) return false; + + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : 0, + ); + return true; + } + case "ArrowUp": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : suggestions.length - 1, + ); + return true; + } + case "Enter": { + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + e.preventDefault(); + selectSuggestion(suggestions[highlightedIndex].url); + return true; + } + return false; + } + case "Escape": { + if (isOpen) { + e.preventDefault(); + e.stopPropagation(); + close(); + return true; + } + return false; + } + default: + return false; + } + }, + [isOpen, suggestions, highlightedIndex, selectSuggestion, close], + ); + + return { + isOpen, + suggestions, + highlightedIndex, + open, + close, + updateQuery, + selectSuggestion, + handleKeyDown, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts new file mode 100644 index 0000000000..787341a48a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts @@ -0,0 +1 @@ +export { BrowserToolbar } from "./BrowserToolbar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts new file mode 100644 index 0000000000..8ff6c1ff18 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_BROWSER_URL = "about:blank"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts new file mode 100644 index 0000000000..e7521359ce --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts @@ -0,0 +1 @@ +export { usePersistentWebview } from "./usePersistentWebview"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts new file mode 100644 index 0000000000..afc8b27ba7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -0,0 +1,115 @@ +import type { RendererContext } from "@superset/panes"; +import { useCallback, useEffect, useRef } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { + BrowserPaneData, + PaneViewerData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { browserRuntimeRegistry } from "../../browserRuntimeRegistry"; +import { DEFAULT_BROWSER_URL } from "../../constants"; + +interface UsePersistentWebviewOptions { + paneId: string; + ctx: RendererContext; +} + +export function usePersistentWebview({ + paneId, + ctx, +}: UsePersistentWebviewOptions) { + const placeholderRef = useRef(null); + const ctxRef = useRef(ctx); + ctxRef.current = ctx; + + const paneData = ctx.pane.data as BrowserPaneData; + const initialUrlRef = useRef(paneData.url || DEFAULT_BROWSER_URL); + + useEffect(() => { + const placeholder = placeholderRef.current; + if (!placeholder) return; + + browserRuntimeRegistry.attach( + paneId, + placeholder, + initialUrlRef.current, + ({ url, pageTitle, faviconUrl }) => { + const current = ctxRef.current.pane.data as BrowserPaneData; + if ( + current.url === url && + current.pageTitle === pageTitle && + current.faviconUrl === faviconUrl + ) + return; + ctxRef.current.actions.updateData({ + ...current, + url, + pageTitle, + faviconUrl, + }); + }, + ); + + return () => { + browserRuntimeRegistry.detach(paneId); + }; + }, [paneId]); + + useEffect(() => { + const newWindowSub = electronTrpcClient.browser.onNewWindow.subscribe( + { paneId }, + { + onData: ({ url }: { url: string }) => { + ctxRef.current.actions.split("right", { + kind: "browser", + data: { url } as BrowserPaneData, + }); + }, + }, + ); + const contextMenuSub = + electronTrpcClient.browser.onContextMenuAction.subscribe( + { paneId }, + { + onData: ({ action, url }: { action: string; url: string }) => { + if (action === "open-in-split") { + ctxRef.current.actions.split("right", { + kind: "browser", + data: { url } as BrowserPaneData, + }); + } + }, + }, + ); + return () => { + newWindowSub.unsubscribe(); + contextMenuSub.unsubscribe(); + }; + }, [paneId]); + + const goBack = useCallback(() => { + browserRuntimeRegistry.goBack(paneId); + }, [paneId]); + + const goForward = useCallback(() => { + browserRuntimeRegistry.goForward(paneId); + }, [paneId]); + + const reload = useCallback(() => { + browserRuntimeRegistry.reload(paneId); + }, [paneId]); + + const navigateTo = useCallback( + (url: string) => { + browserRuntimeRegistry.navigate(paneId, url); + }, + [paneId], + ); + + return { + placeholderRef, + goBack, + goForward, + reload, + navigateTo, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts new file mode 100644 index 0000000000..ac9bea3ed4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts @@ -0,0 +1,7 @@ +export { + BrowserPane, + BrowserPaneToolbar, + getBrowserTabTitle, + renderBrowserTabIcon, +} from "./BrowserPane"; +export { browserRuntimeRegistry } from "./browserRuntimeRegistry"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts new file mode 100644 index 0000000000..422be7ed5d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts @@ -0,0 +1,13 @@ +export function sanitizeUrl(url: string): string { + const value = url.trim(); + if (/^https?:\/\//i.test(value) || value.startsWith("about:")) { + return value; + } + if (/^(localhost|127\.0\.0\.1)(:\d+)?(\/.*)?$/i.test(value)) { + return `http://${value}`; + } + if (/^[^\s/]+\.[^\s]+(\/.*)?$/.test(value)) { + return `https://${value}`; + } + return `https://www.google.com/search?q=${encodeURIComponent(value)}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx index 56f285e09a..4297c56879 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -98,7 +98,7 @@ function FilePaneContent({ context, workspaceId }: FilePaneProps) { if (document.state.kind === "not-found") { return ( -
+
File not found
); @@ -106,7 +106,7 @@ function FilePaneContent({ context, workspaceId }: FilePaneProps) { if (document.state.kind === "too-large") { return ( -
+
File is too large to display
); @@ -119,7 +119,7 @@ function FilePaneContent({ context, workspaceId }: FilePaneProps) { ); } return ( -
+
Binary file — cannot display
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 78337f07c2..09b11820b5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -23,6 +23,11 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; +import { + BrowserPane, + BrowserPaneToolbar, + browserRuntimeRegistry, +} from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; import { FilePane } from "./components/FilePane"; import { TerminalPane } from "./components/TerminalPane"; @@ -175,6 +180,7 @@ export function usePaneRegistry( getIcon: () => , getTitle: (ctx: RendererContext) => { const data = ctx.pane.data as BrowserPaneData; +<<<<<<< HEAD return data.url; }, renderPane: (ctx: RendererContext) => { @@ -191,7 +197,17 @@ export function usePaneRegistry( title={ctx.pane.titleOverride ?? "Browser"} /> ); +======= + return data.pageTitle || data.url; +>>>>>>> 2c6736416 (feat(desktop): port browser pane to v2 workspaces with global persistence (#3346)) }, + renderPane: (ctx: RendererContext) => ( + + ), + renderToolbar: (ctx: RendererContext) => ( + + ), + onRemoved: (pane) => browserRuntimeRegistry.destroy(pane.id), contextMenuActions: (_ctx, defaults) => defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index cbf9ed57e6..6c1c3468af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -54,8 +54,7 @@ export function useWorkspaceHotkeys({ { kind: "browser", data: { - url: "http://localhost:3000", - mode: "preview", + url: "about:blank", } as BrowserPaneData, }, ], @@ -225,8 +224,7 @@ export function useWorkspaceHotkeys({ newPane: { kind: "browser", data: { - url: "http://localhost:3000", - mode: "preview", + url: "about:blank", } as BrowserPaneData, }, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 43ce690e0c..87d6840d5e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -37,6 +37,10 @@ import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; +import { + getBrowserTabTitle, + renderBrowserTabIcon, +} from "./hooks/usePaneRegistry/components/BrowserPane"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; import type { @@ -274,8 +278,7 @@ function WorkspaceContent({ { kind: "browser", data: { - url: "http://localhost:3000", - mode: "preview", + url: "about:blank", } as BrowserPaneData, }, ], @@ -390,6 +393,8 @@ function WorkspaceContent({ registry={paneRegistry} paneActions={defaultPaneActions} contextMenuActions={defaultContextMenuActions} + getTabTitle={getBrowserTabTitle} + renderTabIcon={renderBrowserTabIcon} renderBelowTabBar={() => ( ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index 07d4bc41f1..7e03225716 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx @@ -7,52 +7,64 @@ import { useMemo, useState, } from "react"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; +import type { WorkspaceHostTarget } from "./components/DashboardNewWorkspaceForm/components/DevicePicker"; +import { useCreateDashboardWorkspace } from "./hooks/useCreateDashboardWorkspace"; + +export type LinkedIssue = { + slug: string; // "#123" for GitHub, "SUP-123" for internal + title: string; + source?: "github" | "internal"; + url?: string; // GitHub issue URL + taskId?: string; // Internal task ID for navigation + number?: number; // GitHub issue number + state?: "open" | "closed"; +}; -export type DashboardNewWorkspaceTab = - | "prompt" - | "issues" - | "pull-requests" - | "branches"; +export type LinkedPR = { + prNumber: number; + title: string; + url: string; + state: string; +}; export interface DashboardNewWorkspaceDraft { - activeTab: DashboardNewWorkspaceTab; selectedProjectId: string | null; hostTarget: WorkspaceHostTarget; prompt: string; + baseBranch: string | null; + runSetupScript: boolean; + workspaceName: string; + workspaceNameEdited: boolean; branchName: string; branchNameEdited: boolean; - compareBaseBranch: string | null; - showAdvanced: boolean; - branchSearch: string; - issuesQuery: string; - pullRequestsQuery: string; - branchesQuery: string; + linkedIssues: LinkedIssue[]; + linkedPR: LinkedPR | null; } interface DashboardNewWorkspaceDraftState extends DashboardNewWorkspaceDraft { draftVersion: number; + resetKey: number; } const initialDraft: DashboardNewWorkspaceDraft = { - activeTab: "prompt", selectedProjectId: null, hostTarget: { kind: "local" }, prompt: "", + baseBranch: null, + runSetupScript: true, + workspaceName: "", + workspaceNameEdited: false, branchName: "", branchNameEdited: false, - compareBaseBranch: null, - showAdvanced: false, - branchSearch: "", - issuesQuery: "", - pullRequestsQuery: "", - branchesQuery: "", + linkedIssues: [], + linkedPR: null, }; function buildInitialDraftState(): DashboardNewWorkspaceDraftState { return { ...initialDraft, draftVersion: 0, + resetKey: 0, }; } @@ -69,8 +81,10 @@ interface DashboardNewWorkspaceActionOptions { interface DashboardNewWorkspaceDraftContextValue { draft: DashboardNewWorkspaceDraft; draftVersion: number; + resetKey: number; closeModal: () => void; closeAndResetDraft: () => void; + createWorkspace: ReturnType; runAsyncAction: ( promise: Promise, messages: DashboardNewWorkspaceActionMessages, @@ -89,26 +103,16 @@ export function DashboardNewWorkspaceDraftProvider({ }: PropsWithChildren<{ onClose: () => void }>) { const [state, setState] = useState(buildInitialDraftState); + // Owned here so onSuccess survives Dialog unmounting content on close. + const createWorkspace = useCreateDashboardWorkspace(); + const updateDraft = useCallback( (patch: Partial) => { - setState((state) => { - const entries = Object.entries(patch) as Array< - [ - keyof DashboardNewWorkspaceDraft, - DashboardNewWorkspaceDraft[keyof DashboardNewWorkspaceDraft], - ] - >; - const hasChanges = entries.some(([key, value]) => state[key] !== value); - if (!hasChanges) { - return state; - } - - return { - ...state, - ...patch, - draftVersion: state.draftVersion + 1, - }; - }); + setState((state) => ({ + ...state, + ...patch, + draftVersion: state.draftVersion + 1, + })); }, [], ); @@ -117,6 +121,7 @@ export function DashboardNewWorkspaceDraftProvider({ setState((state) => ({ ...initialDraft, draftVersion: state.draftVersion + 1, + resetKey: state.resetKey + 1, })); }, []); @@ -148,28 +153,30 @@ export function DashboardNewWorkspaceDraftProvider({ const value = useMemo( () => ({ draft: { - activeTab: state.activeTab, selectedProjectId: state.selectedProjectId, hostTarget: state.hostTarget, prompt: state.prompt, + baseBranch: state.baseBranch, + runSetupScript: state.runSetupScript, + workspaceName: state.workspaceName, + workspaceNameEdited: state.workspaceNameEdited, branchName: state.branchName, branchNameEdited: state.branchNameEdited, - compareBaseBranch: state.compareBaseBranch, - showAdvanced: state.showAdvanced, - branchSearch: state.branchSearch, - issuesQuery: state.issuesQuery, - pullRequestsQuery: state.pullRequestsQuery, - branchesQuery: state.branchesQuery, + linkedIssues: state.linkedIssues, + linkedPR: state.linkedPR, }, draftVersion: state.draftVersion, + resetKey: state.resetKey, closeModal: onClose, closeAndResetDraft, + createWorkspace, runAsyncAction, updateDraft, resetDraft, }), [ closeAndResetDraft, + createWorkspace, onClose, resetDraft, runAsyncAction, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx index 12bf45f75d..6e887f5c2f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx @@ -1,3 +1,7 @@ +import { + PromptInputProvider, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; import { Dialog, DialogContent, @@ -5,38 +9,65 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; +import { useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; -import { DashboardNewWorkspaceForm } from "./components/DashboardNewWorkspaceForm"; -import { DashboardNewWorkspaceDraftProvider } from "./DashboardNewWorkspaceDraftContext"; +import { DashboardNewWorkspaceModalContent } from "./components/DashboardNewWorkspaceModalContent"; +import { + DashboardNewWorkspaceDraftProvider, + useDashboardNewWorkspaceDraft, +} from "./DashboardNewWorkspaceDraftContext"; + +/** Clears the PromptInputProvider text & attachments when the draft resets. */ +function PromptInputResetSync() { + const { resetKey } = useDashboardNewWorkspaceDraft(); + const { textInput, attachments } = usePromptInputController(); + const prevResetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKey !== prevResetKeyRef.current) { + prevResetKeyRef.current = resetKey; + textInput.clear(); + attachments.clear(); + } + }, [resetKey, textInput.clear, attachments.clear]); + + return null; +} export function DashboardNewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); const preSelectedProjectId = usePreSelectedProjectId(); + // Prevents AgentSelect from flashing "No agent" while presets load after refresh. + electronTrpc.settings.getAgentPresets.useQuery(); + return ( - !open && closeModal()}> - - New Workspace - - Create a new workspace from a PR, branch, issue, or prompt. - - - - - - + + + !open && closeModal()}> + + New Workspace + Create a new workspace + + e.preventDefault()} + className="bg-popover text-popover-foreground sm:max-w-[560px] max-h-[min(70vh,600px)] !top-[calc(50%-min(35vh,300px))] !-translate-y-0 flex flex-col overflow-hidden p-0" + > + + + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx deleted file mode 100644 index fe5b9b3ab0..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback } from "react"; -import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; -import { DashboardNewWorkspaceFormHeader } from "./components/DashboardNewWorkspaceFormHeader"; -import { DashboardNewWorkspaceListTabContent } from "./components/DashboardNewWorkspaceListTabContent"; -import { DashboardNewWorkspacePromptTabContent } from "./components/DashboardNewWorkspacePromptTabContent"; -import { useDashboardNewWorkspaceProjectSelection } from "./hooks/useDashboardNewWorkspaceProjectSelection"; -import { useResolvedLocalProject } from "./hooks/useResolvedLocalProject"; - -interface DashboardNewWorkspaceFormProps { - isOpen: boolean; - preSelectedProjectId: string | null; -} - -/** Main form for the new workspace modal with collection-based project selection. */ -export function DashboardNewWorkspaceForm({ - isOpen, - preSelectedProjectId, -}: DashboardNewWorkspaceFormProps) { - const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); - const handleSelectProject = useCallback( - (selectedProjectId: string | null) => { - updateDraft({ selectedProjectId }); - }, - [updateDraft], - ); - const { githubRepository, githubRepositoryId } = - useDashboardNewWorkspaceProjectSelection({ - isOpen, - preSelectedProjectId, - selectedProjectId: draft.selectedProjectId, - onSelectProject: handleSelectProject, - }); - const resolvedLocalProjectId = useResolvedLocalProject(githubRepository); - - const listTab = draft.activeTab === "prompt" ? null : draft.activeTab; - const isListTab = listTab !== null; - const listQuery = - draft.activeTab === "issues" - ? draft.issuesQuery - : draft.activeTab === "branches" - ? draft.branchesQuery - : draft.pullRequestsQuery; - - const handleListQueryChange = (value: string) => { - switch (draft.activeTab) { - case "issues": - updateDraft({ issuesQuery: value }); - return; - case "branches": - updateDraft({ branchesQuery: value }); - return; - case "pull-requests": - updateDraft({ pullRequestsQuery: value }); - return; - default: - return; - } - }; - - return ( - <> - updateDraft({ activeTab })} - onSelectHostTarget={(hostTarget) => updateDraft({ hostTarget })} - onSelectProject={handleSelectProject} - /> - - {isListTab ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx new file mode 100644 index 0000000000..317eec2d65 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -0,0 +1,435 @@ +import { + PromptInput, + PromptInputAttachment, + PromptInputAttachments, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + useProviderAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { Input } from "@superset/ui/input"; +import { cn } from "@superset/ui/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowUpIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuGitPullRequest } from "react-icons/lu"; +import { AgentSelect } from "renderer/components/AgentSelect"; +import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; +import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; +import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; +import { PLATFORM } from "renderer/hotkeys"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useNewWorkspaceModalOpen } from "renderer/stores/new-workspace-modal"; +import { getEnabledAgentConfigs } from "shared/utils/agent-settings"; +import { sanitizeUserBranchName, slugifyForBranch } from "shared/utils/branch"; +import type { LinkedPR } from "../../../DashboardNewWorkspaceDraftContext"; +import { useDashboardNewWorkspaceDraft } from "../../../DashboardNewWorkspaceDraftContext"; +import { DevicePicker } from "../components/DevicePicker"; +import { useBranchContext } from "../hooks/useBranchContext"; +import { AttachmentButtons } from "./components/AttachmentButtons"; +import { CompareBaseBranchPicker } from "./components/CompareBaseBranchPicker"; +import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; +import { LinkedGitHubIssuePill } from "./components/LinkedGitHubIssuePill"; +import { LinkedPRPill } from "./components/LinkedPRPill"; +import { PRLinkCommand } from "./components/PRLinkCommand"; +import { ProjectPickerPill } from "./components/ProjectPickerPill"; +import { useSubmitWorkspace } from "./hooks/useSubmitWorkspace"; +import { + AGENT_STORAGE_KEY, + PILL_BUTTON_CLASS, + type ProjectOption, + type WorkspaceCreateAgent, +} from "./types"; + +interface PromptGroupProps { + projectId: string | null; + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; +} + +export function PromptGroup(props: PromptGroupProps) { + return ; +} + +function PromptGroupInner({ + projectId, + selectedProject, + recentProjects, + onSelectProject, +}: PromptGroupProps) { + const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; + const isNewWorkspaceModalOpen = useNewWorkspaceModalOpen(); + const { closeModal, draft, updateDraft } = useDashboardNewWorkspaceDraft(); + const attachments = useProviderAttachments(); + const { + baseBranch, + hostTarget, + prompt, + workspaceName, + branchName, + branchNameEdited, + linkedIssues, + linkedPR, + } = draft; + + // ── Agent presets ──────────────────────────────────────────────── + const agentPresetsQuery = electronTrpc.settings.getAgentPresets.useQuery(); + const enabledAgentPresets = useMemo( + () => getEnabledAgentConfigs(agentPresetsQuery.data ?? []), + [agentPresetsQuery.data], + ); + const selectableAgentIds = useMemo( + () => enabledAgentPresets.map((preset) => preset.id), + [enabledAgentPresets], + ); + const { selectedAgent, setSelectedAgent } = + useAgentLaunchPreferences({ + agentStorageKey: AGENT_STORAGE_KEY, + defaultAgent: "claude", + fallbackAgent: "none", + validAgents: ["none", ...selectableAgentIds], + agentsReady: agentPresetsQuery.isFetched, + }); + + // ── Link commands ──────────────────────────────────────────────── + const [issueLinkOpen, setIssueLinkOpen] = useState(false); + const [gitHubIssueLinkOpen, setGitHubIssueLinkOpen] = useState(false); + const [prLinkOpen, setPRLinkOpen] = useState(false); + const plusMenuRef = useRef(null); + const trimmedPrompt = prompt.trim(); + + // ── Branch data ────────────────────────────────────────────────── + const { + data: branchData, + isLoading: isBranchesLoading, + isError: isBranchesError, + } = useBranchContext(projectId, hostTarget); + + const effectiveCompareBaseBranch = + baseBranch || branchData?.defaultBranch || null; + + const branchPreview = branchNameEdited + ? sanitizeUserBranchName(branchName) + : slugifyForBranch(trimmedPrompt); + + // Reset baseBranch on project or host change + const previousProjectIdRef = useRef(projectId); + const previousHostRef = useRef(JSON.stringify(hostTarget)); + useEffect(() => { + const nextHost = JSON.stringify(hostTarget); + if ( + previousProjectIdRef.current !== projectId || + previousHostRef.current !== nextHost + ) { + previousProjectIdRef.current = projectId; + previousHostRef.current = nextHost; + updateDraft({ baseBranch: null }); + } + }, [projectId, hostTarget, updateDraft]); + + // ── Create ─────────────────────────────────────────────────────── + const handleCreate = useSubmitWorkspace(projectId); + + const handlePromptSubmit = useCallback(() => { + void handleCreate(); + }, [handleCreate]); + + useEffect(() => { + if (!isNewWorkspaceModalOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleCreate(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isNewWorkspaceModalOpen, handleCreate]); + + // ── Issue / PR linking ─────────────────────────────────────────── + const addLinkedIssue = ( + slug: string, + title: string, + taskId: string | undefined, + url?: string, + ) => { + if (linkedIssues.some((issue) => issue.slug === slug)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { slug, title, source: "internal", taskId, url }, + ], + }); + }; + + const addLinkedGitHubIssue = ( + issueNumber: number, + title: string, + url: string, + state: string, + ) => { + if (linkedIssues.some((i) => i.url === url)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { + slug: `#${issueNumber}`, + title, + source: "github" as const, + url, + number: issueNumber, + state: state.toLowerCase() === "closed" ? "closed" : "open", + }, + ], + }); + }; + + const removeLinkedIssue = (slug: string) => + updateDraft({ + linkedIssues: linkedIssues.filter((i) => i.slug !== slug), + }); + + const setLinkedPR = (pr: LinkedPR) => updateDraft({ linkedPR: pr }); + const removeLinkedPR = () => updateDraft({ linkedPR: null }); + + // ── Render ──────────────────────────────────────────────────────── + return ( +
+ {/* Workspace name + branch name */} +
+ + updateDraft({ + workspaceName: e.target.value, + workspaceNameEdited: true, + }) + } + onBlur={() => { + if (!workspaceName.trim()) + updateDraft({ workspaceName: "", workspaceNameEdited: false }); + }} + /> +
+ + updateDraft({ + branchName: e.target.value.replace(/\s+/g, "-"), + branchNameEdited: true, + }) + } + onBlur={() => { + const sanitized = sanitizeUserBranchName(branchName.trim()); + if (!sanitized) + updateDraft({ branchName: "", branchNameEdited: false }); + else updateDraft({ branchName: sanitized }); + }} + /> +
+
+ + {/* Prompt input */} + + {(linkedPR || + linkedIssues.length > 0 || + attachments.files.length > 0) && ( +
+ + {linkedPR && ( + + + + )} + {linkedIssues.map((issue) => ( + + {issue.source === "github" && issue.number != null ? ( + removeLinkedIssue(issue.slug)} + /> + ) : ( + removeLinkedIssue(issue.slug)} + /> + )} + + ))} + + + {(file) => } + +
+ )} + updateDraft({ prompt: e.target.value })} + /> + + + + agents={enabledAgentPresets} + value={selectedAgent} + placeholder="No agent" + onValueChange={setSelectedAgent} + onBeforeConfigureAgents={closeModal} + triggerClassName={`${PILL_BUTTON_CLASS} px-1.5 gap-1 text-foreground w-auto max-w-[160px]`} + iconClassName="size-3 object-contain" + allowNone + noneLabel="No agent" + noneValue="none" + /> + +
+ + requestAnimationFrame(() => setIssueLinkOpen(true)) + } + onOpenGitHubIssue={() => + requestAnimationFrame(() => setGitHubIssueLinkOpen(true)) + } + onOpenPRLink={() => + requestAnimationFrame(() => setPRLinkOpen(true)) + } + /> + + + addLinkedGitHubIssue( + issue.issueNumber, + issue.title, + issue.url, + issue.state, + ) + } + projectId={projectId} + hostTarget={hostTarget} + anchorRef={plusMenuRef} + /> + + { + e.preventDefault(); + void handleCreate(); + }} + > + + +
+
+
+ + {/* Bottom bar */} +
+
+ + + {linkedPR ? ( + + + based off PR #{linkedPR.prNumber} + + ) : ( + + + updateDraft({ baseBranch: branch }) + } + /> + + )} + +
+
+ updateDraft({ hostTarget: t })} + /> + + {modKey}↵ + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx new file mode 100644 index 0000000000..d753778a9a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx @@ -0,0 +1,74 @@ +import { + PromptInputButton, + usePromptInputAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { PaperclipIcon } from "lucide-react"; +import { GoIssueOpened } from "react-icons/go"; +import { LuGitPullRequest } from "react-icons/lu"; +import { SiLinear } from "react-icons/si"; +import { PILL_BUTTON_CLASS } from "../../types"; + +interface AttachmentButtonsProps { + anchorRef: React.RefObject; + onOpenIssueLink: () => void; + onOpenGitHubIssue: () => void; + onOpenPRLink: () => void; +} + +export function AttachmentButtons({ + anchorRef, + onOpenIssueLink, + onOpenGitHubIssue, + onOpenPRLink, +}: AttachmentButtonsProps) { + const attachments = usePromptInputAttachments(); + return ( +
+ + + attachments.openFileDialog()} + > + + + + Add attachment + + + + + + + + Link issue + + + + + + + + Link GitHub issue + + + + + + + + Link pull request + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/index.ts new file mode 100644 index 0000000000..1bcf39bf3f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/index.ts @@ -0,0 +1 @@ +export { AttachmentButtons } from "./AttachmentButtons"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx new file mode 100644 index 0000000000..1d470bb691 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx @@ -0,0 +1,134 @@ +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useMemo, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; + +interface CompareBaseBranchPickerProps { + effectiveCompareBaseBranch: string | null; + defaultBranch: string | null | undefined; + isBranchesLoading: boolean; + isBranchesError: boolean; + branches: Array<{ + name: string; + lastCommitDate: number; + isLocal: boolean; + hasWorkspace: boolean; + }>; + onSelectCompareBaseBranch: (branchName: string) => void; +} + +export function CompareBaseBranchPicker({ + effectiveCompareBaseBranch, + defaultBranch, + isBranchesLoading, + isBranchesError, + branches, + onSelectCompareBaseBranch, +}: CompareBaseBranchPickerProps) { + const [open, setOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); + + const filteredBranches = useMemo(() => { + if (!branchSearch) return branches; + const searchLower = branchSearch.toLowerCase(); + return branches.filter((b) => b.name.toLowerCase().includes(searchLower)); + }, [branches, branchSearch]); + + if (isBranchesError) { + return ( + Failed to load branches + ); + } + + return ( + { + setOpen(v); + if (!v) setBranchSearch(""); + }} + > + + + + event.stopPropagation()} + > + + + + No branches found + {filteredBranches.map((branch) => ( + { + onSelectCompareBaseBranch(branch.name); + setOpen(false); + }} + className="group h-11 flex items-center justify-between gap-3 px-3" + > + + + + {branch.name} + + + {branch.name === defaultBranch && ( + + default + + )} + {branch.hasWorkspace && ( + + workspace + + )} + + + + {branch.lastCommitDate > 0 && ( + + {formatRelativeTime(branch.lastCommitDate * 1000)} + + )} + {effectiveCompareBaseBranch === branch.name && ( + + )} + + + ))} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/index.ts new file mode 100644 index 0000000000..b2481b345b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/index.ts @@ -0,0 +1 @@ +export { CompareBaseBranchPicker } from "./CompareBaseBranchPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx new file mode 100644 index 0000000000..0820c0b943 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -0,0 +1,154 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; +import { env } from "renderer/env.renderer"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +const MAX_RESULTS = 20; + +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export interface SelectedIssue { + issueNumber: number; + title: string; + url: string; + state: string; +} + +interface GitHubIssueLinkCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (issue: SelectedIssue) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; + anchorRef: RefObject; +} + +export function GitHubIssueLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + hostTarget, + anchorRef, +}: GitHubIssueLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const { activeHostUrl } = useLocalHostService(); + + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data, isLoading } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchGitHubIssues", + projectId, + hostUrl, + debouncedQuery, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { issues: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: debouncedQuery.trim() || undefined, + limit: MAX_RESULTS, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const searchResults = data?.issues ?? []; + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (issue: (typeof searchResults)[number]) => { + onSelect({ + issueNumber: issue.issueNumber, + title: issue.title, + url: issue.url, + state: issue.state, + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {searchResults.length === 0 && ( + + {isLoading ? "Loading issues..." : "No open issues found."} + + )} + {searchResults.length > 0 && ( + + {searchResults.map((issue) => ( + handleSelect(issue)} + className="group" + > + + + #{issue.issueNumber} + + + {issue.title} + + + Link ↵ + + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts new file mode 100644 index 0000000000..c7d5f8cdb5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts @@ -0,0 +1 @@ +export { GitHubIssueLinkCommand } from "./GitHubIssueLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx new file mode 100644 index 0000000000..75ecb4b52d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx @@ -0,0 +1,59 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; + +interface LinkedGitHubIssuePillProps { + issueNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +// Normalize issue state to valid IssueState type +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export function LinkedGitHubIssuePill({ + issueNumber, + title, + state, + onRemove, +}: LinkedGitHubIssuePillProps) { + return ( +
+
+ + +
+
+ {title} +
+ #{issueNumber} + · + GitHub +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts new file mode 100644 index 0000000000..fe1657259a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts @@ -0,0 +1 @@ +export { LinkedGitHubIssuePill } from "./LinkedGitHubIssuePill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx new file mode 100644 index 0000000000..9e2c4b3572 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx @@ -0,0 +1,55 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; + +interface LinkedPRPillProps { + prNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +export function LinkedPRPill({ + prNumber, + title, + state, + onRemove, +}: LinkedPRPillProps) { + return ( +
+
+ + +
+
+ {title} +
+ #{prNumber} + · + GitHub +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts new file mode 100644 index 0000000000..1042cfae4d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts @@ -0,0 +1 @@ +export { LinkedPRPill } from "./LinkedPRPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx new file mode 100644 index 0000000000..a5b79ae418 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -0,0 +1,168 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; +import { env } from "renderer/env.renderer"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +export interface SelectedPR { + prNumber: number; + title: string; + url: string; + state: string; +} + +interface PRLinkCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (pr: SelectedPR) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; + anchorRef: RefObject; +} + +function normalizeState(state: string, isDraft: boolean): string { + if (isDraft) return "draft"; + if (state === "OPEN" || state === "open") return "open"; + return state.toLowerCase(); +} + +export function PRLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + hostTarget, + anchorRef, +}: PRLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const { activeHostUrl } = useLocalHostService(); + + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data, isLoading } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchPullRequests", + projectId, + hostUrl, + debouncedQuery, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { pullRequests: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchPullRequests.query({ + projectId, + query: debouncedQuery.trim() || undefined, + limit: 30, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const pullRequests = data?.pullRequests ?? []; + const debouncedTrimmed = debouncedQuery.trim(); + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (pr: (typeof pullRequests)[number]) => { + onSelect({ + prNumber: pr.prNumber, + title: pr.title, + url: pr.url, + state: normalizeState(pr.state, pr.isDraft), + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {pullRequests.length === 0 && ( + + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading pull requests..." + : debouncedTrimmed + ? "No pull requests found." + : "No open pull requests."} + + )} + {pullRequests.length > 0 && ( + + {pullRequests.map((pr) => ( + handleSelect(pr)} + className="group" + > + + + #{pr.prNumber} + + + {pr.title} + + + Link ↵ + + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts new file mode 100644 index 0000000000..ba614340e8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts @@ -0,0 +1 @@ +export { PRLinkCommand } from "./PRLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx new file mode 100644 index 0000000000..37490d407f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx @@ -0,0 +1,79 @@ +import { PromptInputButton } from "@superset/ui/ai-elements/prompt-input"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useState } from "react"; +import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { PILL_BUTTON_CLASS, type ProjectOption } from "../../types"; + +interface ProjectPickerPillProps { + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; +} + +export function ProjectPickerPill({ + selectedProject, + recentProjects, + onSelectProject, +}: ProjectPickerPillProps) { + 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 && ( + + )} + + ))} + + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/index.ts new file mode 100644 index 0000000000..5a501c182e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/index.ts @@ -0,0 +1 @@ +export { ProjectPickerPill } from "./ProjectPickerPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts new file mode 100644 index 0000000000..20473c95be --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts @@ -0,0 +1 @@ +export { useSubmitWorkspace } from "./useSubmitWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/mapLinkedContext.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/mapLinkedContext.ts new file mode 100644 index 0000000000..4d8d25875c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/mapLinkedContext.ts @@ -0,0 +1,30 @@ +import type { DashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; + +interface MappedLinkedContext { + internalIssueIds: string[] | undefined; + githubIssueUrls: string[] | undefined; + linkedPrUrl: string | undefined; +} + +/** + * Maps draft linked issues/PR into the API payload shape. + * Pure function — no side effects, no hooks. + */ +export function mapLinkedContext( + draft: DashboardNewWorkspaceDraft, +): MappedLinkedContext { + const internalIssueIds = draft.linkedIssues + .filter((i) => i.source === "internal" && i.taskId) + .map((i) => i.taskId as string); + + const githubIssueUrls = draft.linkedIssues + .filter((i) => i.source === "github" && i.url) + .map((i) => i.url as string); + + return { + internalIssueIds: + internalIssueIds.length > 0 ? internalIssueIds : undefined, + githubIssueUrls: githubIssueUrls.length > 0 ? githubIssueUrls : undefined, + linkedPrUrl: draft.linkedPR?.url, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts new file mode 100644 index 0000000000..338058c0b0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts @@ -0,0 +1,35 @@ +import { sanitizeUserBranchName, slugifyForBranch } from "shared/utils/branch"; +import { generateFriendlyBranchName } from "shared/utils/friendly-branch-name"; +import type { DashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; + +interface ResolvedNames { + branchName: string; + workspaceName: string; +} + +/** + * Resolves the branch name and workspace display name from draft state. + * Pure function — no side effects, no hooks. + * + * Priority: + * - Branch: user-typed (sanitized) > prompt slug > friendly random + * - Workspace: user-typed > prompt text > same as branch + */ +export function resolveNames(draft: DashboardNewWorkspaceDraft): ResolvedNames { + const trimmedPrompt = draft.prompt.trim(); + const friendlyFallback = generateFriendlyBranchName(); + + const branchName = + draft.branchNameEdited && draft.branchName.trim() + ? sanitizeUserBranchName(draft.branchName.trim()) + : trimmedPrompt + ? slugifyForBranch(trimmedPrompt) + : friendlyFallback; + + const workspaceName = + draft.workspaceNameEdited && draft.workspaceName.trim() + ? draft.workspaceName.trim() + : trimmedPrompt || friendlyFallback; + + return { branchName, workspaceName }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts new file mode 100644 index 0000000000..4f7022ee8d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts @@ -0,0 +1,134 @@ +import { useProviderAttachments } from "@superset/ui/ai-elements/prompt-input"; +import { toast } from "@superset/ui/sonner"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { + clearAttachments, + storeAttachments, +} from "renderer/lib/pending-attachment-store"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useDashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; +import { mapLinkedContext } from "./mapLinkedContext"; +import { resolveNames } from "./resolveNames"; + +/** + * Returns a callback that submits a new workspace: + * resolve names → store attachments → insert pending row → close modal → + * navigate to pending page → fire-and-forget host-service call → + * update collection on resolve/reject. + */ +export function useSubmitWorkspace(projectId: string | null) { + const navigate = useNavigate(); + const { closeAndResetDraft, createWorkspace, draft } = + useDashboardNewWorkspaceDraft(); + const attachments = useProviderAttachments(); + const collections = useCollections(); + + return useCallback(async () => { + if (!projectId) { + toast.error("Select a project first"); + return; + } + + // 1. Resolve names + const { branchName, workspaceName } = resolveNames(draft); + + // 2. Store attachments in IndexedDB before closing modal + const pendingId = crypto.randomUUID(); + const detachedFiles = attachments.takeFiles(); + if (detachedFiles.length > 0) { + try { + await storeAttachments(pendingId, detachedFiles); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to store attachments", + ); + return; + } finally { + for (const file of detachedFiles) { + if (file.url?.startsWith("blob:")) URL.revokeObjectURL(file.url); + } + } + } + + // 3. Insert pending workspace (full draft for retry) + collections.pendingWorkspaces.insert({ + id: pendingId, + projectId, + name: workspaceName, + branchName, + prompt: draft.prompt, + baseBranch: draft.baseBranch ?? null, + runSetupScript: draft.runSetupScript, + linkedIssues: draft.linkedIssues as unknown[], + linkedPR: draft.linkedPR, + hostTarget: draft.hostTarget, + attachmentCount: detachedFiles.length, + status: "creating", + error: null, + workspaceId: null, + initialCommands: null, + createdAt: new Date(), + }); + + // 4. Close modal, navigate to pending page + closeAndResetDraft(); + void navigate({ to: `/pending/${pendingId}` as string }); + + // 5. Fire create (fire-and-forget — closure survives modal unmount) + const linked = mapLinkedContext(draft); + + let attachmentPayload: + | Array<{ data: string; mediaType: string; filename: string }> + | undefined; + if (detachedFiles.length > 0) { + try { + const { loadAttachments } = await import( + "renderer/lib/pending-attachment-store" + ); + attachmentPayload = await loadAttachments(pendingId); + } catch { + // Non-fatal — create proceeds without attachments + } + } + + try { + const result = await createWorkspace({ + pendingId, + projectId, + hostTarget: draft.hostTarget, + names: { workspaceName, branchName }, + composer: { + prompt: draft.prompt.trim() || undefined, + baseBranch: draft.baseBranch || undefined, + runSetupScript: draft.runSetupScript, + }, + linkedContext: { + ...linked, + attachments: attachmentPayload, + }, + }); + + collections.pendingWorkspaces.update(pendingId, (row) => { + row.status = "succeeded"; + row.workspaceId = result.workspace?.id ?? null; + row.initialCommands = result.initialCommands ?? null; + }); + void clearAttachments(pendingId); + } catch (err) { + collections.pendingWorkspaces.update(pendingId, (row) => { + row.status = "failed"; + row.error = + err instanceof Error ? err.message : "Failed to create workspace"; + }); + } + }, [ + attachments, + closeAndResetDraft, + collections, + createWorkspace, + draft, + navigate, + projectId, + ]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts new file mode 100644 index 0000000000..7122c70c02 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts @@ -0,0 +1,15 @@ +import type { AgentDefinitionId } from "shared/utils/agent-settings"; + +export type WorkspaceCreateAgent = AgentDefinitionId | "none"; + +export const AGENT_STORAGE_KEY = "lastSelectedWorkspaceCreateAgent"; + +export const PILL_BUTTON_CLASS = + "!h-[22px] min-h-0 rounded-md border-[0.5px] border-border bg-foreground/[0.04] shadow-none text-[11px]"; + +export interface ProjectOption { + id: string; + name: string; + githubOwner: string | null; + githubRepoName: string | null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx deleted file mode 100644 index 455999262f..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import Fuse from "fuse.js"; -import { useCallback, useMemo } from "react"; -import { GoArrowUpRight, GoGitBranch, GoGlobe } from "react-icons/go"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface BranchesGroupProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function BranchesGroup({ - projectId, - localProjectId, - hostTarget, -}: BranchesGroupProps) { - const navigate = useNavigate(); - const collections = useCollections(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const hasLocalProject = !!localProjectId; - - const { data: localData, isLoading: isLocalLoading } = - electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const { data: remoteData } = electronTrpc.projects.getBranches.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const data = remoteData ?? localData; - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const defaultBranch = data?.defaultBranch ?? "main"; - - const branches = (data?.branches ?? []).sort((a, b) => { - if (a.name === defaultBranch) return -1; - if (b.name === defaultBranch) return 1; - if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - const branchRows = useMemo(() => { - return branches.map((branch) => ({ - branch, - existingWorkspaceId: workspaceByBranch.get(branch.name), - })); - }, [branches, workspaceByBranch]); - - const debouncedQuery = useDebouncedValue(draft.branchesQuery, 150); - - const branchFuse = useMemo( - () => - new Fuse(branchRows, { - keys: ["branch.name"], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - }), - [branchRows], - ); - - const visibleBranchRows = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return branchRows.slice(0, 100); - } - return branchFuse - .search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, branchRows, branchFuse]); - - const handleCreate = useCallback( - (branchName: string) => { - if (!projectId) return; - void runAsyncAction( - createWorkspace({ - projectId, - name: branchName, - branch: branchName, - hostTarget, - }), - { - loading: "Creating workspace from branch...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }, - [createWorkspace, hostTarget, projectId, runAsyncAction], - ); - - const handleOpen = useCallback( - (workspaceId: string) => { - closeAndResetDraft(); - navigateToV2Workspace(workspaceId, navigate); - }, - [closeAndResetDraft, navigate], - ); - - const handleBranchAction = useCallback( - (branchName: string) => { - const existingId = workspaceByBranch.get(branchName); - if (existingId) { - handleOpen(existingId); - return; - } - handleCreate(branchName); - }, - [handleCreate, handleOpen, workspaceByBranch], - ); - - if (!projectId) { - return ( - - Select a project to view branches. - - ); - } - - if (!hasLocalProject) { - return ( - - No local repository linked to this project. - - ); - } - - if (isLocalLoading) { - return ( - - Loading branches... - - ); - } - - return ( - - No branches found. - {visibleBranchRows.map(({ branch, existingWorkspaceId }) => { - const buttonLabel = existingWorkspaceId ? "Open" : "Create"; - return ( - handleBranchAction(branch.name)} - className="group h-12" - > - {existingWorkspaceId ? ( - - ) : branch.isLocal ? ( - - ) : ( - - )} - {branch.name} - - - ); - })} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts deleted file mode 100644 index 75953e3d24..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BranchesGroup } from "./BranchesGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx deleted file mode 100644 index cd712d1bbd..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { DevicePicker } from "../DevicePicker"; -import { ProjectSelector } from "../ProjectSelector"; - -interface DashboardNewWorkspaceFormHeaderProps { - activeTab: DashboardNewWorkspaceTab; - hostTarget: WorkspaceHostTarget; - selectedProjectId: string | null; - onSelectTab: (tab: DashboardNewWorkspaceTab) => void; - onSelectHostTarget: (hostTarget: WorkspaceHostTarget) => void; - onSelectProject: (projectId: string | null) => void; -} - -export function DashboardNewWorkspaceFormHeader({ - activeTab, - hostTarget, - selectedProjectId, - onSelectTab, - onSelectHostTarget, - onSelectProject, -}: DashboardNewWorkspaceFormHeaderProps) { - return ( -
- - onSelectTab(value as DashboardNewWorkspaceTab) - } - > - - Prompt - Issues - Pull requests - Branches - - -
- -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts deleted file mode 100644 index f446941052..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceFormHeader } from "./DashboardNewWorkspaceFormHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx deleted file mode 100644 index aa01a2dd25..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Command, CommandInput, CommandList } from "@superset/ui/command"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { BranchesGroup } from "../BranchesGroup"; -import { IssuesGroup } from "../IssuesGroup"; -import { PullRequestsGroup } from "../PullRequestsGroup"; - -const COMMAND_CLASS_NAME = - "[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 flex h-full w-full flex-1 flex-col overflow-hidden rounded-none"; - -interface DashboardNewWorkspaceListTabContentProps { - activeTab: Exclude; - projectId: string | null; - githubRepositoryId: string | null; - hostTarget: WorkspaceHostTarget; - localProjectId: string | null; - query: string; - onQueryChange: (value: string) => void; -} - -export function DashboardNewWorkspaceListTabContent({ - activeTab, - projectId, - githubRepositoryId, - hostTarget, - localProjectId, - query, - onQueryChange, -}: DashboardNewWorkspaceListTabContentProps) { - return ( - - - - - {activeTab === "pull-requests" && ( - - )} - {activeTab === "branches" && ( - - )} - {activeTab === "issues" && ( - - )} - - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts deleted file mode 100644 index af9feb38a8..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceListTabContent } from "./DashboardNewWorkspaceListTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx deleted file mode 100644 index 801c7311f0..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { PromptGroup } from "../PromptGroup"; - -interface DashboardNewWorkspacePromptTabContentProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function DashboardNewWorkspacePromptTabContent({ - projectId, - localProjectId, - hostTarget, -}: DashboardNewWorkspacePromptTabContentProps) { - return ( -
- -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts deleted file mode 100644 index 0dd4c4cbf1..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspacePromptTabContent } from "./DashboardNewWorkspacePromptTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx deleted file mode 100644 index d31ef1e646..0000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Avatar } from "@superset/ui/atoms/Avatar"; -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { toast } from "@superset/ui/sonner"; -import { eq, isNull } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { GoArrowUpRight } from "react-icons/go"; -import { HiOutlineUserCircle } from "react-icons/hi2"; -import { SiLinear } from "react-icons/si"; -import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { getSlugColumnWidth } from "renderer/lib/slug-width"; -import { - StatusIcon, - type StatusType, -} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; -import { useHybridSearch } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch"; -import { compareTasks } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/utils/sorting"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface IssuesGroupProps { - projectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function IssuesGroup({ projectId, hostTarget }: IssuesGroupProps) { - const collections = useCollections(); - const navigate = useNavigate(); - const { gateFeature } = usePaywall(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const { data: integrations } = useLiveQuery( - (q) => - q - .from({ - integrationConnections: collections.integrationConnections, - }) - .select(({ integrationConnections }) => ({ - ...integrationConnections, - })), - [collections], - ); - - const isLinearConnected = - integrations?.some((i) => i.provider === "linear") ?? false; - - const { data } = useLiveQuery( - (q) => - q - .from({ tasks: collections.tasks }) - .innerJoin({ status: collections.taskStatuses }, ({ tasks, status }) => - eq(tasks.statusId, status.id), - ) - .leftJoin({ assignee: collections.users }, ({ tasks, assignee }) => - eq(tasks.assigneeId, assignee.id), - ) - .select(({ tasks, status, assignee }) => ({ - ...tasks, - status, - assignee: assignee ?? null, - })) - .where(({ tasks }) => isNull(tasks.deletedAt)), - [collections], - ); - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const tasks = useMemo(() => data ?? [], [data]); - const sortedTasks = useMemo(() => [...tasks].sort(compareTasks), [tasks]); - - const debouncedQuery = useDebouncedValue(draft.issuesQuery, 150); - const { search } = useHybridSearch(sortedTasks); - - const visibleTasks = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return sortedTasks.slice(0, 100); - } - return search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, sortedTasks, search]); - - const slugWidth = useMemo( - () => getSlugColumnWidth(visibleTasks.map((t) => t.slug)), - [visibleTasks], - ); - - if (!isLinearConnected) { - return ( -
- -
-

Connect Linear

-

- Sync issues from Linear to create workspaces -

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