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 00000000000..8fd4af66375 --- /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 00000000000..367e272ce0a --- /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 00000000000..e8921938d4a --- /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/lib/formatRelativeTime/formatRelativeTime.test.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts index 7424e33e990..3787653bac8 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 a55ed489e2b..27bed8ba713 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 00000000000..aeb450811f9 --- /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 1fc60f6f72f..a825932302c 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 c87f5536e6e..85248582359 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 a8a52331141..329d90bd701 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/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx new file mode 100644 index 00000000000..f4ab5c46b1b --- /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/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 1d07c99086f..bc6eba977ee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -64,6 +64,14 @@ function V2WorkspaceLayout() { ensureWorkspaceInSidebar(workspace.id, workspace.projectId); }, [ensureWorkspaceInSidebar, workspace]); + // TODO: This renders child routes without WorkspaceTrpcProvider when + // the workspace hasn't loaded from collections yet, or during route + // transitions (e.g. navigating away from a workspace). If the outgoing + // workspace page hasn't fully unmounted, its components (TerminalPane, + // etc.) will crash with "useWorkspaceClient must be used within + // WorkspaceClientProvider". Either the layout should never render + // children without the provider, or the provider should move to the + // page level so each page owns its own context. if (!workspaceId || !workspace) { return ; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index 07d4bc41f1f..7e032257160 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 12bf45f75db..6e887f5c2f9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx @@ -1,3 +1,7 @@ +import { + PromptInputProvider, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; import { Dialog, DialogContent, @@ -5,38 +9,65 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; +import { useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; -import { DashboardNewWorkspaceForm } from "./components/DashboardNewWorkspaceForm"; -import { DashboardNewWorkspaceDraftProvider } from "./DashboardNewWorkspaceDraftContext"; +import { DashboardNewWorkspaceModalContent } from "./components/DashboardNewWorkspaceModalContent"; +import { + DashboardNewWorkspaceDraftProvider, + useDashboardNewWorkspaceDraft, +} from "./DashboardNewWorkspaceDraftContext"; + +/** Clears the PromptInputProvider text & attachments when the draft resets. */ +function PromptInputResetSync() { + const { resetKey } = useDashboardNewWorkspaceDraft(); + const { textInput, attachments } = usePromptInputController(); + const prevResetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKey !== prevResetKeyRef.current) { + prevResetKeyRef.current = resetKey; + textInput.clear(); + attachments.clear(); + } + }, [resetKey, textInput.clear, attachments.clear]); + + return null; +} export function DashboardNewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); const preSelectedProjectId = usePreSelectedProjectId(); + // Prevents AgentSelect from flashing "No agent" while presets load after refresh. + electronTrpc.settings.getAgentPresets.useQuery(); + return ( - !open && closeModal()}> - - New Workspace - - Create a new workspace from a PR, branch, issue, or prompt. - - - - - - + + + !open && closeModal()}> + + New Workspace + Create a new workspace + + e.preventDefault()} + className="bg-popover text-popover-foreground sm:max-w-[560px] max-h-[min(70vh,600px)] !top-[calc(50%-min(35vh,300px))] !-translate-y-0 flex flex-col overflow-hidden p-0" + > + + + + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx deleted file mode 100644 index fe5b9b3ab0c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback } from "react"; -import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; -import { DashboardNewWorkspaceFormHeader } from "./components/DashboardNewWorkspaceFormHeader"; -import { DashboardNewWorkspaceListTabContent } from "./components/DashboardNewWorkspaceListTabContent"; -import { DashboardNewWorkspacePromptTabContent } from "./components/DashboardNewWorkspacePromptTabContent"; -import { useDashboardNewWorkspaceProjectSelection } from "./hooks/useDashboardNewWorkspaceProjectSelection"; -import { useResolvedLocalProject } from "./hooks/useResolvedLocalProject"; - -interface DashboardNewWorkspaceFormProps { - isOpen: boolean; - preSelectedProjectId: string | null; -} - -/** Main form for the new workspace modal with collection-based project selection. */ -export function DashboardNewWorkspaceForm({ - isOpen, - preSelectedProjectId, -}: DashboardNewWorkspaceFormProps) { - const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); - const handleSelectProject = useCallback( - (selectedProjectId: string | null) => { - updateDraft({ selectedProjectId }); - }, - [updateDraft], - ); - const { githubRepository, githubRepositoryId } = - useDashboardNewWorkspaceProjectSelection({ - isOpen, - preSelectedProjectId, - selectedProjectId: draft.selectedProjectId, - onSelectProject: handleSelectProject, - }); - const resolvedLocalProjectId = useResolvedLocalProject(githubRepository); - - const listTab = draft.activeTab === "prompt" ? null : draft.activeTab; - const isListTab = listTab !== null; - const listQuery = - draft.activeTab === "issues" - ? draft.issuesQuery - : draft.activeTab === "branches" - ? draft.branchesQuery - : draft.pullRequestsQuery; - - const handleListQueryChange = (value: string) => { - switch (draft.activeTab) { - case "issues": - updateDraft({ issuesQuery: value }); - return; - case "branches": - updateDraft({ branchesQuery: value }); - return; - case "pull-requests": - updateDraft({ pullRequestsQuery: value }); - return; - default: - return; - } - }; - - return ( - <> - updateDraft({ activeTab })} - onSelectHostTarget={(hostTarget) => updateDraft({ hostTarget })} - onSelectProject={handleSelectProject} - /> - - {isListTab ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx new file mode 100644 index 00000000000..317eec2d656 --- /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 00000000000..d753778a9a7 --- /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 00000000000..1bcf39bf3f0 --- /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 00000000000..1d470bb6919 --- /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 00000000000..b2481b345b6 --- /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 00000000000..0820c0b943e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -0,0 +1,154 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; +import { env } from "renderer/env.renderer"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +const MAX_RESULTS = 20; + +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export interface SelectedIssue { + issueNumber: number; + title: string; + url: string; + state: string; +} + +interface GitHubIssueLinkCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (issue: SelectedIssue) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; + anchorRef: RefObject; +} + +export function GitHubIssueLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + hostTarget, + anchorRef, +}: GitHubIssueLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const { activeHostUrl } = useLocalHostService(); + + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data, isLoading } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchGitHubIssues", + projectId, + hostUrl, + debouncedQuery, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { issues: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: debouncedQuery.trim() || undefined, + limit: MAX_RESULTS, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const searchResults = data?.issues ?? []; + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (issue: (typeof searchResults)[number]) => { + onSelect({ + issueNumber: issue.issueNumber, + title: issue.title, + url: issue.url, + state: issue.state, + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {searchResults.length === 0 && ( + + {isLoading ? "Loading issues..." : "No open issues found."} + + )} + {searchResults.length > 0 && ( + + {searchResults.map((issue) => ( + handleSelect(issue)} + className="group" + > + + + #{issue.issueNumber} + + + {issue.title} + + + Link ↵ + + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts new file mode 100644 index 00000000000..c7d5f8cdb50 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts @@ -0,0 +1 @@ +export { GitHubIssueLinkCommand } from "./GitHubIssueLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx new file mode 100644 index 00000000000..75ecb4b52d2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx @@ -0,0 +1,59 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; + +interface LinkedGitHubIssuePillProps { + issueNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +// Normalize issue state to valid IssueState type +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export function LinkedGitHubIssuePill({ + issueNumber, + title, + state, + onRemove, +}: LinkedGitHubIssuePillProps) { + return ( +
+
+ + +
+
+ {title} +
+ #{issueNumber} + · + GitHub +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts new file mode 100644 index 00000000000..fe1657259a6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts @@ -0,0 +1 @@ +export { LinkedGitHubIssuePill } from "./LinkedGitHubIssuePill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx new file mode 100644 index 00000000000..9e2c4b35720 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx @@ -0,0 +1,55 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; + +interface LinkedPRPillProps { + prNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +export function LinkedPRPill({ + prNumber, + title, + state, + onRemove, +}: LinkedPRPillProps) { + return ( +
+
+ + +
+
+ {title} +
+ #{prNumber} + · + GitHub +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts new file mode 100644 index 00000000000..1042cfae4d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts @@ -0,0 +1 @@ +export { LinkedPRPill } from "./LinkedPRPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx new file mode 100644 index 00000000000..a5b79ae418e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -0,0 +1,168 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { useQuery } from "@tanstack/react-query"; +import type React from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; +import { env } from "renderer/env.renderer"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +export interface SelectedPR { + prNumber: number; + title: string; + url: string; + state: string; +} + +interface PRLinkCommandProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (pr: SelectedPR) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; + anchorRef: RefObject; +} + +function normalizeState(state: string, isDraft: boolean): string { + if (isDraft) return "draft"; + if (state === "OPEN" || state === "open") return "open"; + return state.toLowerCase(); +} + +export function PRLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + hostTarget, + anchorRef, +}: PRLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const { activeHostUrl } = useLocalHostService(); + + const hostUrl = + hostTarget.kind === "local" + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${hostTarget.hostId}`; + + const { data, isLoading } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchPullRequests", + projectId, + hostUrl, + debouncedQuery, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { pullRequests: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchPullRequests.query({ + projectId, + query: debouncedQuery.trim() || undefined, + limit: 30, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const pullRequests = data?.pullRequests ?? []; + const debouncedTrimmed = debouncedQuery.trim(); + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (pr: (typeof pullRequests)[number]) => { + onSelect({ + prNumber: pr.prNumber, + title: pr.title, + url: pr.url, + state: normalizeState(pr.state, pr.isDraft), + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {pullRequests.length === 0 && ( + + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading pull requests..." + : debouncedTrimmed + ? "No pull requests found." + : "No open pull requests."} + + )} + {pullRequests.length > 0 && ( + + {pullRequests.map((pr) => ( + handleSelect(pr)} + className="group" + > + + + #{pr.prNumber} + + + {pr.title} + + + Link ↵ + + + ))} + + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts new file mode 100644 index 00000000000..ba614340e89 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts @@ -0,0 +1 @@ +export { PRLinkCommand } from "./PRLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/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 00000000000..37490d407f1 --- /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 00000000000..5a501c182e3 --- /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 00000000000..20473c95be6 --- /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 00000000000..4d8d25875c9 --- /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 00000000000..338058c0b0f --- /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 00000000000..4f7022ee8d6 --- /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 00000000000..7122c70c026 --- /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 455999262f6..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import Fuse from "fuse.js"; -import { useCallback, useMemo } from "react"; -import { GoArrowUpRight, GoGitBranch, GoGlobe } from "react-icons/go"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface BranchesGroupProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function BranchesGroup({ - projectId, - localProjectId, - hostTarget, -}: BranchesGroupProps) { - const navigate = useNavigate(); - const collections = useCollections(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const hasLocalProject = !!localProjectId; - - const { data: localData, isLoading: isLocalLoading } = - electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const { data: remoteData } = electronTrpc.projects.getBranches.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const data = remoteData ?? localData; - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const defaultBranch = data?.defaultBranch ?? "main"; - - const branches = (data?.branches ?? []).sort((a, b) => { - if (a.name === defaultBranch) return -1; - if (b.name === defaultBranch) return 1; - if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - const branchRows = useMemo(() => { - return branches.map((branch) => ({ - branch, - existingWorkspaceId: workspaceByBranch.get(branch.name), - })); - }, [branches, workspaceByBranch]); - - const debouncedQuery = useDebouncedValue(draft.branchesQuery, 150); - - const branchFuse = useMemo( - () => - new Fuse(branchRows, { - keys: ["branch.name"], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - }), - [branchRows], - ); - - const visibleBranchRows = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return branchRows.slice(0, 100); - } - return branchFuse - .search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, branchRows, branchFuse]); - - const handleCreate = useCallback( - (branchName: string) => { - if (!projectId) return; - void runAsyncAction( - createWorkspace({ - projectId, - name: branchName, - branch: branchName, - hostTarget, - }), - { - loading: "Creating workspace from branch...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }, - [createWorkspace, hostTarget, projectId, runAsyncAction], - ); - - const handleOpen = useCallback( - (workspaceId: string) => { - closeAndResetDraft(); - navigateToV2Workspace(workspaceId, navigate); - }, - [closeAndResetDraft, navigate], - ); - - const handleBranchAction = useCallback( - (branchName: string) => { - const existingId = workspaceByBranch.get(branchName); - if (existingId) { - handleOpen(existingId); - return; - } - handleCreate(branchName); - }, - [handleCreate, handleOpen, workspaceByBranch], - ); - - if (!projectId) { - return ( - - Select a project to view branches. - - ); - } - - if (!hasLocalProject) { - return ( - - No local repository linked to this project. - - ); - } - - if (isLocalLoading) { - return ( - - Loading branches... - - ); - } - - return ( - - No branches found. - {visibleBranchRows.map(({ branch, existingWorkspaceId }) => { - const buttonLabel = existingWorkspaceId ? "Open" : "Create"; - return ( - handleBranchAction(branch.name)} - className="group h-12" - > - {existingWorkspaceId ? ( - - ) : branch.isLocal ? ( - - ) : ( - - )} - {branch.name} - - - ); - })} - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts deleted file mode 100644 index 75953e3d249..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BranchesGroup } from "./BranchesGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx deleted file mode 100644 index cd712d1bbd6..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { DevicePicker } from "../DevicePicker"; -import { ProjectSelector } from "../ProjectSelector"; - -interface DashboardNewWorkspaceFormHeaderProps { - activeTab: DashboardNewWorkspaceTab; - hostTarget: WorkspaceHostTarget; - selectedProjectId: string | null; - onSelectTab: (tab: DashboardNewWorkspaceTab) => void; - onSelectHostTarget: (hostTarget: WorkspaceHostTarget) => void; - onSelectProject: (projectId: string | null) => void; -} - -export function DashboardNewWorkspaceFormHeader({ - activeTab, - hostTarget, - selectedProjectId, - onSelectTab, - onSelectHostTarget, - onSelectProject, -}: DashboardNewWorkspaceFormHeaderProps) { - return ( -
- - onSelectTab(value as DashboardNewWorkspaceTab) - } - > - - Prompt - Issues - Pull requests - Branches - - -
- -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts deleted file mode 100644 index f4469410524..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceFormHeader } from "./DashboardNewWorkspaceFormHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx deleted file mode 100644 index aa01a2dd257..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Command, CommandInput, CommandList } from "@superset/ui/command"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { BranchesGroup } from "../BranchesGroup"; -import { IssuesGroup } from "../IssuesGroup"; -import { PullRequestsGroup } from "../PullRequestsGroup"; - -const COMMAND_CLASS_NAME = - "[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 flex h-full w-full flex-1 flex-col overflow-hidden rounded-none"; - -interface DashboardNewWorkspaceListTabContentProps { - activeTab: Exclude; - projectId: string | null; - githubRepositoryId: string | null; - hostTarget: WorkspaceHostTarget; - localProjectId: string | null; - query: string; - onQueryChange: (value: string) => void; -} - -export function DashboardNewWorkspaceListTabContent({ - activeTab, - projectId, - githubRepositoryId, - hostTarget, - localProjectId, - query, - onQueryChange, -}: DashboardNewWorkspaceListTabContentProps) { - return ( - - - - - {activeTab === "pull-requests" && ( - - )} - {activeTab === "branches" && ( - - )} - {activeTab === "issues" && ( - - )} - - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts deleted file mode 100644 index af9feb38a8c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceListTabContent } from "./DashboardNewWorkspaceListTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx deleted file mode 100644 index 801c7311f06..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { PromptGroup } from "../PromptGroup"; - -interface DashboardNewWorkspacePromptTabContentProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function DashboardNewWorkspacePromptTabContent({ - projectId, - localProjectId, - hostTarget, -}: DashboardNewWorkspacePromptTabContentProps) { - return ( -
- -
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts deleted file mode 100644 index 0dd4c4cbf1a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspacePromptTabContent } from "./DashboardNewWorkspacePromptTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx deleted file mode 100644 index d31ef1e646c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Avatar } from "@superset/ui/atoms/Avatar"; -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { toast } from "@superset/ui/sonner"; -import { eq, isNull } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { GoArrowUpRight } from "react-icons/go"; -import { HiOutlineUserCircle } from "react-icons/hi2"; -import { SiLinear } from "react-icons/si"; -import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { getSlugColumnWidth } from "renderer/lib/slug-width"; -import { - StatusIcon, - type StatusType, -} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; -import { useHybridSearch } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch"; -import { compareTasks } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/utils/sorting"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface IssuesGroupProps { - projectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function IssuesGroup({ projectId, hostTarget }: IssuesGroupProps) { - const collections = useCollections(); - const navigate = useNavigate(); - const { gateFeature } = usePaywall(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const { data: integrations } = useLiveQuery( - (q) => - q - .from({ - integrationConnections: collections.integrationConnections, - }) - .select(({ integrationConnections }) => ({ - ...integrationConnections, - })), - [collections], - ); - - const isLinearConnected = - integrations?.some((i) => i.provider === "linear") ?? false; - - const { data } = useLiveQuery( - (q) => - q - .from({ tasks: collections.tasks }) - .innerJoin({ status: collections.taskStatuses }, ({ tasks, status }) => - eq(tasks.statusId, status.id), - ) - .leftJoin({ assignee: collections.users }, ({ tasks, assignee }) => - eq(tasks.assigneeId, assignee.id), - ) - .select(({ tasks, status, assignee }) => ({ - ...tasks, - status, - assignee: assignee ?? null, - })) - .where(({ tasks }) => isNull(tasks.deletedAt)), - [collections], - ); - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const tasks = useMemo(() => data ?? [], [data]); - const sortedTasks = useMemo(() => [...tasks].sort(compareTasks), [tasks]); - - const debouncedQuery = useDebouncedValue(draft.issuesQuery, 150); - const { search } = useHybridSearch(sortedTasks); - - const visibleTasks = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return sortedTasks.slice(0, 100); - } - return search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, sortedTasks, search]); - - const slugWidth = useMemo( - () => getSlugColumnWidth(visibleTasks.map((t) => t.slug)), - [visibleTasks], - ); - - if (!isLinearConnected) { - return ( -
- -
-

Connect Linear

-

- Sync issues from Linear to create workspaces -

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