Skip to content
Merged
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
123 changes: 123 additions & 0 deletions apps/desktop/plans/20260421-v2-main-workspace-creation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# V2 Main Workspace Creation

## Problem

V1 auto-creates a singleton `type='branch'` workspace per project via
`ensureMainWorkspace` (`apps/desktop/src/lib/trpc/routers/projects/projects.ts:174`),
called inline from five mutations. V2 has no equivalent — `v2_workspaces` rows only
come from `workspaceCreation`, which always produces worktrees. Users finish
`project.setup` and see an empty sidebar.

## Goals

- Each `(projectId, hostId)` gets one "main" workspace whose path == the host's
`repoPath`. Multiple mains per project (one per host) are allowed.
- Created automatically on project create/setup success — no type picker, no UI
step.
- Main workspaces are real v2 workspace rows but are not deletable through normal
workspace delete flows. Users can remove them from the sidebar; host/project
removal owns deleting the cloud row.

## Design

### Schema

Add to `v2_workspaces` (`packages/db/src/schema/schema.ts:524`):

```ts
type: v2WorkspaceType().notNull().default("worktree"),
```

Backed by a `pgEnum("v2_workspace_type", ["main", "worktree"])` for DB-level
enforcement, matching the `v2ClientType` / `v2UsersHostRole` precedent.
Partial unique index: `(projectId, hostId) WHERE type = 'main'`.

Column name `type` over `isMain: boolean` so the workspace-creation modal's
contract is explicit — it only ever writes `'worktree'`.

No `pinnedAt`/pinning column. Sidebar membership remains renderer-local in
`v2WorkspaceLocalState`, and main workspace auto-visibility is derived from
`type='main'`, current host, and project sidebar membership.

### `ensureMainWorkspace` helper (host-service)

New helper in `packages/host-service/src/trpc/router/project/`. Given
`(projectId, repoPath)`:

1. `ensureV2Host` (reuse call from `workspace-creation.ts:372`).
2. Resolve current branch: `git symbolic-ref --short HEAD` at `repoPath`.
3. `ctx.api.v2Workspace.create.mutate({ ..., type: "main", branch, name: branch })`.
Skip if the unique index rejects — idempotent.
4. Insert local `workspaces` row (`packages/host-service/src/db/schema.ts:95`)
with `worktreePath = repoPath`. The column is named `worktreePath` but holds
any absolute checkout path; for main that's the repo root.

Log-and-continue on failure: any cloud/local error is caught, logged, and
swallowed (the helper returns `null`). `project.setup` doesn't regress when a
transient cloud blip hits — the startup sweep backfills on the next boot.
Idempotency via the partial unique index handles duplicates on retry.

If a main row already exists and the repo root branch changed, cloud branch
metadata is refreshed. The display name only follows the branch while it still
equals the previous branch name; user-renamed main workspaces keep their label.

### Call sites

1. **`project.create` success** — after cloning/importing a new cloud project and
persisting the local project row.
2. **`project.setup` success** (`packages/host-service/src/trpc/router/project/project.ts:134`) —
after `persistLocalProject` in both `clone` and `import` branches.
3. **Workspace creation/adoption/checkout preflight** — before creating or
adopting a worktree, call the helper for the local project. This keeps normal
workspace flows self-healing if setup returned before main creation completed.
4. **Host-service startup sweep** — on boot, iterate local `projects` rows and
call the helper for each. Idempotent via the unique index, so it's safe on
every boot; in practice only does work once per pre-existing project. This is
the recovery path for projects already set up before this change ships.

### Sidebar and modal

Workspace-creation modal continues to write worktree workspaces only. Project
setup/create responses include `mainWorkspaceId` when the helper succeeds, so the
renderer can add the main workspace to the sidebar immediately.

Main workspaces are auto-visible when:

- the workspace is `type='main'`
- the host is the current machine
- the project is in the sidebar
- no renderer-local hidden tombstone exists for that workspace

Removing a main workspace from the sidebar sets
`v2WorkspaceLocalState.sidebarState.isHidden = true` instead of deleting the local
state row. This prevents auto-visibility from immediately re-adding the row and
keeps hidden-row filtering centralized through the dashboard sidebar local-state
visibility helper.

### Delete behavior

`v2Workspace.delete` rejects `type='main'` rows. Regular workspace cleanup/delete
flows are worktree-only. Host project removal uses the explicit
`v2Workspace.deleteMainForHost` endpoint for the repo-root main row, then removes
the local project row. Cloud project deletion still cascades through the database
like any other project-level delete.

## Migration

`bunx drizzle-kit generate --name="v2_workspaces_main_type"`. No SQL backfill —
the cloud doesn't know `repoPath` or current branch. Existing setups are filled
in by the startup sweep the first time the updated host-service boots.

## Rollout

Cloud/API before desktop (per deploy ordering). Verify:

- Fresh `project.setup` creates exactly one main row + local row.
- Re-running `project.setup` on the same host is idempotent.
- Two hosts on one project each get their own main row.
- Startup sweep fills in a main row for a project set up pre-update, without
duplicating on subsequent boots.
- `project.remove` cleans up the main row alongside worktrees.
- Normal workspace delete and cleanup flows reject main workspaces.
- Removing a main workspace from the sidebar hides it and does not re-add it on
the next sidebar recompute.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ type NewProjectMode = "clone" | "empty" | "template";
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: (result: { projectId: string; repoPath: string }) => void;
onSuccess?: (result: {
projectId: string;
repoPath: string;
mainWorkspaceId: string | null;
}) => void;
onError?: (message: string) => void;
}

Expand Down Expand Up @@ -72,7 +76,8 @@ export function NewProjectModal({
onError,
}: NewProjectModalProps) {
const { activeHostUrl } = useLocalHostService();
const { ensureProjectInSidebar } = useDashboardSidebarState();
const { ensureProjectInSidebar, ensureWorkspaceInSidebar } =
useDashboardSidebarState();
const selectDirectory = electronTrpc.window.selectDirectory.useMutation();
const { data: homeDir } = electronTrpc.window.getHomeDir.useQuery();

Expand Down Expand Up @@ -142,7 +147,11 @@ export function NewProjectModal({
name,
mode: { kind: "clone", parentDir: trimmedParent, url: trimmedUrl },
});
ensureProjectInSidebar(result.projectId);
if (result.mainWorkspaceId) {
ensureWorkspaceInSidebar(result.mainWorkspaceId, result.projectId);
} else {
ensureProjectInSidebar(result.projectId);
}
onSuccess?.(result);
reset();
onOpenChange(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { getBaseName } from "renderer/lib/pathBasename";
import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState";
import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider";

interface ProjectSetupResult {
projectId: string;
repoPath: string;
mainWorkspaceId: string | null;
}

export interface UseFolderFirstImportResult {
start: () => Promise<void>;
}
Expand All @@ -15,21 +21,26 @@ interface MatchingProject {
}

export function useFolderFirstImport(options?: {
onSuccess?: (result: { projectId: string; repoPath: string }) => void;
onSuccess?: (result: ProjectSetupResult) => void;
onError?: (message: string) => void;
onMultipleProjects?: (input: { candidates: MatchingProject[] }) => void;
}): UseFolderFirstImportResult {
const { activeHostUrl } = useLocalHostService();
const { ensureProjectInSidebar } = useDashboardSidebarState();
const { ensureProjectInSidebar, ensureWorkspaceInSidebar } =
useDashboardSidebarState();
const selectDirectory = electronTrpc.window.selectDirectory.useMutation();
const { onError, onMultipleProjects, onSuccess } = options ?? {};

const reportSuccess = useCallback(
(result: { projectId: string; repoPath: string }) => {
ensureProjectInSidebar(result.projectId);
(result: ProjectSetupResult) => {
if (result.mainWorkspaceId) {
ensureWorkspaceInSidebar(result.mainWorkspaceId, result.projectId);
} else {
ensureProjectInSidebar(result.projectId);
}
onSuccess?.(result);
},
[ensureProjectInSidebar, onSuccess],
[ensureProjectInSidebar, ensureWorkspaceInSidebar, onSuccess],
);

const reportError = useCallback(
Expand Down Expand Up @@ -85,7 +96,11 @@ export function useFolderFirstImport(options?: {
projectId: only.id,
mode: { kind: "import", repoPath },
});
reportSuccess({ projectId: only.id, repoPath: result.repoPath });
reportSuccess({
projectId: only.id,
repoPath: result.repoPath,
mainWorkspaceId: result.mainWorkspaceId,
});
} else {
const result = await client.project.create.mutate({
name: getBaseName(repoPath),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function DashboardSidebarWorkspaceItem({
branch,
creationStatus,
} = workspace;
const isMainWorkspace = workspace.type === "main";
const diffStats = useDiffStats(id);
const workspaceStatus = useV2WorkspaceNotificationStatus(id);
const {
Expand Down Expand Up @@ -62,6 +63,7 @@ export function DashboardSidebarWorkspaceItem({
projectId,
workspaceName: name,
branch,
isMainWorkspace,
});

const navigate = useNavigate();
Expand Down Expand Up @@ -132,15 +134,17 @@ export function DashboardSidebarWorkspaceItem({
onCopyBranchName={handleCopyBranchName}
onRemoveFromSidebar={handleRemoveFromSidebar}
onRename={startRename}
onDelete={() => setIsDeleteDialogOpen(true)}
onDelete={
isMainWorkspace ? undefined : () => setIsDeleteDialogOpen(true)
}
onToggleUnread={handleToggleUnread}
>
{content}
</DashboardSidebarWorkspaceContextMenu>
)}
</div>

{!isPending && (
{!isPending && !isMainWorkspace && (
<DashboardSidebarDeleteDialog
workspaceId={id}
workspaceName={name || branch}
Expand All @@ -164,7 +168,11 @@ export function DashboardSidebarWorkspaceItem({
workspaceStatus={workspaceStatus}
onClick={isPending ? handlePendingClick : handleClick}
onDoubleClick={isPending ? undefined : startRename}
onDeleteClick={() => setIsDeleteDialogOpen(true)}
onDeleteClick={
isMainWorkspace
? handleRemoveFromSidebar
: () => setIsDeleteDialogOpen(true)
}
onRenameValueChange={setRenameValue}
onSubmitRename={submitRename}
onCancelRename={cancelRename}
Expand Down Expand Up @@ -200,15 +208,17 @@ export function DashboardSidebarWorkspaceItem({
onCopyBranchName={handleCopyBranchName}
onRemoveFromSidebar={handleRemoveFromSidebar}
onRename={startRename}
onDelete={() => setIsDeleteDialogOpen(true)}
onDelete={
isMainWorkspace ? undefined : () => setIsDeleteDialogOpen(true)
}
onToggleUnread={handleToggleUnread}
>
{expandedContent}
</DashboardSidebarWorkspaceContextMenu>
)}
</div>

{!isPending && (
{!isPending && !isMainWorkspace && (
<DashboardSidebarDeleteDialog
workspaceId={id}
workspaceName={name || branch}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
() => getCreationStatusText(creationStatus),
[creationStatus],
);
const isMainWorkspace = workspace.type === "main";
const workspaceKindTitle = isMainWorkspace
? "Main workspace"
: "Worktree workspace";
const workspaceKindDescription = isMainWorkspace
? "Uses the repository checkout on this host"
: "Isolated copy for parallel development";
const closeLabel = isMainWorkspace
? "Remove from sidebar"
: "Close workspace";

return (
// biome-ignore lint/a11y/noStaticElementInteractions: Mirrors the legacy sidebar row UI, which includes nested action buttons.
Expand Down Expand Up @@ -192,22 +202,26 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
) : (
<>
<p className="text-xs font-medium">
{hostType === "local-device"
? "Local workspace"
: hostType === "remote-device"
? hostIsOnline === false
? "Remote workspace — device offline"
: "Remote workspace"
: "Cloud workspace"}
{isMainWorkspace
? workspaceKindTitle
: hostType === "local-device"
? "Local workspace"
: hostType === "remote-device"
? hostIsOnline === false
? "Remote workspace — device offline"
: "Remote workspace"
: "Cloud workspace"}
</p>
<p className="text-xs text-muted-foreground">
{hostType === "local-device"
? "Running on this device"
: hostType === "remote-device"
? hostIsOnline === false
? "The associated device isn't reachable right now"
: "Running on a paired device"
: "Hosted in the cloud"}
{isMainWorkspace
? workspaceKindDescription
: hostType === "local-device"
? "Running on this device"
: hostType === "remote-device"
? hostIsOnline === false
? "The associated device isn't reachable right now"
: "Running on a paired device"
: "Hosted in the cloud"}
</p>
</>
)}
Expand Down Expand Up @@ -273,15 +287,19 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
onDeleteClick();
}}
className="flex items-center justify-center text-muted-foreground hover:text-foreground"
aria-label="Close workspace"
aria-label={closeLabel}
>
<HiMiniXMark className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
<HotkeyLabel
label="Close workspace"
id={isActive ? "CLOSE_WORKSPACE" : undefined}
label={closeLabel}
id={
isActive && !isMainWorkspace
? "CLOSE_WORKSPACE"
: undefined
}
/>
</TooltipContent>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ interface DashboardSidebarWorkspaceContextMenuProps {
onCopyBranchName: () => void;
onRemoveFromSidebar: () => void;
onRename: () => void;
onDelete: () => void;
onDelete?: () => void;
onToggleUnread: () => void;
children: React.ReactNode;
}
Expand Down Expand Up @@ -168,13 +168,15 @@ export function DashboardSidebarWorkspaceContextMenu({
<LuX className="size-4 mr-2 text-destructive" />
Remove from Sidebar
</ContextMenuItem>
<ContextMenuItem
onSelect={onDelete}
className="text-destructive focus:text-destructive"
>
<LuTrash2 className="size-4 mr-2 text-destructive" />
Delete
</ContextMenuItem>
{onDelete ? (
<ContextMenuItem
onSelect={onDelete}
className="text-destructive focus:text-destructive"
>
<LuTrash2 className="size-4 mr-2 text-destructive" />
Delete
</ContextMenuItem>
) : null}
</ContextMenuContent>
);

Expand Down
Loading
Loading