From 63295af0f4ad216074c260a02b4511608a2856d4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 5 Apr 2026 21:50:40 -0700 Subject: [PATCH 1/9] document v1-on-v2 workspace creation port --- ...0405-1945-v1-workspace-ux-into-v2-modal.md | 503 +++++++++++++++++ ...260405-2002-v2-workspace-host-api-shape.md | 524 ++++++++++++++++++ 2 files changed, 1027 insertions(+) create mode 100644 apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md create mode 100644 apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md diff --git a/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md b/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md new file mode 100644 index 00000000000..bc209ed66f8 --- /dev/null +++ b/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md @@ -0,0 +1,503 @@ +# Full Port Of V1 Workspace UX Onto V2 Paths And Architecture + +This ExecPlan is a living document. Update `Progress`, `Decision Log`, `Surprises & Discoveries`, and `Outcomes & Retrospective` as implementation proceeds. + + +## Goal + +Match the V1 workspace creation experience exactly, while using the V2-enabled routes, collections, host-service, and sidebar/workspace architecture underneath. + +Target outcome: + +1. the V2-enabled modal looks and behaves like the V1 modal +2. all create/open flows execute through V2 paths and architecture, not the old modal-specific stack +3. current V2-only modal UI is removed wherever it diverges from the V1 experience +4. V1 business logic is ported or extracted, not left stranded in the legacy modal +5. there is one canonical workspace-creation UX stack when `V2_CLOUD` is enabled + + +## Non-Goals + +1. Do not change the `V2_CLOUD` off-path behavior. +2. Do not remove the legacy modal until V2 reaches agreed parity. +3. Do not preserve the current V2 modal tabs, header, or device-picker UX if they conflict with exact V1 parity. +4. Do not hand-edit generated database artifacts. + + +## Current State + +### V1 strengths + +The old modal under `apps/desktop/src/renderer/components/NewWorkspaceModal/` already has the behaviors we want to preserve: + +1. one prompt-centric composer flow +2. editable workspace name and branch name +3. prompt attachments +4. linked internal issues +5. linked GitHub issues +6. linked PRs +7. agent selection and agent launch request creation +8. setup-script control +9. compare-base-branch selection +10. open existing active workspaces, tracked worktrees, and external worktrees +11. pending workspace lifecycle and optimistic init state +12. auto-navigation into the created workspace + +### V2 architecture strengths + +The V2-enabled stack already gives us the backing architecture we want to keep: + +1. V2 collections-backed project data +2. host-service based workspace creation +3. V2 dashboard-specific routing +4. V2 sidebar insertion after create +5. a path to device-targeted creation without depending on the old modal's local-only assumptions + +### Main gaps today + +1. prompt tab is much thinner than V1 +2. prompt tab has no attachments, agent picker, linked context pills, or workspace-name field +3. `compareBaseBranch` is stored in draft but not sent anywhere +4. setup-script and pending-init behavior are missing +5. the current V2 tabbed interaction model does not match V1 and should not be treated as the target +6. PR and issue tabs create directly instead of matching V1 inline linking behavior +7. branch tab cannot open tracked or external worktrees the way V1 can +8. the visible device-picker and header shell are V2-specific UX that diverge from V1 +9. V2 create only sends `projectId`, `name`, and `branch` +10. V2 create does not seed the old pending terminal setup / agent launch pipeline +11. V2 create does not auto-navigate into the new workspace + + +## Proposed UX Mapping + +Recommended target behavior: + +1. The V2-enabled modal should render and behave like the V1 modal, not like the current tabbed V2 dashboard modal. +2. Restore the V1 single-composer surface in full: + - workspace name + - branch name + - prompt composer + - attachments + - linked issue pills + - linked GitHub issue pills + - linked PR pill + - agent picker + - inline project picker + - inline compare-base-branch / worktree picker + - setup toggle + - V1 shortcut hints and action placement +3. PR, issue, and branch behaviors should return as V1 inline commands and pickers, not dedicated V2 tabs. +4. If host-target selection is still needed for architecture reasons, it should be defaulted or integrated behind the scenes for the first full-port pass rather than changing the V1 UX. +5. Use one shared V2 creation orchestration hook behind the exact V1 UI. + + +## Recommended Decisions + +### DL-1 Exact V1 UX parity beats hybridization + +Decision: the target is a full UX port. Do not preserve current V2 modal structure when it conflicts with the V1 experience. + +Reason: + +1. The user explicitly wants a full port, not a compromise. +2. Hybridizing two interaction models will keep the feature hard to maintain. +3. The current V2 shell is implementation scaffolding, not the product target. + + +### DL-2 Restore the V1 single-composer model + +Decision: the V2 modal should use the V1 single-composer interaction model, not the current prompt/issues/PRs/branches tab split. + +Reason: + +1. V1 does not present these flows as separate tabs. +2. A single composer is the clearest way to achieve exact parity. +3. It avoids carrying two different mental models through the port. + + +### DL-3 PR, issue, and branch affordances should return in V1 form + +Decision: restore PR link, issue link, GitHub issue link, and inline compare-base/worktree picker behavior from V1 instead of adapting the current V2 tab components. + +Reason: + +1. Exact parity requires the same placement and interaction model. +2. The current V2 tabs materially change the experience. + + +### DL-4 V2 paths remain the backing implementation + +Decision: use the V2 collections, routing, host-service, and sidebar/workspace model as the backing implementation, even though the surface UX should match V1. + +Reason: + +1. The request is specifically a full port onto V2 paths and architecture. +2. This avoids carrying old create logic indefinitely. +3. Device-targeted creation and V2 workspace records belong in the new stack. + + +### DL-5 Visible host/device controls are not part of the first-parity target + +Decision: if host-target selection is still required, satisfy it internally or via a non-divergent fallback during the full-port pass. Do not keep the current visible device picker if it changes the V1 experience. + +Reason: + +1. The user asked for an exact V1 experience. +2. Architecture requirements should not force obvious UX regressions during the port. + + +## Workstreams + +### Milestone 0: Lock UX contract before editing code + +Checklist: + +- [ ] Explicitly lock the target as exact V1 UX parity +- [ ] Explicitly drop the current V2 tabbed modal as the UX target +- [ ] Decide how host-target selection is handled without changing the V1 experience +- [ ] Confirm whether `Open project` and `New project` return exactly as in V1 +- [ ] Confirm that setup-script toggle is required in the first full-port pass +- [ ] Confirm that V2 create should auto-navigate exactly like V1 + +Acceptance: + +1. The team agrees that this is a full V1 UX port and not a hybrid. + + +### Milestone 1: Expand the V2 draft model to hold the full V1 state model + +Files: + +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx` +- `apps/desktop/src/renderer/stores/new-workspace-modal.ts` + +Checklist: + +- [ ] Add `workspaceName` and `workspaceNameEdited` +- [ ] Add `runSetupScript` +- [ ] Add linked issue state +- [ ] Add linked PR state +- [ ] Add attachment reset support equivalent to V1's `resetKey` +- [ ] Add selected agent state or a hook boundary for persisted agent selection +- [ ] Keep only the V2 fields that are architectural necessities: + - [ ] `selectedProjectId` + - [ ] `hostTarget` + - [ ] `compareBaseBranch` + - [ ] `branchName` +- [ ] Remove V2-only draft fields that exist only for the current tabbed UX: + - [ ] `activeTab` + - [ ] per-tab search queries, if no longer needed after the full port + +Acceptance: + +1. The V2 modal can represent all V1 creation inputs in one shared draft. + + +### Milestone 2: Extract one shared V2 creation orchestration hook + +Goal: + +Move V1's create pipeline out of the old modal-specific component and into a reusable V2-oriented hook. + +Suggested new files: + +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspaceFlow.ts` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useWorkspaceDraftLaunchRequest.ts` + +Source behavior to port from V1: + +1. pending workspace lifecycle +2. branch-name generation when branch is not manually edited +3. attachment conversion +4. GitHub issue content fetch and attachment enrichment +5. agent launch request construction +6. setup-script override handling +7. toast lifecycle +8. post-create navigation + +Checklist: + +- [ ] Extract the V1 prompt-create pipeline from the old `PromptGroup` +- [ ] Define one typed V2 flow input that includes: + - [ ] project ID + - [ ] host target + - [ ] workspace name + - [ ] branch name or generation strategy + - [ ] compare base branch + - [ ] prompt + - [ ] linked issue / linked PR context + - [ ] attachments + - [ ] agent launch request inputs + - [ ] setup toggle +- [ ] Use the shared modal store for `pendingWorkspace` +- [ ] Preserve duplicate-submit protection +- [ ] Add a single success path that closes the modal, clears draft, updates sidebar state, and navigates exactly like V1 + +Acceptance: + +1. All ported V1 affordances call the same V2-backed orchestration hook. + + +### Milestone 3: Port the V1 modal body into the V2 modal entrypoint + +Files: + +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/` + +Checklist: + +- [ ] Replace the current V2 modal body with the V1 interaction layout +- [ ] Replace the bare textarea flow with `PromptInputProvider` and `PromptInput` +- [ ] Add workspace-name input +- [ ] Add branch-name input +- [ ] Add attachment button and attachment list +- [ ] Add internal issue linking command +- [ ] Add GitHub issue linking command +- [ ] Add PR linking command +- [ ] Add linked context pills +- [ ] Add agent picker +- [ ] Restore `Cmd/Ctrl+Enter` create shortcut +- [ ] Restore V1 project picker placement and behavior +- [ ] Restore V1 inline compare-base/worktree picker placement and behavior +- [ ] Restore V1 composer footer actions and labels +- [ ] Restore advanced options: + - [ ] branch override + - [ ] compare base branch + - [ ] setup toggle + +Acceptance: + +1. The V2-enabled modal is visually and behaviorally equivalent to V1. + + +### Milestone 4: Port the V1 inline PR, issue, and project affordances + +Files: + +- `.../PromptGroup/PromptGroup.tsx` +- `apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx` +- `apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx` +- `apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx` + +Checklist: + +- [ ] Port V1 PR linking command into the V2 modal body +- [ ] Port V1 internal issue linking command into the V2 modal body +- [ ] Port V1 GitHub issue linking command into the V2 modal body +- [ ] Port V1 linked context pills +- [ ] Port V1 project picker including: + - [ ] recent/local project selection + - [ ] `Open project` + - [ ] `New project` +- [ ] Remove current V2 PR/Issue tab-specific create flows +- [ ] Remove current V2 project-selector-only UX if it diverges from V1 + +Acceptance: + +1. PR, issue, and project interactions behave like V1 inside the V2-backed modal. + + +### Milestone 5: Port the V1 inline branch and worktree behavior exactly + +Files: + +- `.../PromptGroup/PromptGroup.tsx` +- supporting open-worktree helpers from the legacy modal + +Checklist: + +- [ ] Port V1's inline compare-base-branch picker +- [ ] Port V1's ability to distinguish: + - [ ] active workspace + - [ ] tracked worktree without active workspace + - [ ] external worktree + - [ ] plain branch +- [ ] Port V1 filter and badge behavior for worktrees and external branches +- [ ] Add open tracked worktree action +- [ ] Add open external worktree action +- [ ] Add reuse-or-adopt flow for orphaned/external worktrees where applicable +- [ ] Preserve V1 "open when possible, create only when needed" behavior +- [ ] Remove current V2 Branches tab UX if it diverges from this behavior + +Acceptance: + +1. Inline branch/worktree behavior matches V1 exactly. + + +### Milestone 6: Expand the host-service create contract to support real V1 semantics + +Files: + +- `packages/host-service/src/trpc/router/workspace/workspace.ts` +- any shared V2 router and host-service client types touched by the new input contract +- `apps/desktop/src/renderer/lib/v2-workspace-host.ts` +- `apps/desktop/src/renderer/lib/host-service-client.ts` + +Checklist: + +- [ ] Extend create input beyond `{ projectId, name, branch }` +- [ ] Add support for `compareBaseBranch` +- [ ] Add support for branch-generation strategy or generated branch handoff +- [ ] Decide how PR-based creation maps into host-service: + - [ ] explicit `source: { kind: "pr" }` + - [ ] or draft-linked PR resolved in renderer before create +- [ ] Ensure host-service create can return enough data for post-create orchestration +- [ ] Ensure new behavior works for: + - [ ] local host + - [ ] cloud host + - [ ] other device host + +Acceptance: + +1. The V2 creation path can express the semantics the V1 UX needs. + + +### Milestone 7: Reconnect setup, init, and agent-launch behavior to V2 + +Context: + +V1 uses `useCreateWorkspace` and `useCreateFromPr` to seed `workspace-init` and pending terminal setup. The current V2 modal does not. + +Checklist: + +- [ ] Decide whether V2 workspaces should use the same `workspace-init` store or a V2-specific equivalent +- [ ] Reconnect pending terminal setup for newly created V2 workspaces +- [ ] Reconnect agent launch request handoff after create +- [ ] Reconnect optimistic progress so the new workspace does not flash an incomplete state +- [ ] Verify auto-run and setup flows on the V2 workspace route + +Acceptance: + +1. Creating from the V2 modal can still launch setup and agent work the way V1 can. + + +### Milestone 8: Remove duplication and retire legacy modal usage + +Checklist: + +- [ ] Audit duplicated logic now shared between legacy and V2 implementations +- [ ] Keep extracted shared helpers in a neutral location if both flows still need them during rollout +- [ ] Delete or reduce old modal-only code once V2 parity is verified +- [ ] Remove dead imports and stale create hooks +- [ ] Delete current V2 modal-only shell components that no longer belong in the exact-parity target: + - [ ] tab header + - [ ] tab content wrappers + - [ ] tab-specific create groups + - [ ] visible device-picker shell if it remains divergent + +Acceptance: + +1. The V2 modal is the canonical creation flow and the old modal is no longer carrying unique business logic. + + +## File Targets + +Primary renderer files: + +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx` +- `apps/desktop/src/renderer/stores/new-workspace-modal.ts` + +Primary legacy sources to extract from: + +- `apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModalDraftContext.tsx` +- `apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx` +- `apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts` +- `apps/desktop/src/renderer/react-query/workspaces/useCreateFromPr.ts` +- `apps/desktop/src/renderer/react-query/workspaces/useOpenTrackedWorktree.ts` +- `apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts` + +Backend and host-service files: + +- `packages/host-service/src/trpc/router/workspace/workspace.ts` +- `packages/trpc/src/router/v2-workspace/v2-workspace.ts` + + +## Verification + +Manual verification: + +1. Create from the ported V1 composer with only prompt text +2. Create from the ported V1 composer with manual workspace name +3. Create from the ported V1 composer with manual branch name +4. Create from the ported V1 composer with attachments +5. Create from the ported V1 composer with linked internal issue +6. Create from the ported V1 composer with linked GitHub issue +7. Create from the ported V1 composer with linked PR +8. Create or open via the inline compare-base/worktree picker when branch already has: + - [ ] active workspace + - [ ] tracked worktree + - [ ] external worktree + - [ ] nothing +9. Verify there are no remaining visible V2-only modal tabs or header behaviors that diverge from V1 +10. Create with setup toggle on and off +11. Create with agent selected and with no agent +12. Create on: + - [ ] local host + - [ ] cloud host + - [ ] another device +13. Confirm new workspace appears in sidebar and route opens correctly + +Code verification: + +```bash +bun run typecheck +bun run lint:fix +``` + +Suggested search checks: + +```bash +rg -n "createWorkspace\\({" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal +rg -n "compareBaseBranch" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal packages/host-service +rg -n "agentLaunchRequest|pendingWorkspace|runSetupScript" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal +rg -n "TabsTrigger|DevicePicker|ProjectSelector|DashboardNewWorkspaceListTabContent" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal +``` + + +## Risks + +1. The hardest part is not UI parity. It is restoring V1's creation pipeline without collapsing back into legacy-only routing. +2. Exact parity means some current V2 modal components may need to be removed rather than reused. +3. Host-targeted creation may expose differences between local, cloud, and other-device repo availability. +4. V2 workspace routes may need extra plumbing before setup and agent launch behave like the old workspace route. + + +## Progress + +- [x] (2026-04-05 19:45 America/Los_Angeles) Compare V1 and V2 workspace modal flows end to end +- [x] (2026-04-05 19:45 America/Los_Angeles) Draft migration plan for moving V1 UX into V2 modal patterns +- [x] (2026-04-05 19:55 America/Los_Angeles) Lock target as exact V1 UX parity on V2 paths and architecture +- [ ] Finalize remaining implementation details for host-target handling +- [ ] Expand V2 draft model +- [ ] Extract shared V2 creation orchestration +- [ ] Port the V1 modal body into the V2 modal entrypoint +- [ ] Port V1 inline PR, issue, and project affordances +- [ ] Port V1 inline branch/worktree behavior +- [ ] Expand host-service create semantics +- [ ] Restore setup/init/agent launch parity +- [ ] Remove duplication and verify rollout + + +## Surprises & Discoveries + +- The current V2 modal stores `compareBaseBranch`, but the create path does not use it. +- The current V2 create flow only ensures sidebar presence. It does not replicate V1's pending setup, agent launch, or navigation behavior. +- Existing task flows still rely on the legacy `useCreateWorkspace` path for setup and agent launch. That logic should be extracted, not reimplemented again. +- The current V2 modal shell itself is now out of scope as a preserved UX; the full-port requirement turns it into migration scaffolding to remove. + + +## Outcomes & Retrospective + +Pending implementation. diff --git a/apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md b/apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md new file mode 100644 index 00000000000..30642af91f4 --- /dev/null +++ b/apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md @@ -0,0 +1,524 @@ +# Proposed V2 Workspace Creation Host-Service API Shape + +This document proposes the target V2 API shape needed to support a full V1 workspace-creation UX port while preserving V2 architecture. + + +## Intent + +The surface UX should match V1 exactly. + +The backing implementation should stay V2: + +1. renderer stays thin +2. host service owns workspace creation orchestration +3. cloud API stores shared V2 workspace records +4. renderer chooses a host endpoint, then talks only to that host service +5. the API must work equally well for local, cloud, and remote device hosts + + +## Design Rules + +1. The renderer should select the host by URL, not by encoding host-specific behavior into the request payload. +2. The host service should own branch, PR, worktree, and repo resolution. +3. The renderer should send a semantic workspace-create draft, not low-level git instructions. +4. The host service should return structured outcomes like `created_workspace` or `opened_existing_workspace`. +5. The API should avoid leaking host-local filesystem paths to the renderer. +6. The API should avoid inlining raw attachment blobs in the create mutation. +7. The API should be good over remote: + - few round trips + - stable semantic contracts + - opaque selection identifiers where host-local state is involved + + +## Current Shape + +Today the V2 path is effectively: + +```ts +type WorkspaceHostTarget = + | { kind: "local" } + | { kind: "cloud" } + | { kind: "device"; deviceId: string }; + +type CurrentRendererCreateInput = { + projectId: string; + name: string; + branch: string; + hostTarget: WorkspaceHostTarget; +}; +``` + +The renderer resolves `hostTarget` to a host URL, then calls: + +```ts +client.workspace.create.mutate({ + projectId, + name, + branch, +}); +``` + +The host service then: + +1. ensures or clones the local repo +2. creates a git worktree +3. ensures a V2 host device +4. calls cloud `v2Workspace.create` +5. stores a local workspace mapping + + +## Proposed Top-Level Shape + +Recommended host-service surface: + +```ts +workspaceCreation.getContext(input) +workspaceCreation.searchBranches(input) +workspaceCreation.searchPullRequests(input) +workspaceCreation.searchInternalIssues(input) +workspaceCreation.searchGitHubIssues(input) +workspaceCreation.create(input) +workspaceCreation.getCreateStatus(input) +``` + +Keep the lower-level workspace router for runtime CRUD: + +```ts +workspace.get(input) +workspace.gitStatus(input) +workspace.delete(input) +``` + + +## Renderer Boundary + +The renderer should still choose the host endpoint using the existing target model: + +```ts +type WorkspaceHostTarget = + | { kind: "local" } + | { kind: "cloud" } + | { kind: "device"; deviceId: string }; +``` + +But after selecting the host URL, all creation semantics should go through host service APIs only. + +The renderer should not: + +1. decide whether something is an open vs create vs adopt action +2. compose PR-specific git behavior itself +3. pass worktree paths +4. build setup execution plans from raw git state + + +## API Summary + +### 1. `workspaceCreation.getContext` + +Purpose: + +Hydrate the V1 modal with project defaults and capabilities. + +```ts +type GetWorkspaceCreationContextInput = { + projectId: string; +}; + +type GetWorkspaceCreationContextResult = { + project: { + id: string; + name: string; + }; + repo: { + available: boolean; + defaultBranch: string | null; + workspaceBaseBranch: string | null; + branchPrefix: string | null; + }; + defaults: { + runSetupScript: boolean; + compareBaseBranch: string | null; + }; + capabilities: { + canLinkPullRequests: boolean; + canLinkGitHubIssues: boolean; + canLinkInternalIssues: boolean; + canUploadAttachments: boolean; + canRunSetupScript: boolean; + canLaunchAgent: boolean; + }; +}; +``` + +Why: + +1. keeps branch prefix and base-branch logic host-owned +2. gives the renderer all V1 form defaults without extra heuristics + + +### 2. `workspaceCreation.searchBranches` + +Purpose: + +Return branch rows that are already action-resolved by the host. + +```ts +type SearchWorkspaceBranchesInput = { + projectId: string; + query?: string; + filter?: "all" | "worktrees"; + limit?: number; +}; + +type BranchRowAction = + | { + kind: "open_workspace"; + workspaceId: string; + label: "Open"; + } + | { + kind: "open_worktree"; + selectionId: string; + label: "Open"; + } + | { + kind: "adopt_external_worktree"; + selectionId: string; + label: "Open"; + } + | { + kind: "create_workspace"; + selectionId: string; + label: "Create"; + }; + +type SearchWorkspaceBranchesResult = { + items: Array<{ + id: string; + branch: string; + isDefault: boolean; + isLocal: boolean; + lastCommitAt: string | null; + badges: Array<"default" | "tracked" | "external">; + action: BranchRowAction; + }>; +}; +``` + +Important: + +1. `selectionId` should be opaque +2. do not send host filesystem paths +3. host service should decide open vs create vs adopt + + +### 3. `workspaceCreation.searchPullRequests` + +Purpose: + +Power the V1 PR linking command. + +```ts +type SearchWorkspacePullRequestsInput = { + projectId: string; + query?: string; + limit?: number; +}; + +type SearchWorkspacePullRequestsResult = { + items: Array<{ + id: string; + prNumber: number; + title: string; + url: string; + state: "open" | "closed" | "draft"; + authorLogin: string | null; + headBranch: string; + }>; +}; +``` + + +### 4. `workspaceCreation.searchInternalIssues` + +Purpose: + +Power the V1 internal issue linker. + +```ts +type SearchWorkspaceInternalIssuesInput = { + projectId: string; + query?: string; + limit?: number; +}; + +type SearchWorkspaceInternalIssuesResult = { + items: Array<{ + id: string; + taskId: string; + slug: string; + title: string; + url: string | null; + status: { + type: string; + color: string; + progressPercent: number | null; + } | null; + }>; +}; +``` + + +### 5. `workspaceCreation.searchGitHubIssues` + +Purpose: + +Power the V1 GitHub issue linker. + +```ts +type SearchWorkspaceGitHubIssuesInput = { + projectId: string; + query?: string; + limit?: number; +}; + +type SearchWorkspaceGitHubIssuesResult = { + items: Array<{ + id: string; + issueNumber: number; + title: string; + url: string; + state: "open" | "closed"; + }>; +}; +``` + + +### 6. `workspaceCreation.create` + +Purpose: + +Represent the full V1 creation surface as one semantic host-service call. + +```ts +type WorkspaceCreateSource = + | { kind: "prompt" } + | { kind: "pull_request"; prUrl: string } + | { kind: "branch_selection"; selectionId: string }; + +type WorkspaceAttachmentRef = { + id: string; + filename: string; + mediaType: string; +}; + +type WorkspaceLinkedContext = { + internalIssueIds: string[]; + githubIssueUrls: string[]; + linkedPrUrl?: string | null; + attachments: WorkspaceAttachmentRef[]; +}; + +type WorkspaceLaunchConfig = { + agentId: string | null; + autoRun: boolean; +}; + +type CreateWorkspaceFromDraftInput = { + projectId: string; + source: WorkspaceCreateSource; + composer: { + workspaceName?: string; + prompt?: string; + branchName?: string; + compareBaseBranch?: string | null; + runSetupScript: boolean; + }; + linkedContext: WorkspaceLinkedContext; + launch: WorkspaceLaunchConfig; + behavior?: { + ifBranchExists?: "open_existing" | "error"; + ifWorktreeExists?: "open_existing" | "adopt" | "error"; + }; +}; +``` + +Notes: + +1. `hostTarget` is not part of this payload +2. the renderer already selected the host by choosing the host URL +3. `selectionId` is used where the host has authoritative knowledge about branch/worktree state + + +### 7. `workspaceCreation.create` result + +Purpose: + +Return a rich semantic outcome instead of only a workspace row. + +```ts +type CreateWorkspaceFromDraftResult = { + outcome: + | "created_workspace" + | "opened_existing_workspace" + | "opened_worktree" + | "adopted_external_worktree"; + + workspace: { + id: string; + projectId: string; + name: string; + branch: string; + deviceId: string | null; + }; + + init: { + status: "queued" | "not_required"; + setup: { + status: "queued" | "skipped"; + initialCommands: string[] | null; + }; + agent: { + status: "queued" | "skipped"; + launchRequestId: string | null; + }; + }; + + warnings: Array<{ + code: + | "auto_branch_fallback" + | "auto_name_failed" + | "github_issue_fetch_partial" + | "setup_skipped" + | "agent_skipped"; + message: string; + }>; +}; +``` + +Why this matters: + +1. the renderer gets one authoritative answer +2. the host service owns all branching complexity +3. the response works the same over local and remote hosts + + +### 8. `workspaceCreation.getCreateStatus` + +Purpose: + +Allow the renderer to observe queued setup and agent launch work cleanly. + +```ts +type GetWorkspaceCreateStatusInput = { + workspaceId: string; +}; + +type GetWorkspaceCreateStatusResult = { + workspaceId: string; + setup: { + status: "queued" | "running" | "completed" | "failed" | "skipped"; + message: string | null; + }; + agent: { + status: "queued" | "running" | "completed" | "failed" | "skipped"; + message: string | null; + }; +}; +``` + +This can later be replaced or augmented with subscriptions, but the shape should be semantic from the start. + + +## Attachment Handling + +Do not keep V1's inline base64-in-create approach. + +Recommended pattern: + +1. upload attachments first +2. pass attachment refs into `workspaceCreation.create` +3. let host service fetch and materialize those refs as needed + +Suggested attachment contract: + +```ts +workspaceAttachments.beginUpload() +workspaceAttachments.completeUpload() +``` + +Then pass: + +```ts +linkedContext.attachments: WorkspaceAttachmentRef[] +``` + + +## Cloud API Shape + +The cloud V2 workspace API should stay thinner than host service. + +Recommended shared cloud shape: + +```ts +type CloudCreateV2WorkspaceInput = { + projectId: string; + deviceId: string; + name: string; + branch: string; + baseBranch?: string | null; + metadata?: { + sourceKind: "prompt" | "pull_request" | "branch"; + sourceRef?: string | null; + }; +}; +``` + +This keeps: + +1. orchestration in host service +2. shared record persistence in cloud +3. enough shared metadata for future auditability + + +## What Should Stay Out Of The Renderer + +Do not require the renderer to: + +1. distinguish `open workspace` vs `open tracked worktree` vs `adopt external worktree` +2. resolve branch prefixes +3. derive setup execution plans +4. decide whether a pasted PR should use a PR-specific creation path +5. fetch host-local filesystem paths + + +## Smallest Viable End State + +If implementation needs to phase in: + +Phase 1: + +1. add `workspaceCreation.getContext` +2. add `workspaceCreation.searchBranches` +3. add `workspaceCreation.create` + +Phase 2: + +1. move PR search into host service +2. move issue search into host service +3. add attachment upload refs +4. add explicit create status tracking + +This still gets the hardest part right early: + +1. host-service owns create semantics +2. renderer stays thin +3. remote hosts behave the same as local hosts + + +## Open Questions + +1. Should PR and issue search live in host service immediately, or can they remain renderer/cloud-backed in phase 1? +2. Should `workspaceCreation.create` queue setup and agent launch itself, or return a plan for the renderer to execute? +3. Should cloud `v2Workspace` store `baseBranch` and `sourceKind` now, or can that remain host-local temporarily? +4. Should host-target selection remain visible anywhere in the UI once the full V1 UX port lands? From ecf7ee6cb3aa50c3fd32e6a436c1f0778ade47ed Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 7 Apr 2026 12:52:05 -0700 Subject: [PATCH 2/9] consolidated doc --- ...0405-1945-v1-workspace-ux-into-v2-modal.md | 586 ++++-------------- ...260405-2002-v2-workspace-host-api-shape.md | 524 ---------------- 2 files changed, 131 insertions(+), 979 deletions(-) delete mode 100644 apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md diff --git a/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md b/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md index bc209ed66f8..49e6b13c617 100644 --- a/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md +++ b/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md @@ -1,503 +1,179 @@ -# Full Port Of V1 Workspace UX Onto V2 Paths And Architecture - -This ExecPlan is a living document. Update `Progress`, `Decision Log`, `Surprises & Discoveries`, and `Outcomes & Retrospective` as implementation proceeds. +# V1 Create Workspace Port On V2 Hosts +This doc replaces the earlier split plan and API draft. ## Goal -Match the V1 workspace creation experience exactly, while using the V2-enabled routes, collections, host-service, and sidebar/workspace architecture underneath. - -Target outcome: - -1. the V2-enabled modal looks and behaves like the V1 modal -2. all create/open flows execute through V2 paths and architecture, not the old modal-specific stack -3. current V2-only modal UI is removed wherever it diverges from the V1 experience -4. V1 business logic is ported or extracted, not left stranded in the legacy modal -5. there is one canonical workspace-creation UX stack when `V2_CLOUD` is enabled - - -## Non-Goals - -1. Do not change the `V2_CLOUD` off-path behavior. -2. Do not remove the legacy modal until V2 reaches agreed parity. -3. Do not preserve the current V2 modal tabs, header, or device-picker UX if they conflict with exact V1 parity. -4. Do not hand-edit generated database artifacts. - - -## Current State - -### V1 strengths - -The old modal under `apps/desktop/src/renderer/components/NewWorkspaceModal/` already has the behaviors we want to preserve: - -1. one prompt-centric composer flow -2. editable workspace name and branch name -3. prompt attachments -4. linked internal issues -5. linked GitHub issues -6. linked PRs -7. agent selection and agent launch request creation -8. setup-script control -9. compare-base-branch selection -10. open existing active workspaces, tracked worktrees, and external worktrees -11. pending workspace lifecycle and optimistic init state -12. auto-navigation into the created workspace - -### V2 architecture strengths - -The V2-enabled stack already gives us the backing architecture we want to keep: - -1. V2 collections-backed project data -2. host-service based workspace creation -3. V2 dashboard-specific routing -4. V2 sidebar insertion after create -5. a path to device-targeted creation without depending on the old modal's local-only assumptions - -### Main gaps today - -1. prompt tab is much thinner than V1 -2. prompt tab has no attachments, agent picker, linked context pills, or workspace-name field -3. `compareBaseBranch` is stored in draft but not sent anywhere -4. setup-script and pending-init behavior are missing -5. the current V2 tabbed interaction model does not match V1 and should not be treated as the target -6. PR and issue tabs create directly instead of matching V1 inline linking behavior -7. branch tab cannot open tracked or external worktrees the way V1 can -8. the visible device-picker and header shell are V2-specific UX that diverge from V1 -9. V2 create only sends `projectId`, `name`, and `branch` -10. V2 create does not seed the old pending terminal setup / agent launch pipeline -11. V2 create does not auto-navigate into the new workspace - - -## Proposed UX Mapping - -Recommended target behavior: - -1. The V2-enabled modal should render and behave like the V1 modal, not like the current tabbed V2 dashboard modal. -2. Restore the V1 single-composer surface in full: - - workspace name - - branch name - - prompt composer - - attachments - - linked issue pills - - linked GitHub issue pills - - linked PR pill - - agent picker - - inline project picker - - inline compare-base-branch / worktree picker - - setup toggle - - V1 shortcut hints and action placement -3. PR, issue, and branch behaviors should return as V1 inline commands and pickers, not dedicated V2 tabs. -4. If host-target selection is still needed for architecture reasons, it should be defaulted or integrated behind the scenes for the first full-port pass rather than changing the V1 UX. -5. Use one shared V2 creation orchestration hook behind the exact V1 UI. - - -## Recommended Decisions - -### DL-1 Exact V1 UX parity beats hybridization - -Decision: the target is a full UX port. Do not preserve current V2 modal structure when it conflicts with the V1 experience. - -Reason: - -1. The user explicitly wants a full port, not a compromise. -2. Hybridizing two interaction models will keep the feature hard to maintain. -3. The current V2 shell is implementation scaffolding, not the product target. - - -### DL-2 Restore the V1 single-composer model - -Decision: the V2 modal should use the V1 single-composer interaction model, not the current prompt/issues/PRs/branches tab split. - -Reason: - -1. V1 does not present these flows as separate tabs. -2. A single composer is the clearest way to achieve exact parity. -3. It avoids carrying two different mental models through the port. - - -### DL-3 PR, issue, and branch affordances should return in V1 form - -Decision: restore PR link, issue link, GitHub issue link, and inline compare-base/worktree picker behavior from V1 instead of adapting the current V2 tab components. - -Reason: - -1. Exact parity requires the same placement and interaction model. -2. The current V2 tabs materially change the experience. - - -### DL-4 V2 paths remain the backing implementation - -Decision: use the V2 collections, routing, host-service, and sidebar/workspace model as the backing implementation, even though the surface UX should match V1. - -Reason: - -1. The request is specifically a full port onto V2 paths and architecture. -2. This avoids carrying old create logic indefinitely. -3. Device-targeted creation and V2 workspace records belong in the new stack. - - -### DL-5 Visible host/device controls are not part of the first-parity target - -Decision: if host-target selection is still required, satisfy it internally or via a non-divergent fallback during the full-port pass. Do not keep the current visible device picker if it changes the V1 experience. - -Reason: - -1. The user asked for an exact V1 experience. -2. Architecture requirements should not force obvious UX regressions during the port. - - -## Workstreams - -### Milestone 0: Lock UX contract before editing code - -Checklist: - -- [ ] Explicitly lock the target as exact V1 UX parity -- [ ] Explicitly drop the current V2 tabbed modal as the UX target -- [ ] Decide how host-target selection is handled without changing the V1 experience -- [ ] Confirm whether `Open project` and `New project` return exactly as in V1 -- [ ] Confirm that setup-script toggle is required in the first full-port pass -- [ ] Confirm that V2 create should auto-navigate exactly like V1 - -Acceptance: - -1. The team agrees that this is a full V1 UX port and not a hybrid. - +Match the V1 create-workspace experience exactly while keeping the V2 stack: -### Milestone 1: Expand the V2 draft model to hold the full V1 state model +1. V1 composer UX +2. V2 routes, collections, sidebar, and workspace rows +3. host-service as the semantic backend +4. `@superset/workspace-client` as the only host transport +5. the unified `/events` bus as the live-state channel -Files: +## Boundaries -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx` -- `apps/desktop/src/renderer/stores/new-workspace-modal.ts` +### Renderer -Checklist: +Owns: -- [ ] Add `workspaceName` and `workspaceNameEdited` -- [ ] Add `runSetupScript` -- [ ] Add linked issue state -- [ ] Add linked PR state -- [ ] Add attachment reset support equivalent to V1's `resetKey` -- [ ] Add selected agent state or a hook boundary for persisted agent selection -- [ ] Keep only the V2 fields that are architectural necessities: - - [ ] `selectedProjectId` - - [ ] `hostTarget` - - [ ] `compareBaseBranch` - - [ ] `branchName` -- [ ] Remove V2-only draft fields that exist only for the current tabbed UX: - - [ ] `activeTab` - - [ ] per-tab search queries, if no longer needed after the full port +1. modal draft state +2. exact V1 UI +3. picking one `WorkspaceHostTarget` +4. optimistic UI and navigation -Acceptance: +Does not own: -1. The V2 modal can represent all V1 creation inputs in one shared draft. +1. branch/worktree/open/adopt decisions +2. repo scanning +3. PR-specific create behavior +4. setup or agent execution +5. a separate websocket or polling layer +### `@superset/workspace-client` -### Milestone 2: Extract one shared V2 creation orchestration hook +Owns: -Goal: +1. one tRPC client per host URL +2. one `/events` connection per host URL +3. auth, reconnect, ref-counting, subscriptions -Move V1's create pipeline out of the old modal-specific component and into a reusable V2-oriented hook. +Does not own: -Suggested new files: +1. create semantics +2. repo/worktree logic -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspaceFlow.ts` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useWorkspaceDraftLaunchRequest.ts` +### Host-service -Source behavior to port from V1: +Owns: -1. pending workspace lifecycle -2. branch-name generation when branch is not manually edited -3. attachment conversion -4. GitHub issue content fetch and attachment enrichment -5. agent launch request construction -6. setup-script override handling -7. toast lifecycle -8. post-create navigation +1. `workspaceCreation.*` APIs +2. repo clone/ensure +3. branch generation and base-branch handling +4. PR/issue/worktree resolution +5. open vs create vs adopt behavior +6. setup/init execution +7. agent launch handoff +8. lifecycle events on `/events` -Checklist: +### Cloud/shared APIs -- [ ] Extract the V1 prompt-create pipeline from the old `PromptGroup` -- [ ] Define one typed V2 flow input that includes: - - [ ] project ID - - [ ] host target - - [ ] workspace name - - [ ] branch name or generation strategy - - [ ] compare base branch - - [ ] prompt - - [ ] linked issue / linked PR context - - [ ] attachments - - [ ] agent launch request inputs - - [ ] setup toggle -- [ ] Use the shared modal store for `pendingWorkspace` -- [ ] Preserve duplicate-submit protection -- [ ] Add a single success path that closes the modal, clears draft, updates sidebar state, and navigates exactly like V1 +Stay thin: -Acceptance: +1. hosts +2. workspace rows +3. project metadata +4. shared PR/issue/task data if proxied by host-service -1. All ported V1 affordances call the same V2-backed orchestration hook. +## Target UX +Keep the V1 surface: -### Milestone 3: Port the V1 modal body into the V2 modal entrypoint +1. single composer +2. workspace name +3. branch name +4. prompt +5. attachments +6. linked internal issues +7. linked GitHub issues +8. linked PR +9. agent picker +10. setup toggle +11. inline compare-base/worktree picker +12. auto-open/navigate after create -Files: +Do not keep the current V2 tabbed modal or visible host picker if they change the V1 experience. -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/` +## Target Host API -Checklist: +```ts +workspaceCreation.getContext({ projectId }) +workspaceCreation.searchBranches({ projectId, query, filter, limit }) +workspaceCreation.searchPullRequests({ projectId, query, limit }) +workspaceCreation.searchInternalIssues({ projectId, query, limit }) +workspaceCreation.searchGitHubIssues({ projectId, query, limit }) +workspaceCreation.prepareAttachmentUpload(...) +workspaceCreation.commitAttachmentUpload(...) +workspaceCreation.create(...) -- [ ] Replace the current V2 modal body with the V1 interaction layout -- [ ] Replace the bare textarea flow with `PromptInputProvider` and `PromptInput` -- [ ] Add workspace-name input -- [ ] Add branch-name input -- [ ] Add attachment button and attachment list -- [ ] Add internal issue linking command -- [ ] Add GitHub issue linking command -- [ ] Add PR linking command -- [ ] Add linked context pills -- [ ] Add agent picker -- [ ] Restore `Cmd/Ctrl+Enter` create shortcut -- [ ] Restore V1 project picker placement and behavior -- [ ] Restore V1 inline compare-base/worktree picker placement and behavior -- [ ] Restore V1 composer footer actions and labels -- [ ] Restore advanced options: - - [ ] branch override - - [ ] compare base branch - - [ ] setup toggle - -Acceptance: - -1. The V2-enabled modal is visually and behaviorally equivalent to V1. - - -### Milestone 4: Port the V1 inline PR, issue, and project affordances - -Files: - -- `.../PromptGroup/PromptGroup.tsx` -- `apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx` -- `apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx` -- `apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx` - -Checklist: - -- [ ] Port V1 PR linking command into the V2 modal body -- [ ] Port V1 internal issue linking command into the V2 modal body -- [ ] Port V1 GitHub issue linking command into the V2 modal body -- [ ] Port V1 linked context pills -- [ ] Port V1 project picker including: - - [ ] recent/local project selection - - [ ] `Open project` - - [ ] `New project` -- [ ] Remove current V2 PR/Issue tab-specific create flows -- [ ] Remove current V2 project-selector-only UX if it diverges from V1 - -Acceptance: - -1. PR, issue, and project interactions behave like V1 inside the V2-backed modal. - - -### Milestone 5: Port the V1 inline branch and worktree behavior exactly - -Files: - -- `.../PromptGroup/PromptGroup.tsx` -- supporting open-worktree helpers from the legacy modal - -Checklist: - -- [ ] Port V1's inline compare-base-branch picker -- [ ] Port V1's ability to distinguish: - - [ ] active workspace - - [ ] tracked worktree without active workspace - - [ ] external worktree - - [ ] plain branch -- [ ] Port V1 filter and badge behavior for worktrees and external branches -- [ ] Add open tracked worktree action -- [ ] Add open external worktree action -- [ ] Add reuse-or-adopt flow for orphaned/external worktrees where applicable -- [ ] Preserve V1 "open when possible, create only when needed" behavior -- [ ] Remove current V2 Branches tab UX if it diverges from this behavior - -Acceptance: - -1. Inline branch/worktree behavior matches V1 exactly. - - -### Milestone 6: Expand the host-service create contract to support real V1 semantics - -Files: - -- `packages/host-service/src/trpc/router/workspace/workspace.ts` -- any shared V2 router and host-service client types touched by the new input contract -- `apps/desktop/src/renderer/lib/v2-workspace-host.ts` -- `apps/desktop/src/renderer/lib/host-service-client.ts` - -Checklist: - -- [ ] Extend create input beyond `{ projectId, name, branch }` -- [ ] Add support for `compareBaseBranch` -- [ ] Add support for branch-generation strategy or generated branch handoff -- [ ] Decide how PR-based creation maps into host-service: - - [ ] explicit `source: { kind: "pr" }` - - [ ] or draft-linked PR resolved in renderer before create -- [ ] Ensure host-service create can return enough data for post-create orchestration -- [ ] Ensure new behavior works for: - - [ ] local host - - [ ] cloud host - - [ ] other device host - -Acceptance: - -1. The V2 creation path can express the semantics the V1 UX needs. - - -### Milestone 7: Reconnect setup, init, and agent-launch behavior to V2 - -Context: - -V1 uses `useCreateWorkspace` and `useCreateFromPr` to seed `workspace-init` and pending terminal setup. The current V2 modal does not. - -Checklist: - -- [ ] Decide whether V2 workspaces should use the same `workspace-init` store or a V2-specific equivalent -- [ ] Reconnect pending terminal setup for newly created V2 workspaces -- [ ] Reconnect agent launch request handoff after create -- [ ] Reconnect optimistic progress so the new workspace does not flash an incomplete state -- [ ] Verify auto-run and setup flows on the V2 workspace route - -Acceptance: - -1. Creating from the V2 modal can still launch setup and agent work the way V1 can. - - -### Milestone 8: Remove duplication and retire legacy modal usage - -Checklist: - -- [ ] Audit duplicated logic now shared between legacy and V2 implementations -- [ ] Keep extracted shared helpers in a neutral location if both flows still need them during rollout -- [ ] Delete or reduce old modal-only code once V2 parity is verified -- [ ] Remove dead imports and stale create hooks -- [ ] Delete current V2 modal-only shell components that no longer belong in the exact-parity target: - - [ ] tab header - - [ ] tab content wrappers - - [ ] tab-specific create groups - - [ ] visible device-picker shell if it remains divergent - -Acceptance: - -1. The V2 modal is the canonical creation flow and the old modal is no longer carrying unique business logic. - - -## File Targets - -Primary renderer files: - -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx` -- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx` -- `apps/desktop/src/renderer/stores/new-workspace-modal.ts` - -Primary legacy sources to extract from: - -- `apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModalDraftContext.tsx` -- `apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx` -- `apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts` -- `apps/desktop/src/renderer/react-query/workspaces/useCreateFromPr.ts` -- `apps/desktop/src/renderer/react-query/workspaces/useOpenTrackedWorktree.ts` -- `apps/desktop/src/renderer/react-query/workspaces/useOpenExternalWorktree.ts` - -Backend and host-service files: +workspace.get({ id }) +workspace.getInitState({ workspaceId }) +workspace.gitStatus({ id }) +workspace.delete({ id }) +``` -- `packages/host-service/src/trpc/router/workspace/workspace.ts` -- `packages/trpc/src/router/v2-workspace/v2-workspace.ts` +Core create shape: + +```ts +workspaceCreation.create({ + projectId, + source, + names: { workspaceName, branchName }, + composer: { prompt, compareBaseBranch, runSetupScript }, + linkedContext: { + internalIssueIds, + githubIssueUrls, + linkedPrUrl, + attachments, + }, + launch: { agentId, autoRun }, + behavior: { onExistingWorkspace, onExistingWorktree }, +}) +``` +Create returns: -## Verification +1. outcome: `created_workspace | opened_existing_workspace | opened_worktree | adopted_external_worktree` +2. workspace row +3. initial init state +4. warnings -Manual verification: +## Event Bus -1. Create from the ported V1 composer with only prompt text -2. Create from the ported V1 composer with manual workspace name -3. Create from the ported V1 composer with manual branch name -4. Create from the ported V1 composer with attachments -5. Create from the ported V1 composer with linked internal issue -6. Create from the ported V1 composer with linked GitHub issue -7. Create from the ported V1 composer with linked PR -8. Create or open via the inline compare-base/worktree picker when branch already has: - - [ ] active workspace - - [ ] tracked worktree - - [ ] external worktree - - [ ] nothing -9. Verify there are no remaining visible V2-only modal tabs or header behaviors that diverge from V1 -10. Create with setup toggle on and off -11. Create with agent selected and with no agent -12. Create on: - - [ ] local host - - [ ] cloud host - - [ ] another device -13. Confirm new workspace appears in sidebar and route opens correctly +Use the existing host `/events` bus. -Code verification: +Keep: -```bash -bun run typecheck -bun run lint:fix -``` +1. `git:changed` +2. `fs:events` -Suggested search checks: +Add: -```bash -rg -n "createWorkspace\\({" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal -rg -n "compareBaseBranch" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal packages/host-service -rg -n "agentLaunchRequest|pendingWorkspace|runSetupScript" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal -rg -n "TabsTrigger|DevicePicker|ProjectSelector|DashboardNewWorkspaceListTabContent" apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal +```ts +workspace:init:changed { workspaceId, init } ``` +Rules: -## Risks - -1. The hardest part is not UI parity. It is restoring V1's creation pipeline without collapsing back into legacy-only routing. -2. Exact parity means some current V2 modal components may need to be removed rather than reused. -3. Host-targeted creation may expose differences between local, cloud, and other-device repo availability. -4. V2 workspace routes may need extra plumbing before setup and agent launch behave like the old workspace route. - - -## Progress +1. `workspaceCreation.create` returns the initial init snapshot +2. `workspace.getInitState` hydrates on reload +3. `/events` pushes later setup/agent progress +4. no separate create-status polling flow -- [x] (2026-04-05 19:45 America/Los_Angeles) Compare V1 and V2 workspace modal flows end to end -- [x] (2026-04-05 19:45 America/Los_Angeles) Draft migration plan for moving V1 UX into V2 modal patterns -- [x] (2026-04-05 19:55 America/Los_Angeles) Lock target as exact V1 UX parity on V2 paths and architecture -- [ ] Finalize remaining implementation details for host-target handling -- [ ] Expand V2 draft model -- [ ] Extract shared V2 creation orchestration -- [ ] Port the V1 modal body into the V2 modal entrypoint -- [ ] Port V1 inline PR, issue, and project affordances -- [ ] Port V1 inline branch/worktree behavior -- [ ] Expand host-service create semantics -- [ ] Restore setup/init/agent launch parity -- [ ] Remove duplication and verify rollout +## Phases +### Phase 1 -## Surprises & Discoveries +1. Replace the V2 modal UI with the exact V1 composer +2. Expand the V2 draft/store to hold full V1 state +3. Add `workspaceCreation.getContext` +4. Add `workspaceCreation.searchBranches` +5. Add semantic `workspaceCreation.create` +6. Add `workspace.getInitState` +7. Add `workspace:init:changed` -- The current V2 modal stores `compareBaseBranch`, but the create path does not use it. -- The current V2 create flow only ensures sidebar presence. It does not replicate V1's pending setup, agent launch, or navigation behavior. -- Existing task flows still rely on the legacy `useCreateWorkspace` path for setup and agent launch. That logic should be extracted, not reimplemented again. -- The current V2 modal shell itself is now out of scope as a preserved UX; the full-port requirement turns it into migration scaffolding to remove. +### Phase 2 +1. Move PR and issue linking behind host-service +2. Move attachments to upload refs +3. Port open/adopt worktree behavior fully +4. Remove remaining V2-only modal shell pieces -## Outcomes & Retrospective +## Decisions Locked -Pending implementation. +1. Exact V1 UX wins over preserving the current V2 modal structure. +2. Host-service is the only semantic backend boundary for modal behavior. +3. `@superset/workspace-client` is the only host transport boundary. +4. Live init/setup state should extend the unified event bus. +5. Visible host selection is not part of first-pass parity. diff --git a/apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md b/apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md deleted file mode 100644 index 30642af91f4..00000000000 --- a/apps/desktop/plans/20260405-2002-v2-workspace-host-api-shape.md +++ /dev/null @@ -1,524 +0,0 @@ -# Proposed V2 Workspace Creation Host-Service API Shape - -This document proposes the target V2 API shape needed to support a full V1 workspace-creation UX port while preserving V2 architecture. - - -## Intent - -The surface UX should match V1 exactly. - -The backing implementation should stay V2: - -1. renderer stays thin -2. host service owns workspace creation orchestration -3. cloud API stores shared V2 workspace records -4. renderer chooses a host endpoint, then talks only to that host service -5. the API must work equally well for local, cloud, and remote device hosts - - -## Design Rules - -1. The renderer should select the host by URL, not by encoding host-specific behavior into the request payload. -2. The host service should own branch, PR, worktree, and repo resolution. -3. The renderer should send a semantic workspace-create draft, not low-level git instructions. -4. The host service should return structured outcomes like `created_workspace` or `opened_existing_workspace`. -5. The API should avoid leaking host-local filesystem paths to the renderer. -6. The API should avoid inlining raw attachment blobs in the create mutation. -7. The API should be good over remote: - - few round trips - - stable semantic contracts - - opaque selection identifiers where host-local state is involved - - -## Current Shape - -Today the V2 path is effectively: - -```ts -type WorkspaceHostTarget = - | { kind: "local" } - | { kind: "cloud" } - | { kind: "device"; deviceId: string }; - -type CurrentRendererCreateInput = { - projectId: string; - name: string; - branch: string; - hostTarget: WorkspaceHostTarget; -}; -``` - -The renderer resolves `hostTarget` to a host URL, then calls: - -```ts -client.workspace.create.mutate({ - projectId, - name, - branch, -}); -``` - -The host service then: - -1. ensures or clones the local repo -2. creates a git worktree -3. ensures a V2 host device -4. calls cloud `v2Workspace.create` -5. stores a local workspace mapping - - -## Proposed Top-Level Shape - -Recommended host-service surface: - -```ts -workspaceCreation.getContext(input) -workspaceCreation.searchBranches(input) -workspaceCreation.searchPullRequests(input) -workspaceCreation.searchInternalIssues(input) -workspaceCreation.searchGitHubIssues(input) -workspaceCreation.create(input) -workspaceCreation.getCreateStatus(input) -``` - -Keep the lower-level workspace router for runtime CRUD: - -```ts -workspace.get(input) -workspace.gitStatus(input) -workspace.delete(input) -``` - - -## Renderer Boundary - -The renderer should still choose the host endpoint using the existing target model: - -```ts -type WorkspaceHostTarget = - | { kind: "local" } - | { kind: "cloud" } - | { kind: "device"; deviceId: string }; -``` - -But after selecting the host URL, all creation semantics should go through host service APIs only. - -The renderer should not: - -1. decide whether something is an open vs create vs adopt action -2. compose PR-specific git behavior itself -3. pass worktree paths -4. build setup execution plans from raw git state - - -## API Summary - -### 1. `workspaceCreation.getContext` - -Purpose: - -Hydrate the V1 modal with project defaults and capabilities. - -```ts -type GetWorkspaceCreationContextInput = { - projectId: string; -}; - -type GetWorkspaceCreationContextResult = { - project: { - id: string; - name: string; - }; - repo: { - available: boolean; - defaultBranch: string | null; - workspaceBaseBranch: string | null; - branchPrefix: string | null; - }; - defaults: { - runSetupScript: boolean; - compareBaseBranch: string | null; - }; - capabilities: { - canLinkPullRequests: boolean; - canLinkGitHubIssues: boolean; - canLinkInternalIssues: boolean; - canUploadAttachments: boolean; - canRunSetupScript: boolean; - canLaunchAgent: boolean; - }; -}; -``` - -Why: - -1. keeps branch prefix and base-branch logic host-owned -2. gives the renderer all V1 form defaults without extra heuristics - - -### 2. `workspaceCreation.searchBranches` - -Purpose: - -Return branch rows that are already action-resolved by the host. - -```ts -type SearchWorkspaceBranchesInput = { - projectId: string; - query?: string; - filter?: "all" | "worktrees"; - limit?: number; -}; - -type BranchRowAction = - | { - kind: "open_workspace"; - workspaceId: string; - label: "Open"; - } - | { - kind: "open_worktree"; - selectionId: string; - label: "Open"; - } - | { - kind: "adopt_external_worktree"; - selectionId: string; - label: "Open"; - } - | { - kind: "create_workspace"; - selectionId: string; - label: "Create"; - }; - -type SearchWorkspaceBranchesResult = { - items: Array<{ - id: string; - branch: string; - isDefault: boolean; - isLocal: boolean; - lastCommitAt: string | null; - badges: Array<"default" | "tracked" | "external">; - action: BranchRowAction; - }>; -}; -``` - -Important: - -1. `selectionId` should be opaque -2. do not send host filesystem paths -3. host service should decide open vs create vs adopt - - -### 3. `workspaceCreation.searchPullRequests` - -Purpose: - -Power the V1 PR linking command. - -```ts -type SearchWorkspacePullRequestsInput = { - projectId: string; - query?: string; - limit?: number; -}; - -type SearchWorkspacePullRequestsResult = { - items: Array<{ - id: string; - prNumber: number; - title: string; - url: string; - state: "open" | "closed" | "draft"; - authorLogin: string | null; - headBranch: string; - }>; -}; -``` - - -### 4. `workspaceCreation.searchInternalIssues` - -Purpose: - -Power the V1 internal issue linker. - -```ts -type SearchWorkspaceInternalIssuesInput = { - projectId: string; - query?: string; - limit?: number; -}; - -type SearchWorkspaceInternalIssuesResult = { - items: Array<{ - id: string; - taskId: string; - slug: string; - title: string; - url: string | null; - status: { - type: string; - color: string; - progressPercent: number | null; - } | null; - }>; -}; -``` - - -### 5. `workspaceCreation.searchGitHubIssues` - -Purpose: - -Power the V1 GitHub issue linker. - -```ts -type SearchWorkspaceGitHubIssuesInput = { - projectId: string; - query?: string; - limit?: number; -}; - -type SearchWorkspaceGitHubIssuesResult = { - items: Array<{ - id: string; - issueNumber: number; - title: string; - url: string; - state: "open" | "closed"; - }>; -}; -``` - - -### 6. `workspaceCreation.create` - -Purpose: - -Represent the full V1 creation surface as one semantic host-service call. - -```ts -type WorkspaceCreateSource = - | { kind: "prompt" } - | { kind: "pull_request"; prUrl: string } - | { kind: "branch_selection"; selectionId: string }; - -type WorkspaceAttachmentRef = { - id: string; - filename: string; - mediaType: string; -}; - -type WorkspaceLinkedContext = { - internalIssueIds: string[]; - githubIssueUrls: string[]; - linkedPrUrl?: string | null; - attachments: WorkspaceAttachmentRef[]; -}; - -type WorkspaceLaunchConfig = { - agentId: string | null; - autoRun: boolean; -}; - -type CreateWorkspaceFromDraftInput = { - projectId: string; - source: WorkspaceCreateSource; - composer: { - workspaceName?: string; - prompt?: string; - branchName?: string; - compareBaseBranch?: string | null; - runSetupScript: boolean; - }; - linkedContext: WorkspaceLinkedContext; - launch: WorkspaceLaunchConfig; - behavior?: { - ifBranchExists?: "open_existing" | "error"; - ifWorktreeExists?: "open_existing" | "adopt" | "error"; - }; -}; -``` - -Notes: - -1. `hostTarget` is not part of this payload -2. the renderer already selected the host by choosing the host URL -3. `selectionId` is used where the host has authoritative knowledge about branch/worktree state - - -### 7. `workspaceCreation.create` result - -Purpose: - -Return a rich semantic outcome instead of only a workspace row. - -```ts -type CreateWorkspaceFromDraftResult = { - outcome: - | "created_workspace" - | "opened_existing_workspace" - | "opened_worktree" - | "adopted_external_worktree"; - - workspace: { - id: string; - projectId: string; - name: string; - branch: string; - deviceId: string | null; - }; - - init: { - status: "queued" | "not_required"; - setup: { - status: "queued" | "skipped"; - initialCommands: string[] | null; - }; - agent: { - status: "queued" | "skipped"; - launchRequestId: string | null; - }; - }; - - warnings: Array<{ - code: - | "auto_branch_fallback" - | "auto_name_failed" - | "github_issue_fetch_partial" - | "setup_skipped" - | "agent_skipped"; - message: string; - }>; -}; -``` - -Why this matters: - -1. the renderer gets one authoritative answer -2. the host service owns all branching complexity -3. the response works the same over local and remote hosts - - -### 8. `workspaceCreation.getCreateStatus` - -Purpose: - -Allow the renderer to observe queued setup and agent launch work cleanly. - -```ts -type GetWorkspaceCreateStatusInput = { - workspaceId: string; -}; - -type GetWorkspaceCreateStatusResult = { - workspaceId: string; - setup: { - status: "queued" | "running" | "completed" | "failed" | "skipped"; - message: string | null; - }; - agent: { - status: "queued" | "running" | "completed" | "failed" | "skipped"; - message: string | null; - }; -}; -``` - -This can later be replaced or augmented with subscriptions, but the shape should be semantic from the start. - - -## Attachment Handling - -Do not keep V1's inline base64-in-create approach. - -Recommended pattern: - -1. upload attachments first -2. pass attachment refs into `workspaceCreation.create` -3. let host service fetch and materialize those refs as needed - -Suggested attachment contract: - -```ts -workspaceAttachments.beginUpload() -workspaceAttachments.completeUpload() -``` - -Then pass: - -```ts -linkedContext.attachments: WorkspaceAttachmentRef[] -``` - - -## Cloud API Shape - -The cloud V2 workspace API should stay thinner than host service. - -Recommended shared cloud shape: - -```ts -type CloudCreateV2WorkspaceInput = { - projectId: string; - deviceId: string; - name: string; - branch: string; - baseBranch?: string | null; - metadata?: { - sourceKind: "prompt" | "pull_request" | "branch"; - sourceRef?: string | null; - }; -}; -``` - -This keeps: - -1. orchestration in host service -2. shared record persistence in cloud -3. enough shared metadata for future auditability - - -## What Should Stay Out Of The Renderer - -Do not require the renderer to: - -1. distinguish `open workspace` vs `open tracked worktree` vs `adopt external worktree` -2. resolve branch prefixes -3. derive setup execution plans -4. decide whether a pasted PR should use a PR-specific creation path -5. fetch host-local filesystem paths - - -## Smallest Viable End State - -If implementation needs to phase in: - -Phase 1: - -1. add `workspaceCreation.getContext` -2. add `workspaceCreation.searchBranches` -3. add `workspaceCreation.create` - -Phase 2: - -1. move PR search into host service -2. move issue search into host service -3. add attachment upload refs -4. add explicit create status tracking - -This still gets the hardest part right early: - -1. host-service owns create semantics -2. renderer stays thin -3. remote hosts behave the same as local hosts - - -## Open Questions - -1. Should PR and issue search live in host service immediately, or can they remain renderer/cloud-backed in phase 1? -2. Should `workspaceCreation.create` queue setup and agent launch itself, or return a plan for the renderer to execute? -3. Should cloud `v2Workspace` store `baseBranch` and `sourceKind` now, or can that remain host-local temporarily? -4. Should host-target selection remain visible anywhere in the UI once the full V1 UX port lands? From c2bc83c64e7b49ce74a0f76d54274fbd1a9c420a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 7 Apr 2026 14:26:47 -0700 Subject: [PATCH 3/9] Update docs --- ...0405-1945-v1-workspace-ux-into-v2-modal.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md b/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md index 49e6b13c617..3b6425c8ad4 100644 --- a/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md +++ b/apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md @@ -4,9 +4,9 @@ This doc replaces the earlier split plan and API draft. ## Goal -Match the V1 create-workspace experience exactly while keeping the V2 stack: +Match the V1 create-workspace experience on the V2 stack, with one intentional addition: explicit host-target selection. -1. V1 composer UX +1. V1 composer UX and semantics 2. V2 routes, collections, sidebar, and workspace rows 3. host-service as the semantic backend 4. `@superset/workspace-client` as the only host transport @@ -19,7 +19,7 @@ Match the V1 create-workspace experience exactly while keeping the V2 stack: Owns: 1. modal draft state -2. exact V1 UI +2. V1 composer UI plus host-target selection 3. picking one `WorkspaceHostTarget` 4. optimistic UI and navigation @@ -68,7 +68,7 @@ Stay thin: ## Target UX -Keep the V1 surface: +Keep the V1 surface, plus explicit host target selection: 1. single composer 2. workspace name @@ -81,9 +81,10 @@ Keep the V1 surface: 9. agent picker 10. setup toggle 11. inline compare-base/worktree picker -12. auto-open/navigate after create +12. host target selection +13. auto-open/navigate after create -Do not keep the current V2 tabbed modal or visible host picker if they change the V1 experience. +Do not keep the current V2 tabbed modal. Keep host target selection available without changing the core V1 composer flow. ## Target Host API @@ -155,11 +156,11 @@ Rules: ### Phase 1 -1. Replace the V2 modal UI with the exact V1 composer +1. Replace the V2 modal UI with the V1 composer plus explicit host target selection 2. Expand the V2 draft/store to hold full V1 state 3. Add `workspaceCreation.getContext` 4. Add `workspaceCreation.searchBranches` -5. Add semantic `workspaceCreation.create` +5. Add semantic `workspaceCreation.create` with full V1 outcome resolution (`created_workspace`, `opened_existing_workspace`, `opened_worktree`, `adopted_external_worktree`) 6. Add `workspace.getInitState` 7. Add `workspace:init:changed` @@ -167,13 +168,13 @@ Rules: 1. Move PR and issue linking behind host-service 2. Move attachments to upload refs -3. Port open/adopt worktree behavior fully -4. Remove remaining V2-only modal shell pieces +3. Remove remaining V2-only modal shell pieces ## Decisions Locked -1. Exact V1 UX wins over preserving the current V2 modal structure. +1. V1 composer UX and semantics win over preserving the current V2 modal structure. 2. Host-service is the only semantic backend boundary for modal behavior. 3. `@superset/workspace-client` is the only host transport boundary. 4. Live init/setup state should extend the unified event bus. -5. Visible host selection is not part of first-pass parity. +5. Visible host selection is intentionally part of the first-pass UX. +6. Phase 1 `workspaceCreation.create` includes full V1 create/open/adopt semantics. From a342548f8ca7fc7aa792d2f6e41d3ecd10733bf0 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 7 Apr 2026 14:59:45 -0700 Subject: [PATCH 4/9] Add UI --- .../DashboardNewWorkspaceDraftContext.tsx | 53 +- .../DashboardNewWorkspaceModal.tsx | 69 +- .../DashboardNewWorkspaceForm.tsx | 902 ++++++++++++++++-- .../BranchesGroup/BranchesGroup.tsx | 206 ---- .../components/BranchesGroup/index.ts | 1 - .../DashboardNewWorkspaceFormHeader.tsx | 52 - .../DashboardNewWorkspaceFormHeader/index.ts | 1 - .../DashboardNewWorkspaceListTabContent.tsx | 65 -- .../index.ts | 1 - .../DashboardNewWorkspacePromptTabContent.tsx | 24 - .../index.ts | 1 - .../GitHubIssueLinkCommand.tsx | 162 ++++ .../GitHubIssueLinkCommand/index.ts | 1 + .../components/IssuesGroup/IssuesGroup.tsx | 211 ---- .../components/IssuesGroup/index.ts | 1 - .../LinkedGitHubIssuePill.tsx | 59 ++ .../components/LinkedGitHubIssuePill/index.ts | 1 + .../components/LinkedPRPill/LinkedPRPill.tsx | 55 ++ .../components/LinkedPRPill/index.ts | 1 + .../PRLinkCommand/PRLinkCommand.tsx | 218 +++++ .../components/PRLinkCommand/index.ts | 1 + .../components/PromptGroup/PromptGroup.tsx | 239 ----- .../PromptGroupAdvancedOptions.tsx | 217 ----- .../PromptGroupAdvancedOptions/index.ts | 1 - .../components/PromptGroup/index.ts | 1 - .../PullRequestsGroup/PullRequestsGroup.tsx | 181 ---- .../components/PullRequestsGroup/index.ts | 1 - .../utils/resolveOpenableWorktrees/index.ts | 6 + .../resolveOpenableWorktrees.ts | 51 + .../useCreateDashboardWorkspace.ts | 65 +- packages/host-service/src/events/event-bus.ts | 8 + packages/host-service/src/events/index.ts | 1 + packages/host-service/src/events/types.ts | 7 + .../host-service/src/trpc/router/router.ts | 2 + .../trpc/router/workspace-creation/index.ts | 1 + .../workspace-creation/workspace-creation.ts | 473 +++++++++ .../src/trpc/router/workspace/workspace.ts | 21 + packages/workspace-client/src/lib/eventBus.ts | 20 +- 38 files changed, 2069 insertions(+), 1311 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/GitHubIssueLinkCommand/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedGitHubIssuePill/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedPRPill/LinkedPRPill.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedPRPill/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/PRLinkCommand.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/PromptGroupAdvancedOptions.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/utils/resolveOpenableWorktrees/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/utils/resolveOpenableWorktrees/resolveOpenableWorktrees.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/index.ts create mode 100644 packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index 1f8ad072681..87efc27f7e2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx @@ -9,50 +9,65 @@ import { } from "react"; import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -export type DashboardNewWorkspaceTab = - | "prompt" - | "issues" - | "pull-requests" - | "branches"; +export type LinkedIssue = { + slug: string; + title: string; + source?: "github" | "internal"; + url?: string; + taskId?: string; + number?: number; + state?: "open" | "closed"; +}; + +export type LinkedPR = { + prNumber: number; + title: string; + url: string; + state: string; +}; export interface DashboardNewWorkspaceDraft { - activeTab: DashboardNewWorkspaceTab; selectedProjectId: string | null; hostTarget: WorkspaceHostTarget; prompt: string; + workspaceName: string; + workspaceNameEdited: boolean; branchName: string; branchNameEdited: boolean; compareBaseBranch: string | null; showAdvanced: boolean; branchSearch: string; - issuesQuery: string; - pullRequestsQuery: string; - branchesQuery: string; + runSetupScript: boolean; + linkedIssues: LinkedIssue[]; + linkedPR: LinkedPR | null; } interface DashboardNewWorkspaceDraftState extends DashboardNewWorkspaceDraft { draftVersion: number; + resetKey: number; } const initialDraft: DashboardNewWorkspaceDraft = { - activeTab: "prompt", selectedProjectId: null, hostTarget: { kind: "local" }, prompt: "", + workspaceName: "", + workspaceNameEdited: false, branchName: "", branchNameEdited: false, compareBaseBranch: null, showAdvanced: false, branchSearch: "", - issuesQuery: "", - pullRequestsQuery: "", - branchesQuery: "", + runSetupScript: true, + linkedIssues: [], + linkedPR: null, }; function buildInitialDraftState(): DashboardNewWorkspaceDraftState { return { ...initialDraft, draftVersion: 0, + resetKey: 0, }; } @@ -69,6 +84,7 @@ interface DashboardNewWorkspaceActionOptions { interface DashboardNewWorkspaceDraftContextValue { draft: DashboardNewWorkspaceDraft; draftVersion: number; + resetKey: number; closeModal: () => void; closeAndResetDraft: () => void; runAsyncAction: ( @@ -117,6 +133,7 @@ export function DashboardNewWorkspaceDraftProvider({ setState((state) => ({ ...initialDraft, draftVersion: state.draftVersion + 1, + resetKey: state.resetKey + 1, })); }, []); @@ -148,20 +165,22 @@ export function DashboardNewWorkspaceDraftProvider({ const value = useMemo( () => ({ draft: { - activeTab: state.activeTab, selectedProjectId: state.selectedProjectId, hostTarget: state.hostTarget, prompt: state.prompt, + 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, + runSetupScript: state.runSetupScript, + linkedIssues: state.linkedIssues, + linkedPR: state.linkedPR, }, draftVersion: state.draftVersion, + resetKey: state.resetKey, closeModal: onClose, closeAndResetDraft, 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..c952aa8fc8f 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,67 @@ 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 { + 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 from a prompt, PR, branch, or issue. + + + 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 index fe5b9b3ab0c..cd8e2b5bf89 100644 --- 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 @@ -1,90 +1,868 @@ -import { useCallback } from "react"; -import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; -import { DashboardNewWorkspaceFormHeader } from "./components/DashboardNewWorkspaceFormHeader"; -import { DashboardNewWorkspaceListTabContent } from "./components/DashboardNewWorkspaceListTabContent"; -import { DashboardNewWorkspacePromptTabContent } from "./components/DashboardNewWorkspacePromptTabContent"; +import { + PromptInput, + PromptInputAttachment, + PromptInputAttachments, + PromptInputButton, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + usePromptInputAttachments, + useProviderAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Input } from "@superset/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowUpIcon, PaperclipIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { GoGitBranch, GoIssueOpened } from "react-icons/go"; +import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; +import { LuGitPullRequest } from "react-icons/lu"; +import { SiLinear } from "react-icons/si"; +import { AgentSelect } from "renderer/components/AgentSelect"; +import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; +import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; +import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; +import { PLATFORM } from "renderer/hotkeys"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { + useClearPendingWorkspace, + useNewWorkspaceModalOpen, + useSetPendingWorkspace, + useSetPendingWorkspaceStatus, +} from "renderer/stores/new-workspace-modal"; +import { + type AgentDefinitionId, + getEnabledAgentConfigs, +} from "shared/utils/agent-settings"; +import { sanitizeBranchNameWithMaxLength } from "shared/utils/branch"; +import { + type LinkedPR, + useDashboardNewWorkspaceDraft, +} from "../../DashboardNewWorkspaceDraftContext"; +import { useCreateDashboardWorkspace } from "../../hooks/useCreateDashboardWorkspace"; +import { DevicePicker } from "./components/DevicePicker"; +import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; +import { LinkedGitHubIssuePill } from "./components/LinkedGitHubIssuePill"; +import { LinkedPRPill } from "./components/LinkedPRPill"; +import { PRLinkCommand } from "./components/PRLinkCommand"; +import { ProjectSelector } from "./components/ProjectSelector"; import { useDashboardNewWorkspaceProjectSelection } from "./hooks/useDashboardNewWorkspaceProjectSelection"; import { useResolvedLocalProject } from "./hooks/useResolvedLocalProject"; +type WorkspaceCreateAgent = AgentDefinitionId | "none"; + +const AGENT_STORAGE_KEY = "lastSelectedWorkspaceCreateAgent"; + +const PILL_BUTTON_CLASS = + "!h-[22px] min-h-0 rounded-md border-[0.5px] border-border bg-foreground/[0.04] shadow-none text-[11px]"; + +type ConvertedFile = { + data: string; + mediaType: string; + filename?: string; +}; + +// ── Attachment Buttons ──────────────────────────────────────────────── + +function AttachmentButtons({ + anchorRef, + onOpenIssueLink, + onOpenGitHubIssue, + onOpenPRLink, +}: { + anchorRef: React.RefObject; + onOpenIssueLink: () => void; + onOpenGitHubIssue: () => void; + onOpenPRLink: () => void; +}) { + const attachments = usePromptInputAttachments(); + + return ( +
+ + + attachments.openFileDialog()} + > + + + + Add attachment + + + + + + + + Link issue + + + + + + + + Link GitHub issue + + + + + + + + Link pull request + +
+ ); +} + +// ── Compare Base Branch Picker ──────────────────────────────────────── + +function CompareBaseBranchPicker({ + effectiveCompareBaseBranch, + defaultBranch, + isBranchesLoading, + isBranchesError, + branches, + onSelectCompareBaseBranch, +}: { + effectiveCompareBaseBranch: string | null; + defaultBranch?: string; + isBranchesLoading: boolean; + isBranchesError: boolean; + branches: Array<{ name: string; lastCommitDate: number; isLocal: boolean }>; + onSelectCompareBaseBranch: (branchName: string) => void; +}) { + const [open, setOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); + + const filteredBranches = useMemo(() => { + if (!branches.length) return []; + if (!branchSearch) return branches; + const searchLower = branchSearch.toLowerCase(); + return branches.filter((branch) => + branch.name.toLowerCase().includes(searchLower), + ); + }, [branches, branchSearch]); + + 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="flex items-center justify-between" + > + + + + {branch.name} + + {branch.name === defaultBranch && ( + + default + + )} + + {effectiveCompareBaseBranch === branch.name && ( + + )} + + ))} + + + + + ); +} + +// ── Main Form ───────────────────────────────────────────────────────── + 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 navigate = useNavigate(); + const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; + const isNewWorkspaceModalOpen = useNewWorkspaceModalOpen(); + const { closeAndResetDraft, closeModal, draft, runAsyncAction, updateDraft } = + useDashboardNewWorkspaceDraft(); + const attachments = useProviderAttachments(); + const clearPendingWorkspace = useClearPendingWorkspace(); + const setPendingWorkspace = useSetPendingWorkspace(); + const setPendingWorkspaceStatus = useSetPendingWorkspaceStatus(); + + const { + compareBaseBranch, + prompt, + runSetupScript, + workspaceName, + workspaceNameEdited, + branchName, + branchNameEdited, + linkedIssues, + linkedPR, + hostTarget, + } = draft; + + // ── Project selection ──────────────────────────────────────────── const handleSelectProject = useCallback( (selectedProjectId: string | null) => { updateDraft({ selectedProjectId }); }, [updateDraft], ); - const { githubRepository, githubRepositoryId } = - useDashboardNewWorkspaceProjectSelection({ - isOpen, - preSelectedProjectId, - selectedProjectId: draft.selectedProjectId, - onSelectProject: handleSelectProject, - }); + const { githubRepository } = 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; + // ── Agent presets ──────────────────────────────────────────────── + const agentPresetsQuery = electronTrpc.settings.getAgentPresets.useQuery(); + const agentPresets = agentPresetsQuery.data ?? []; + const enabledAgentPresets = useMemo( + () => getEnabledAgentConfigs(agentPresets), + [agentPresets], + ); + const selectableAgentIds = useMemo( + () => enabledAgentPresets.map((preset) => preset.id), + [enabledAgentPresets], + ); + const { selectedAgent, setSelectedAgent } = + useAgentLaunchPreferences({ + agentStorageKey: AGENT_STORAGE_KEY, + defaultAgent: "claude", + fallbackAgent: "none", + validAgents: ["none", ...selectableAgentIds], + agentsReady: agentPresetsQuery.isFetched, + }); + + // ── Branch data (via local project electronTrpc for now) ───────── + const hasLocalProject = !!resolvedLocalProjectId; + + const { data: project } = electronTrpc.projects.get.useQuery( + { id: resolvedLocalProjectId ?? "" }, + { enabled: hasLocalProject }, + ); + + const { + data: localBranchData, + isLoading: isLocalBranchesLoading, + isError: isBranchesError, + } = electronTrpc.projects.getBranchesLocal.useQuery( + { projectId: resolvedLocalProjectId ?? "" }, + { enabled: hasLocalProject }, + ); + const { data: remoteBranchData } = electronTrpc.projects.getBranches.useQuery( + { projectId: resolvedLocalProjectId ?? "" }, + { enabled: hasLocalProject }, + ); + const branchData = remoteBranchData ?? localBranchData; + const isBranchesLoading = isLocalBranchesLoading && !branchData; + + const effectiveCompareBaseBranch = useMemo(() => { + if (compareBaseBranch) return compareBaseBranch; + if (project?.workspaceBaseBranch) return project.workspaceBaseBranch; + return branchData?.defaultBranch ?? null; + }, [ + compareBaseBranch, + project?.workspaceBaseBranch, + branchData?.defaultBranch, + ]); + + // ── Link state ─────────────────────────────────────────────────── + const [issueLinkOpen, setIssueLinkOpen] = useState(false); + const [gitHubIssueLinkOpen, setGitHubIssueLinkOpen] = useState(false); + const [prLinkOpen, setPRLinkOpen] = useState(false); + const plusMenuRef = useRef(null); + const submitStartedRef = useRef(false); + const trimmedPrompt = prompt.trim(); + + // ── AI branch name ─────────────────────────────────────────────── + const generateBranchNameMutation = + electronTrpc.workspaces.generateBranchName.useMutation(); + + useEffect(() => { + if (isNewWorkspaceModalOpen) { + submitStartedRef.current = false; } + }, [isNewWorkspaceModalOpen]); + + const previousProjectIdRef = useRef(draft.selectedProjectId); + useEffect(() => { + if (previousProjectIdRef.current === draft.selectedProjectId) return; + previousProjectIdRef.current = draft.selectedProjectId; + updateDraft({ compareBaseBranch: null }); + }, [draft.selectedProjectId, updateDraft]); + + // ── Create workspace ───────────────────────────────────────────── + const { createWorkspace } = useCreateDashboardWorkspace(); + + const convertBlobUrlToDataUrl = useCallback( + async (url: string): Promise => { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch attachment: ${response.statusText}`); + } + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => + reject(new Error("Failed to read attachment data")); + reader.onabort = () => reject(new Error("Attachment read was aborted")); + reader.readAsDataURL(blob); + }); + }, + [], + ); + + const handleCreate = useCallback(async () => { + if (!draft.selectedProjectId) { + toast.error("Select a project first"); + return; + } + + if (submitStartedRef.current) return; + submitStartedRef.current = true; + + const displayName = + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : trimmedPrompt || "New workspace"; + const willGenerateAIName = + !branchNameEdited && !!trimmedPrompt && !linkedPR; + const pendingWorkspaceId = crypto.randomUUID(); + const detachedFiles = attachments.takeFiles(); + + setPendingWorkspace({ + id: pendingWorkspaceId, + projectId: draft.selectedProjectId, + name: displayName, + status: willGenerateAIName ? "generating-branch" : "preparing", + }); + closeAndResetDraft(); + + try { + // AI branch name generation + let aiBranchName: string | null = null; + if (willGenerateAIName) { + try { + const result = await Promise.race([ + generateBranchNameMutation.mutateAsync({ + prompt: trimmedPrompt, + projectId: draft.selectedProjectId, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), 30000), + ), + ]); + aiBranchName = result.branchName; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if ( + msg.includes("auth") || + msg.includes("401") || + msg.includes("403") + ) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error("AI authentication failed."); + return; + } + toast.info("Using random branch name"); + } finally { + setPendingWorkspaceStatus(pendingWorkspaceId, "preparing"); + } + } + + // Convert attachments + let convertedFiles: ConvertedFile[] = []; + if (detachedFiles.length > 0) { + try { + convertedFiles = await Promise.all( + detachedFiles.map(async (file) => ({ + data: await convertBlobUrlToDataUrl(file.url), + mediaType: file.mediaType, + filename: file.filename, + })), + ); + } catch (err) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error( + err instanceof Error + ? err.message + : "Failed to process attachments", + ); + return; + } + } + + setPendingWorkspaceStatus(pendingWorkspaceId, "creating"); + + const resolvedBranchName = + (branchNameEdited && branchName.trim() + ? sanitizeBranchNameWithMaxLength(branchName.trim(), undefined, { + preserveCase: true, + }) + : aiBranchName) || undefined; + + const source = linkedPR ? ("pull-request" as const) : ("prompt" as const); + + void runAsyncAction( + createWorkspace({ + projectId: draft.selectedProjectId, + hostTarget, + source, + names: { + workspaceName: + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : undefined, + branchName: resolvedBranchName, + }, + composer: { + prompt: trimmedPrompt || undefined, + compareBaseBranch: compareBaseBranch || undefined, + runSetupScript, + }, + linkedContext: { + linkedPrUrl: linkedPR?.url, + attachments: convertedFiles.length > 0 ? convertedFiles : undefined, + }, + }).then((result) => { + if (result.workspace) { + navigateToV2Workspace(result.workspace.id, navigate); + } + return result; + }), + { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + { closeAndReset: false }, + ).finally(() => { + clearPendingWorkspace(pendingWorkspaceId); + }); + } finally { + for (const file of detachedFiles) { + if (file.url?.startsWith("blob:")) { + URL.revokeObjectURL(file.url); + } + } + } + }, [ + attachments, + branchName, + branchNameEdited, + clearPendingWorkspace, + closeAndResetDraft, + compareBaseBranch, + convertBlobUrlToDataUrl, + createWorkspace, + draft.selectedProjectId, + generateBranchNameMutation, + hostTarget, + linkedPR, + navigate, + runAsyncAction, + runSetupScript, + setPendingWorkspace, + setPendingWorkspaceStatus, + trimmedPrompt, + workspaceName, + workspaceNameEdited, + ]); + + const handlePromptSubmit = useCallback(() => { + void handleCreate(); + }, [handleCreate]); + + useEffect(() => { + if (!isNewWorkspaceModalOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + void handleCreate(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isNewWorkspaceModalOpen, handleCreate]); + + // ── Issue / PR linking helpers ─────────────────────────────────── + + const addLinkedIssue = ( + slug: string, + title: string, + taskId: string | undefined, + url?: string, + ) => { + if (linkedIssues.some((issue) => issue.slug === slug)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { slug, title, source: "internal", taskId, url }, + ], + }); }; + const addLinkedGitHubIssue = ( + issueNumber: number, + title: string, + url: string, + state: string, + ) => { + const normalizedState: "open" | "closed" = + state.toLowerCase() === "closed" ? "closed" : "open"; + const issue = { + slug: `#${issueNumber}`, + title, + source: "github" as const, + url, + number: issueNumber, + state: normalizedState, + }; + if (linkedIssues.some((i) => i.url === url)) return; + updateDraft({ linkedIssues: [...linkedIssues, issue] }); + }; + + const removeLinkedIssue = (slug: string) => { + updateDraft({ + linkedIssues: linkedIssues.filter((issue) => issue.slug !== slug), + }); + }; + + const setLinkedPR = (pr: LinkedPR) => { + updateDraft({ linkedPR: pr }); + }; + + const removeLinkedPR = () => { + updateDraft({ linkedPR: null }); + }; + + // ── Render ──────────────────────────────────────────────────────── + return ( - <> - updateDraft({ activeTab })} - onSelectHostTarget={(hostTarget) => updateDraft({ hostTarget })} - onSelectProject={handleSelectProject} - /> - - {isListTab ? ( - + {/* Workspace name + branch name header */} +
+ + updateDraft({ + workspaceName: e.target.value, + workspaceNameEdited: true, + }) + } + onBlur={() => { + if (!workspaceName.trim()) { + updateDraft({ workspaceName: "", workspaceNameEdited: false }); + } + }} /> - ) : ( - + + updateDraft({ + branchName: e.target.value.replace(/\s+/g, "-"), + branchNameEdited: true, + }) + } + onBlur={() => { + const sanitized = sanitizeBranchNameWithMaxLength( + branchName.trim(), + undefined, + { preserveCase: true }, + ); + if (!sanitized) { + updateDraft({ branchName: "", branchNameEdited: false }); + } else { + updateDraft({ branchName: sanitized }); + } + }} + /> +
+ + + {/* Rich prompt input */} + + {(linkedPR || + linkedIssues.length > 0 || + attachments.files.length > 0) && ( +
+ + {linkedPR && ( + + + + )} + {linkedIssues.map((issue) => ( + + {issue.source === "github" ? ( + 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={resolvedLocalProjectId} + anchorRef={plusMenuRef} + /> + + { + e.preventDefault(); + void handleCreate(); + }} + > + + +
+
+
+ + {/* Bottom bar: project, branch, host target, shortcut hint */} +
+
+ updateDraft({ selectedProjectId: id })} + /> + + {linkedPR ? ( + + + based off PR #{linkedPR.prNumber} + + ) : hasLocalProject ? ( + + + updateDraft({ compareBaseBranch: branch }) + } + /> + + ) : null} + +
+
+ updateDraft({ hostTarget })} + /> + + {modKey}↵ + +
+
+ ); } 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 49fca2ee850..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 type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -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 d9d45c15bee..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/lib/v2-workspace-host"; -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 d6584e9ecc9..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/lib/v2-workspace-host"; -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 b1ff2609f7b..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/lib/v2-workspace-host"; -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/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx new file mode 100644 index 00000000000..1f0415a8de6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -0,0 +1,162 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import Fuse from "fuse.js"; +import type React from "react"; +import type { RefObject } from "react"; +import { useMemo, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; + +const MAX_RESULTS = 20; + +// Normalize issue state to valid IssueState type +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; + anchorRef: RefObject; +} + +export function GitHubIssueLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + anchorRef, +}: GitHubIssueLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + + const { data: issues, isLoading } = electronTrpc.projects.listIssues.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId && open }, + ); + + const issuesWithSearchField = useMemo( + () => + (issues ?? []).map((issue) => ({ + ...issue, + issueNumberStr: String(issue.issueNumber), + })), + [issues], + ); + + const issueFuse = useMemo( + () => + new Fuse(issuesWithSearchField, { + keys: [ + { name: "issueNumberStr", weight: 3 }, + { name: "title", weight: 2 }, + ], + threshold: 0.4, + ignoreLocation: true, + }), + [issuesWithSearchField], + ); + + const searchResults = useMemo(() => { + if (!issuesWithSearchField.length) return []; + if (!searchQuery) { + return issuesWithSearchField.slice(0, MAX_RESULTS); + } + const trimmedQuery = searchQuery.trim(); + const urlMatch = issuesWithSearchField.find( + (issue) => issue.url === trimmedQuery, + ); + if (urlMatch) return [urlMatch]; + return issueFuse + .search(trimmedQuery, { limit: MAX_RESULTS }) + .map((r) => r.item); + }, [issuesWithSearchField, searchQuery, issueFuse]); + + 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/components/GitHubIssueLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/GitHubIssueLinkCommand/index.ts new file mode 100644 index 00000000000..c7d5f8cdb50 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/GitHubIssueLinkCommand/index.ts @@ -0,0 +1 @@ +export { GitHubIssueLinkCommand } from "./GitHubIssueLinkCommand"; 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 f685a4b950d..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 type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -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 { 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/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx new file mode 100644 index 00000000000..75ecb4b52d2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/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/components/LinkedGitHubIssuePill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedGitHubIssuePill/index.ts new file mode 100644 index 00000000000..fe1657259a6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedGitHubIssuePill/index.ts @@ -0,0 +1 @@ +export { LinkedGitHubIssuePill } from "./LinkedGitHubIssuePill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedPRPill/LinkedPRPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedPRPill/LinkedPRPill.tsx new file mode 100644 index 00000000000..9e2c4b35720 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/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/components/LinkedPRPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedPRPill/index.ts new file mode 100644 index 00000000000..1042cfae4d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/LinkedPRPill/index.ts @@ -0,0 +1 @@ +export { LinkedPRPill } from "./LinkedPRPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/PRLinkCommand.tsx new file mode 100644 index 00000000000..2ff524cf715 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/PRLinkCommand.tsx @@ -0,0 +1,218 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import type React from "react"; +import type { RefObject } from "react"; +import { useMemo, useState } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; + +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; + githubOwner: string | null; + repoName: string | null; + anchorRef: RefObject; +} + +function parseGitHubPullRequestUrl(query: string): { + owner: string; + repo: string; + prNumber: string; +} | null { + const match = query.match( + /^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i, + ); + + if (!match) return null; + + return { + owner: match[1], + repo: match[2], + prNumber: match[3], + }; +} + +export function PRLinkCommand({ + open, + onOpenChange, + onSelect, + projectId, + githubOwner, + repoName, + anchorRef, +}: PRLinkCommandProps) { + const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const trimmedQuery = searchQuery.trim(); // Immediate trim for UI decisions + const debouncedTrimmed = debouncedQuery.trim(); // Debounced trim for RPC calls + + // Detect if we're in the pending debounce state + const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + + const parsedPullRequestUrl = useMemo(() => { + return parseGitHubPullRequestUrl(debouncedTrimmed); + }, [debouncedTrimmed]); + + const selectedRepositoryLabel = useMemo(() => { + if (!githubOwner || !repoName) return null; + return `${githubOwner}/${repoName}`; + }, [githubOwner, repoName]); + + const pastedRepository = useMemo(() => { + if (!parsedPullRequestUrl) return null; + return `${parsedPullRequestUrl.owner}/${parsedPullRequestUrl.repo}`.toLowerCase(); + }, [parsedPullRequestUrl]); + + const isCrossRepositoryUrl = Boolean( + selectedRepositoryLabel && + pastedRepository && + pastedRepository !== selectedRepositoryLabel.toLowerCase(), + ); + + // Search by PR number when the pasted URL matches the selected repository. + const effectiveQuery = parsedPullRequestUrl + ? isCrossRepositoryUrl + ? "" + : parsedPullRequestUrl.prNumber + : debouncedTrimmed; + + // Fetch recent PRs for browsing (only when no search query) + const { data: recentPRs, isLoading: isLoadingRecent } = + electronTrpc.projects.listPullRequests.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId && open && !debouncedTrimmed }, + ); + + // Server-side search when user types (use debounced for RPC) + const { data: searchResults, isLoading: isSearching } = + electronTrpc.projects.searchPullRequests.useQuery( + { projectId: projectId ?? "", query: effectiveQuery }, + { + enabled: + !!projectId && open && !!effectiveQuery && !isCrossRepositoryUrl, + }, + ); + + const pullRequests = useMemo(() => { + if (isCrossRepositoryUrl) { + return []; + } + + // Use debounced value for mode decision to avoid empty gap + if (debouncedTrimmed) { + return searchResults ?? []; + } + return recentPRs ?? []; + }, [debouncedTrimmed, isCrossRepositoryUrl, searchResults, recentPRs]); + + const isLoading = isCrossRepositoryUrl + ? false + : debouncedTrimmed + ? isSearching || isPendingDebounce + : isLoadingRecent; + + const handleClose = () => { + setSearchQuery(""); + onOpenChange(false); + }; + + const handleSelect = (pr: (typeof pullRequests)[number]) => { + onSelect({ + prNumber: pr.prNumber, + title: pr.title, + url: pr.url, + state: pr.state, + }); + handleClose(); + }; + + return ( + + } /> + event.stopPropagation()} + onPointerDownOutside={handleClose} + onEscapeKeyDown={handleClose} + onFocusOutside={(e) => e.preventDefault()} + > + + + + {pullRequests.length === 0 && ( + + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading pull requests..." + : isCrossRepositoryUrl + ? `PR URL must match ${selectedRepositoryLabel}.` + : 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/components/PRLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/index.ts new file mode 100644 index 00000000000..ba614340e89 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PRLinkCommand/index.ts @@ -0,0 +1 @@ +export { PRLinkCommand } from "./PRLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx deleted file mode 100644 index f63b138f69d..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 type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { resolveEffectiveWorkspaceBaseBranch } from "renderer/lib/workspaceBaseBranch"; -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 ( -
-