Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions apps/desktop/plans/20260405-1945-v1-workspace-ux-into-v2-modal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# V1 Create Workspace Port On V2 Hosts

This doc replaces the earlier split plan and API draft.

## Goal

Match the V1 create-workspace experience on the V2 stack, with one intentional addition: explicit host-target selection.

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
5. the unified `/events` bus as the live-state channel

## Boundaries

### Renderer

Owns:

1. modal draft state
2. V1 composer UI plus host-target selection
3. picking one `WorkspaceHostTarget`
4. optimistic UI and navigation

Does not own:

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`

Owns:

1. one tRPC client per host URL
2. one `/events` connection per host URL
3. auth, reconnect, ref-counting, subscriptions

Does not own:

1. create semantics
2. repo/worktree logic

### Host-service

Owns:

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

### Cloud/shared APIs

Stay thin:

1. hosts
2. workspace rows
3. project metadata
4. shared PR/issue/task data if proxied by host-service

## Target UX

Keep the V1 surface, plus explicit host target selection:

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. host target selection
13. auto-open/navigate after create

Do not keep the current V2 tabbed modal. Keep host target selection available without changing the core V1 composer flow.

## Target Host API

```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(...)

workspace.get({ id })
workspace.gitStatus({ id })
workspace.delete({ id })
```

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:

1. outcome: `created_workspace | opened_existing_workspace | opened_worktree | adopted_external_worktree`
2. workspace row
3. warnings

The call blocks until the worktree and cloud row are fully created. The renderer awaits it and shows a loading state via the pending-workspace store. No event bus extension or init-state polling needed — worktree creation is fast (<60s) and setup-script progress is visible once the workspace opens.

## Event Bus

Use the existing host `/events` bus. No new event types for Phase 1.

Keep:

1. `git:changed`
2. `fs:events`

## Phases

### Phase 1

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` with full V1 outcome resolution (`created_workspace`, `opened_existing_workspace`, `opened_worktree`, `adopted_external_worktree`)

### Phase 2

1. Move PR and issue linking behind host-service
2. Move attachments to upload refs
3. Remove remaining V2-only modal shell pieces

## Decisions Locked

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. Create blocks until done; renderer shows loading via pending-workspace store. No event bus extension needed for Phase 1.
5. Visible host selection is intentionally part of the first-pass UX.
6. Phase 1 `workspaceCreation.create` includes full V1 create/open/adopt semantics.
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,65 @@ import {
} from "react";
import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker";

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,
};
}

Expand All @@ -69,6 +84,7 @@ interface DashboardNewWorkspaceActionOptions {
interface DashboardNewWorkspaceDraftContextValue {
draft: DashboardNewWorkspaceDraft;
draftVersion: number;
resetKey: number;
closeModal: () => void;
closeAndResetDraft: () => void;
runAsyncAction: <T>(
Expand Down Expand Up @@ -117,6 +133,7 @@ export function DashboardNewWorkspaceDraftProvider({
setState((state) => ({
...initialDraft,
draftVersion: state.draftVersion + 1,
resetKey: state.resetKey + 1,
}));
}, []);

Expand Down Expand Up @@ -148,20 +165,22 @@ export function DashboardNewWorkspaceDraftProvider({
const value = useMemo<DashboardNewWorkspaceDraftContextValue>(
() => ({
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,42 +1,75 @@
import {
PromptInputProvider,
usePromptInputController,
} from "@superset/ui/ai-elements/prompt-input";
import {
Dialog,
DialogContent,
DialogDescription,
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 (
<DashboardNewWorkspaceDraftProvider onClose={closeModal}>
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogHeader className="sr-only">
<DialogTitle>New Workspace</DialogTitle>
<DialogDescription>
Create a new workspace from a PR, branch, issue, or prompt.
</DialogDescription>
</DialogHeader>
<DialogContent
showCloseButton={false}
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"
>
<DashboardNewWorkspaceForm
isOpen={isOpen}
preSelectedProjectId={preSelectedProjectId}
/>
</DialogContent>
</Dialog>
<PromptInputProvider>
<PromptInputResetSync />
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogHeader className="sr-only">
<DialogTitle>New Workspace</DialogTitle>
<DialogDescription>
Create a new workspace from a prompt, PR, branch, or issue.
</DialogDescription>
</DialogHeader>
<DialogContent
showCloseButton={false}
onFocusOutside={(e) => 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"
>
<DashboardNewWorkspaceForm
isOpen={isOpen}
preSelectedProjectId={preSelectedProjectId}
/>
</DialogContent>
</Dialog>
</PromptInputProvider>
</DashboardNewWorkspaceDraftProvider>
);
}
Loading
Loading