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
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export function BranchPickerView({
else emptyMessage = "No branches match your search.";

return (
<div className="flex flex-col gap-3" data-testid="branch-picker-view">
<div className="flex h-full min-h-0 flex-col gap-3" data-testid="branch-picker-view">
<div className="flex items-center gap-2">
<button
type="button"
Expand Down Expand Up @@ -225,7 +225,7 @@ export function BranchPickerView({

<div
ref={listRef}
className="h-[360px] overflow-y-auto rounded-lg border border-white/[0.06] bg-black/20"
className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-white/[0.06] bg-black/20"
>
{filtered.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 text-center text-[12px] text-muted-fg/60">
Expand Down
109 changes: 109 additions & 0 deletions apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
/* @vitest-environment jsdom */

import React from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CreateLaneDialog } from "./CreateLaneDialog";

vi.mock("@tanstack/react-virtual", () => {
return {
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 64,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
start: index * 64,
size: 64,
end: (index + 1) * 64,
key: index,
lane: 0,
})),
measureElement: () => undefined,
}),
};
});

beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
Expand Down Expand Up @@ -66,6 +85,47 @@ function makeProps(overrides: Partial<DialogProps> = {}): DialogProps {
};
}

const importBranches = [
{ name: "origin/feature/import-me", isRemote: true, isCurrent: false, upstream: null, lastCommitAuthor: "x", lastCommitDate: "" } as any,
{ name: "origin/feature/other", isRemote: true, isCurrent: false, upstream: null, lastCommitAuthor: "x", lastCommitDate: "" } as any,
];

const linearIssue = {
id: "issue-1",
identifier: "ADE-321",
title: "Follow up",
} as any;

function StatefulCreateLaneDialog(overrides: Partial<DialogProps> = {}) {
const [name, setName] = React.useState(overrides.createLaneName ?? "");
const [mode, setMode] = React.useState<DialogProps["createMode"]>(overrides.createMode ?? "existing");
const [importBranch, setImportBranch] = React.useState(overrides.createImportBranch ?? "");

return (
<CreateLaneDialog
{...makeProps({
...overrides,
createLaneName: name,
setCreateLaneName: setName,
createMode: mode,
setCreateMode: setMode,
createImportBranch: importBranch,
setCreateImportBranch: setImportBranch,
})}
/>
);
}

function selectImportBranch(branchName: string) {
fireEvent.click(screen.getByRole("button", { name: "Choose import branch" }));
fireEvent.click(screen.getByText(branchName));
fireEvent.click(screen.getByRole("button", { name: "Use this branch" }));
}

function laneNameInput(): HTMLInputElement {
return screen.getByRole("textbox", { name: "Lane name" }) as HTMLInputElement;
}

describe("CreateLaneDialog", () => {
it("opens the shared Linear issue browser as its own modal", async () => {
Object.defineProperty(window, "ade", {
Expand Down Expand Up @@ -134,4 +194,53 @@ describe("CreateLaneDialog", () => {
fireEvent.click(submit);
expect(onSubmit).not.toHaveBeenCalled();
});

it("auto-fills the lane name from the selected remote import branch", () => {
render(
<StatefulCreateLaneDialog
createBranches={importBranches}
/>,
);

selectImportBranch("origin/feature/import-me");
expect(laneNameInput().value).toBe("feature/import-me");

selectImportBranch("origin/feature/other");
expect(laneNameInput().value).toBe("feature/other");
});

it("does not overwrite a custom lane name when the import branch changes", () => {
render(
<StatefulCreateLaneDialog
createBranches={importBranches}
/>,
);

selectImportBranch("origin/feature/import-me");
fireEvent.change(laneNameInput(), {
target: { value: "Custom lane name" },
});

selectImportBranch("origin/feature/other");
expect(laneNameInput().value).toBe("Custom lane name");
});

it("lets Linear issue naming replace an untouched import-branch default", () => {
const { rerender } = render(
<StatefulCreateLaneDialog
createBranches={importBranches}
/>,
);

selectImportBranch("origin/feature/import-me");

rerender(
<StatefulCreateLaneDialog
createBranches={importBranches}
selectedLinearIssue={linearIssue}
/>,
);

expect(laneNameInput().value).toBe("ADE-321 Follow up");
});
});
48 changes: 43 additions & 5 deletions apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { LaneColorPicker } from "./LaneColorPicker";
import { colorsInUse, nextAvailableColor } from "./laneColorPalette";
import { BranchPickerView } from "./BranchPickerView";
import { formatRelativeTime } from "./branchPickerSearch";
import { linearIssueBranchName } from "../../../shared/linearIssueBranch";
import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch";
import { branchExistsForLinearIssue, issueProjectLabel } from "./linearIssueDisplay";
import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "./linearBrand";
import { LinearIssueSelectModal } from "../app/LinearIssueSelectModal";
Expand Down Expand Up @@ -70,6 +70,12 @@ const MODE_META: Record<CreateLaneMode, ModeMeta> = {

const MODE_ORDER: readonly CreateLaneMode[] = ["primary", "existing", "child"];

function defaultLaneNameForImportBranch(branchName: string, branches: LaneBranchOption[]): string {
const branch = branches.find((candidate) => candidate.name === branchName);
if (branch?.isRemote) return branch.name.replace(/^[^/]+\//, "");
return branchName;
}

function submitLabel(
busy: boolean | undefined,
mode: CreateLaneMode,
Expand Down Expand Up @@ -193,11 +199,13 @@ export function CreateLaneDialog({

const [pickerOpen, setPickerOpen] = React.useState(false);
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const importBranchAutoNameRef = React.useRef<string | null>(null);

React.useEffect(() => {
if (!open) {
setPickerOpen(false);
setIssuePickerOpen(false);
importBranchAutoNameRef.current = null;
}
}, [open]);

Expand All @@ -212,6 +220,35 @@ export function CreateLaneDialog({
}
}, [createMode, selectedLinearIssue, setCreateImportBranch, setCreateMode]);

const handleSetCreateLaneName = React.useCallback((value: string) => {
importBranchAutoNameRef.current = null;
setCreateLaneName(value);
}, [setCreateLaneName]);

const handleSelectImportBranch = React.useCallback((branchName: string) => {
setCreateImportBranch(branchName);

const nextName = defaultLaneNameForImportBranch(branchName, createBranches);
const previousAutoName = importBranchAutoNameRef.current;
const trimmedCurrentName = createLaneName.trim();
if (!trimmedCurrentName || (previousAutoName && trimmedCurrentName === previousAutoName)) {
importBranchAutoNameRef.current = nextName;
setCreateLaneName(nextName);
return;
}

importBranchAutoNameRef.current = null;
}, [createBranches, createLaneName, setCreateImportBranch, setCreateLaneName]);

React.useEffect(() => {
if (!selectedLinearIssue) return;
const previousAutoName = importBranchAutoNameRef.current;
if (previousAutoName && createLaneName.trim() === previousAutoName) {
setCreateLaneName(linearIssueLaneName(selectedLinearIssue));
}
importBranchAutoNameRef.current = null;
}, [createLaneName, selectedLinearIssue, setCreateLaneName]);

const prByBranch = React.useMemo(() => {
const map = new Map<string, BranchPullRequest>();
for (const pr of branchPullRequests ?? []) map.set(pr.branch, pr);
Expand Down Expand Up @@ -265,6 +302,7 @@ export function CreateLaneDialog({
: "Create a lane from Primary, an existing branch, or another lane."}
icon={Plus}
widthClassName="w-[min(560px,calc(100vw-24px))]"
heightClassName="h-[min(760px,calc(100vh-24px))]"
busy={busy}
onCloseAutoFocus={(event) => {
event.preventDefault();
Expand All @@ -280,22 +318,22 @@ export function CreateLaneDialog({
pullRequests={branchPullRequests ?? []}
currentUserName={currentGitUserName ?? ""}
selectedBranch={createImportBranch}
onSelect={(name) => setCreateImportBranch(name)}
onSelect={handleSelectImportBranch}
onConfirm={() => setPickerOpen(false)}
onBack={() => setPickerOpen(false)}
busy={busy || laneCreated}
loadingBranches={loadingBranches}
loadingPullRequests={loadingBranchPullRequests}
/>
) : (
<div className="space-y-3" data-tour="lanes.createDialog">
<div className="min-h-full space-y-3" data-tour="lanes.createDialog">
{/* Lane name */}
<section className={SECTION_CLASS_NAME}>
<label className="block">
<span className={LABEL_CLASS_NAME}>Lane name</span>
<input
value={createLaneName}
onChange={(e) => setCreateLaneName(e.target.value)}
onChange={(e) => handleSetCreateLaneName(e.target.value)}
placeholder="e.g. feature/auth-refresh"
className={INPUT_CLASS_NAME}
autoFocus
Expand Down Expand Up @@ -706,7 +744,7 @@ export function CreateLaneDialog({
disabled={busy}
onClick={() => {
onOpenChange(false);
setCreateLaneName("");
handleSetCreateLaneName("");
setCreateParentLaneId("");
setCreateMode("primary");
setCreateBaseBranch("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function LaneDialogShell({
headerExtra,
icon: Icon,
widthClassName,
heightClassName,
busy = false,
onCloseAutoFocus,
children,
Expand All @@ -22,24 +23,26 @@ export function LaneDialogShell({
headerExtra?: ReactNode;
icon?: ComponentType<{ size?: number | string; className?: string }>;
widthClassName?: string;
heightClassName?: string;
busy?: boolean;
onCloseAutoFocus?: (event: Event) => void;
children: ReactNode;
}) {
const width = widthClassName ?? "w-[min(720px,calc(100vw-1rem))]";
const maxHeight = "max-h-[min(92dvh,calc(100vh-1rem))]";
const height = heightClassName ?? "";

return (
<Dialog.Root open={open} onOpenChange={(next) => { if (!busy || next) onOpenChange(next); }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm" />
<Dialog.Content
className={`fixed left-1/2 top-1/2 z-50 flex ${maxHeight} ${width} -translate-x-1/2 -translate-y-1/2 overflow-hidden focus:outline-none`}
className={`fixed left-1/2 top-1/2 z-50 flex ${maxHeight} ${height} ${width} -translate-x-1/2 -translate-y-1/2 overflow-hidden focus:outline-none`}
onCloseAutoFocus={onCloseAutoFocus}
>
<BorderBeam size="md" colorVariant="mono" duration={25} strength={0.85} borderRadius={12}>
<div
className={`relative flex ${maxHeight} min-h-0 flex-col overflow-hidden rounded-xl border border-white/[0.1] shadow-float`}
className={`relative flex ${maxHeight} ${height} min-h-0 flex-col overflow-hidden rounded-xl border border-white/[0.1] shadow-float`}
style={{ backgroundColor: "var(--color-modal-bg, var(--color-card, #1A1830))" }}
>
<div className="pointer-events-none absolute inset-x-6 top-0 h-px bg-gradient-to-r from-transparent via-accent/45 to-transparent" />
Expand Down
2 changes: 1 addition & 1 deletion docs/features/lanes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Renderer components:
| `renderer/components/lanes/useLaneWorkSessions.ts` | Hook behind the lane Work pane's chat/session list. Tracks the latest lane id, project root, and scope key in refs so a refresh that was queued during a lane or project switch replays against the newest target and ignores stale rows from the old scope. `launchPtySession` accepts `WorkPtyLaunchArgs` (including `disposition` and `startupDelayMs`) and returns `WorkPtyLaunchResult`; background disposition skips `selectLane`/`focusSession`/`openSessionTab`. The launcher creates an optimistic `TerminalSessionSummary` snapshot from the `ptyCreate` result and upserts it into the session list immediately, then fires the forced session-list refresh as fire-and-forget so the tab and session card appear without waiting for the IPC round-trip. |
| `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` |
| `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog |
| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header strip plus a single scrollable body — every lane modal (create, attach, multi-attach, manage) inherits this layout so long content scrolls instead of overflowing the dialog. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). |
| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header strip plus a single scrollable body — every lane modal (create, attach, multi-attach, manage) inherits this layout so long content scrolls instead of overflowing the dialog. `CreateLaneDialog` pins a stable shell height so the "import existing branch" sub-view keeps the same frame as the main add-lane form while `BranchPickerView` fills the available body area. Selecting a branch seeds the editable lane name from the branch name until the user customizes it. The "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). |
| `renderer/components/lanes/laneDialogTokens.ts` | Shared Tailwind class-name tokens for lane dialog sections: `SECTION_CLASS_NAME` (neutral), `SECTION_ACCENT_CLASS_NAME` (accent wash used by stack/integration callouts like the Stack position panel), `SECTION_HERO_CLASS_NAME` (the hero strip at the top of Manage Lane), `LABEL_CLASS_NAME`, `INPUT_CLASS_NAME`, `SELECT_CLASS_NAME`. |
| `renderer/components/lanes/BranchPickerView.tsx` | Filterable virtualized branch list rendered inside `CreateLaneDialog`. Each row shows branch name, last-commit author + relative date, and an inline PR pill (`#NNN`, dim for drafts) when the branch has an open PR. Loading/empty/error states are handled inline. Backed by `branchPickerSearch.ts`. |
| `renderer/components/lanes/branchPickerSearch.ts` | Pure parser + matcher. Tokens AND together: `pr:open` / `pr:none` / `pr:draft`, `author:NAME` (or `author:me` / `mine` resolved against the local git user), `stale:Nd` (older than N days), `#PRNUMBER` (exact match), and free text fuzzy-matched across branch name / PR title / author. Also exposes `formatRelativeTime` for the row subtitle. |
Expand Down
Loading