Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6e6b823
Branch discovery
Kitenite Apr 12, 2026
9b37900
UI
Kitenite Apr 12, 2026
3b75ec6
branch discovery
Kitenite Apr 13, 2026
9954cd8
feat(desktop): paginated branch picker with tabs, checkout, and open …
Kitenite Apr 13, 2026
1a5069a
Concise doc
Kitenite Apr 13, 2026
49ec024
fix(desktop): remote-only checkout tracking, host-scoped open, a11y
Kitenite Apr 13, 2026
b72cb9e
fix(desktop): fall back to branch-only workspace lookup when host id …
Kitenite Apr 13, 2026
602ac91
feat(desktop): adopt orphan worktrees on Worktree-tab Create
Kitenite Apr 13, 2026
0b53a03
Style
Kitenite Apr 13, 2026
5c4a97b
Handle git ref
Kitenite Apr 13, 2026
8c2f96f
doc change
Kitenite Apr 13, 2026
aab6111
refactor(host-service): discriminated ResolvedRef for git refs
Kitenite Apr 13, 2026
fe3f252
fix(host-service, desktop): resolveRef remote-prefix handling + a11y/…
Kitenite Apr 13, 2026
bb10738
fix(host-service): resolveStartPoint prefers local over remote-tracking
Kitenite Apr 13, 2026
7c3c6e1
feat(workspace-creation): plumb baseBranchSource hint from picker to …
Kitenite Apr 13, 2026
5b3c56d
feat(workspace-creation): unify fork/checkout/adopt under pending-pag…
Kitenite Apr 13, 2026
3cacd68
fix(desktop): use client collection as source of truth for workspace …
Kitenite Apr 14, 2026
e23584b
docs: add workspace delete design (host-service orchestrates cleanup)
Kitenite Apr 14, 2026
704cb6c
fix(host-service): adopt always creates a fresh cloud row
Kitenite Apr 14, 2026
0ec468e
fix: reset pending-page refs on navigation, clear progress on early t…
Kitenite Apr 14, 2026
5f6733e
refactor(desktop): tighten pending-row schema, extract + test intent …
Kitenite Apr 14, 2026
0b153de
docs: consolidate branch discovery, pending flow, and delete designs
Kitenite Apr 14, 2026
3ad3ad2
docs: rename DESIGN.md → V2_WORKSPACE_CREATION.md, move to apps/deskt…
Kitenite Apr 14, 2026
7bb1ba3
docs: fold WORKSPACE_CREATION_FALLBACK research into V2_WORKSPACE_CRE…
Kitenite Apr 14, 2026
1b8d8f1
refactor(desktop): extract picker controller + linked-context hooks f…
Kitenite Apr 14, 2026
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
435 changes: 435 additions & 0 deletions apps/desktop/V2_WORKSPACE_CREATION.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test";
import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";
import {
buildAdoptPayload,
buildCheckoutPayload,
buildForkPayload,
mapLinkedContextFromPending,
} from "./buildIntentPayload";

function makePending(
overrides: Partial<PendingWorkspaceRow> = {},
): PendingWorkspaceRow {
return {
id: "11111111-1111-1111-1111-111111111111",
projectId: "22222222-2222-2222-2222-222222222222",
hostTarget: { kind: "local" },
intent: "fork",
name: "my-workspace",
branchName: "feature-foo",
status: "creating",
error: null,
workspaceId: null,
warnings: [],
terminals: [],
createdAt: new Date("2026-04-13T00:00:00Z"),
prompt: "",
baseBranch: null,
baseBranchSource: null,
linkedIssues: [],
linkedPR: null,
attachmentCount: 0,
runSetupScript: true,
...overrides,
};
}

describe("mapLinkedContextFromPending", () => {
test("extracts internal task ids from linkedIssues", () => {
const mapped = mapLinkedContextFromPending({
linkedIssues: [
{ slug: "SUP-1", title: "a", source: "internal", taskId: "t1" },
{ slug: "SUP-2", title: "b", source: "internal", taskId: "t2" },
],
linkedPR: null,
});
expect(mapped.internalIssueIds).toEqual(["t1", "t2"]);
expect(mapped.githubIssueUrls).toBeUndefined();
expect(mapped.linkedPrUrl).toBeUndefined();
});

test("extracts github urls from linkedIssues", () => {
const mapped = mapLinkedContextFromPending({
linkedIssues: [
{
slug: "#1",
title: "a",
source: "github",
url: "https://github.com/o/r/issues/1",
},
],
linkedPR: null,
});
expect(mapped.githubIssueUrls).toEqual(["https://github.com/o/r/issues/1"]);
expect(mapped.internalIssueIds).toBeUndefined();
});

test("skips internal issues missing taskId and github issues missing url", () => {
const mapped = mapLinkedContextFromPending({
linkedIssues: [
{ slug: "SUP-1", title: "no task id", source: "internal" },
{ slug: "#1", title: "no url", source: "github" },
],
linkedPR: null,
});
expect(mapped.internalIssueIds).toBeUndefined();
expect(mapped.githubIssueUrls).toBeUndefined();
});

test("surfaces linkedPR.url", () => {
const mapped = mapLinkedContextFromPending({
linkedIssues: [],
linkedPR: {
prNumber: 42,
title: "PR 42",
url: "https://github.com/o/r/pull/42",
state: "open",
},
});
expect(mapped.linkedPrUrl).toBe("https://github.com/o/r/pull/42");
});

test("returns all undefined for empty input", () => {
const mapped = mapLinkedContextFromPending({
linkedIssues: [],
linkedPR: null,
});
expect(mapped).toEqual({
internalIssueIds: undefined,
githubIssueUrls: undefined,
linkedPrUrl: undefined,
});
});
});

describe("buildForkPayload", () => {
test("passes fork-specific fields and linked context", () => {
const pending = makePending({
intent: "fork",
prompt: "do the thing",
baseBranch: "main",
baseBranchSource: "local",
linkedIssues: [
{ slug: "SUP-1", title: "a", source: "internal", taskId: "t1" },
],
linkedPR: {
prNumber: 3,
title: "p",
url: "https://github.com/o/r/pull/3",
state: "open",
},
});
const payload = buildForkPayload("pid", pending, undefined);
expect(payload.pendingId).toBe("pid");
expect(payload.projectId).toBe(pending.projectId);
expect(payload.hostTarget).toEqual({ kind: "local" });
expect(payload.names).toEqual({
workspaceName: "my-workspace",
branchName: "feature-foo",
});
expect(payload.composer.prompt).toBe("do the thing");
expect(payload.composer.baseBranch).toBe("main");
expect(payload.composer.baseBranchSource).toBe("local");
expect(payload.linkedContext?.internalIssueIds).toEqual(["t1"]);
expect(payload.linkedContext?.linkedPrUrl).toBe(
"https://github.com/o/r/pull/3",
);
});

test("empty prompt/baseBranch become undefined, not empty strings", () => {
const pending = makePending({ prompt: "", baseBranch: null });
const payload = buildForkPayload("pid", pending, undefined);
expect(payload.composer.prompt).toBeUndefined();
expect(payload.composer.baseBranch).toBeUndefined();
});

test("attachments are plumbed through linkedContext", () => {
const pending = makePending();
const payload = buildForkPayload("pid", pending, [
{ data: "b64", mediaType: "image/png", filename: "a.png" },
]);
expect(payload.linkedContext?.attachments).toHaveLength(1);
});

test("host-tracking hostTarget survives the map", () => {
const pending = makePending({
hostTarget: { kind: "host", hostId: "h-1" },
});
const payload = buildForkPayload("pid", pending, undefined);
expect(payload.hostTarget).toEqual({ kind: "host", hostId: "h-1" });
});
});

describe("buildCheckoutPayload", () => {
test("sends branch + runSetupScript; no composer prompt/baseBranch", () => {
const pending = makePending({
intent: "checkout",
branchName: "feature-foo",
runSetupScript: false,
});
const payload = buildCheckoutPayload("pid", pending);
expect(payload.branch).toBe("feature-foo");
expect(payload.workspaceName).toBe("my-workspace");
expect(payload.composer).toEqual({ runSetupScript: false });
});
});

describe("buildAdoptPayload", () => {
test("minimal payload: projectId + host + name + branch", () => {
const pending = makePending({
intent: "adopt",
branchName: "agreeable-ermine",
});
const payload = buildAdoptPayload(pending);
expect(payload).toEqual({
projectId: pending.projectId,
hostTarget: { kind: "local" },
workspaceName: "my-workspace",
branch: "agreeable-ermine",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { AdoptWorktreeInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree";
import type { CheckoutWorkspaceInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace";
import type { CreateWorkspaceInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace";
import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema";

/**
* Pure builders that translate a `PendingWorkspaceRow` into the input shape
* each host-service mutation expects. Kept pure (no React, no IO) so the
* dispatch logic in the pending page is testable in isolation. See
* `buildIntentPayload.test.ts` for the contract suite.
*/

type Attachment = { data: string; mediaType: string; filename: string };

export function mapLinkedContextFromPending(
pending: Pick<PendingWorkspaceRow, "linkedIssues" | "linkedPR">,
): {
internalIssueIds: string[] | undefined;
githubIssueUrls: string[] | undefined;
linkedPrUrl: string | undefined;
} {
const internalIssueIds = pending.linkedIssues
.filter((i) => i.source === "internal" && i.taskId)
.map((i) => i.taskId as string);
const githubIssueUrls = pending.linkedIssues
.filter((i) => i.source === "github" && i.url)
.map((i) => i.url as string);
return {
internalIssueIds:
internalIssueIds.length > 0 ? internalIssueIds : undefined,
githubIssueUrls: githubIssueUrls.length > 0 ? githubIssueUrls : undefined,
linkedPrUrl: pending.linkedPR?.url,
};
}

export function buildForkPayload(
pendingId: string,
pending: PendingWorkspaceRow,
attachments: Attachment[] | undefined,
): CreateWorkspaceInput {
const linked = mapLinkedContextFromPending(pending);
return {
pendingId,
projectId: pending.projectId,
hostTarget: pending.hostTarget,
names: {
workspaceName: pending.name,
branchName: pending.branchName,
},
composer: {
prompt: pending.prompt || undefined,
baseBranch: pending.baseBranch || undefined,
baseBranchSource: pending.baseBranchSource ?? undefined,
runSetupScript: pending.runSetupScript,
},
linkedContext: {
internalIssueIds: linked.internalIssueIds,
githubIssueUrls: linked.githubIssueUrls,
linkedPrUrl: linked.linkedPrUrl,
attachments,
},
};
}

export function buildCheckoutPayload(
pendingId: string,
pending: PendingWorkspaceRow,
): CheckoutWorkspaceInput {
return {
pendingId,
projectId: pending.projectId,
hostTarget: pending.hostTarget,
workspaceName: pending.name,
branch: pending.branchName,
composer: { runSetupScript: pending.runSetupScript },
};
}

export function buildAdoptPayload(
pending: PendingWorkspaceRow,
): AdoptWorktreeInput {
return {
projectId: pending.projectId,
hostTarget: pending.hostTarget,
workspaceName: pending.name,
branch: pending.branchName,
};
}
Loading
Loading