Skip to content
Open
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
48 changes: 44 additions & 4 deletions apps/app/src/components/AppLocalStateInitialization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { cleanup, render, screen } from "@testing-library/react";
import { StrictMode } from "react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
promptDraftSlotStorageKeysForTests,
Expand Down Expand Up @@ -36,10 +37,12 @@ describe("AppLocalStateInitialization", () => {
);

render(
<StrictMode>
<AppLocalStateInitialization />
<DraftRows />
</StrictMode>,
<MemoryRouter>
<StrictMode>
<AppLocalStateInitialization />
<DraftRows />
</StrictMode>
</MemoryRouter>,
);

expect(readNewThreadDraftSlots()).toEqual([
Expand All @@ -60,4 +63,41 @@ describe("AppLocalStateInitialization", () => {
).toBeNull();
expect(screen.getByText("Never lose this draft")).not.toBeNull();
});

it("prefers the launch route project and section over the stored project", () => {
window.localStorage.setItem("bb.root-compose.project-id", "project-stored");
window.localStorage.setItem(
promptDraftSlotStorageKeysForTests.legacy,
JSON.stringify({ text: "Route-owned draft", attachments: [] }),
);

render(
<MemoryRouter
initialEntries={[
{
pathname: "/projects/project-route",
state: { sectionId: "section-route" },
},
]}
>
<AppLocalStateInitialization />
</MemoryRouter>,
);

const migratedSlots = readNewThreadDraftSlots();
expect(migratedSlots).toEqual([
expect.objectContaining({
destination: {
projectId: "project-route",
sectionId: "section-route",
},
}),
]);
expect(
window.localStorage.getItem(promptDraftSlotStorageKeysForTests.legacy),
).toBeNull();
expect(readNewThreadDraftSlots()[0]?.destination).toEqual(
migratedSlots[0]?.destination,
);
});
});
31 changes: 28 additions & 3 deletions apps/app/src/components/AppLocalStateInitialization.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { useEffect, useRef } from "react";
import { matchPath, useLocation } from "react-router-dom";
import { refreshNewThreadDraftSlots } from "@/hooks/usePromptDraftStorage";
import { initializeNewThreadDraftSlots } from "@/lib/prompt-draft-slots";
import {
initializeNewThreadDraftSlots,
resolveNewThreadDraftDestination,
} from "@/lib/prompt-draft-slots";
import { readRootComposeSectionId } from "@/lib/root-compose-location-state";
import {
APP_ROOT_ROUTE_PATH,
LEGACY_PROJECT_COMPOSE_ROUTE_PATH,
} from "@/lib/route-paths";
import { readRootComposeProjectId } from "@/lib/root-compose-selection";

/**
Expand All @@ -9,13 +18,29 @@ import { readRootComposeProjectId } from "@/lib/root-compose-selection";
*/
export function AppLocalStateInitialization() {
const didInitializeDraftSlots = useRef(false);
const location = useLocation();

useEffect(() => {
if (didInitializeDraftSlots.current) return;
didInitializeDraftSlots.current = true;
initializeNewThreadDraftSlots(readRootComposeProjectId());
const legacyProjectMatch = matchPath(
LEGACY_PROJECT_COMPOSE_ROUTE_PATH,
location.pathname,
);
const isComposeRoute =
location.pathname === APP_ROOT_ROUTE_PATH || legacyProjectMatch !== null;
initializeNewThreadDraftSlots(
resolveNewThreadDraftDestination({
storedDestination: null,
routeProjectId: legacyProjectMatch?.params.projectId ?? null,
routeSectionId: isComposeRoute
? readRootComposeSectionId(location.state)
: null,
fallbackProjectId: readRootComposeProjectId(),
}),
);
refreshNewThreadDraftSlots();
}, []);
}, [location.pathname, location.state]);

return null;
}
94 changes: 94 additions & 0 deletions apps/app/src/components/commands/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const modeState = vi.hoisted(() => ({
drafts: [] as NewThreadDraftRow[],
searchResponse: undefined as ThreadSearchResponse | undefined,
}));
const openPaneContentInSplitMock = vi.hoisted(() => vi.fn());
const openThreadInSplitMock = vi.hoisted(() => vi.fn());
const routeNavigateMock = vi.hoisted(() => vi.fn());

Expand Down Expand Up @@ -132,6 +133,10 @@ vi.mock("@/lib/split-layout/openThreadInSplit", () => ({
openThreadInSplit: openThreadInSplitMock,
}));

vi.mock("@/lib/split-layout/openPaneContentInSplit", () => ({
openPaneContentInSplit: openPaneContentInSplitMock,
}));

vi.mock("@/components/ui/app-route-anchor", () => ({
useRouteNavigate: () => routeNavigateMock,
}));
Expand Down Expand Up @@ -291,6 +296,7 @@ afterEach(() => {
modeState.archivedRecents = [];
modeState.drafts = [];
modeState.searchResponse = undefined;
openPaneContentInSplitMock.mockReset();
openThreadInSplitMock.mockReset();
routeNavigateMock.mockReset();
window.localStorage.clear();
Expand Down Expand Up @@ -432,6 +438,32 @@ describe("CommandPalette", () => {
);
});

it("opens the closed thread scope with Enter and returns to the input on the next Enter", async () => {
renderPalette();
openThreadSearch();
const input = await screen.findByRole("combobox", {
name: "Search threads",
});
const scope = screen.getByRole("button", { name: "Thread scope" });

scope.focus();
fireEvent.keyDown(scope, { key: "Enter" });

expect(scope.getAttribute("aria-expanded")).toBe("true");
expect(
screen.getByRole("listbox", { name: "Thread scope options" }),
).toBeTruthy();
expect(document.activeElement).toBe(scope);

fireEvent.keyDown(scope, { key: "Enter" });

expect(scope.getAttribute("aria-expanded")).toBe("false");
expect(
screen.queryByRole("listbox", { name: "Thread scope options" }),
).toBeNull();
expect(document.activeElement).toBe(input);
});

it("makes scope the input's only sibling tab stop and applies every keyboard choice immediately", async () => {
modeState.searchResponse = {
active: {
Expand Down Expand Up @@ -641,6 +673,68 @@ describe("CommandPalette", () => {
);
});

it("opens a persisted draft result in a split with its exact slot id", async () => {
modeState.drafts = [
{
id: "draft-slot-exact",
title: "split this draft",
draft: { ...emptyPromptDraftState(), text: "split this draft" },
lastEditedAt: Date.now(),
destination: { projectId: "project-1", sectionId: null },
delete: vi.fn(),
},
];
renderPalette();
openThreadSearch();
const input = await screen.findByRole("combobox", {
name: "Search threads",
});
await screen.findByRole("option", { name: /split this draft/i });

fireEvent.keyDown(input, { key: "Enter", metaKey: true });

await waitFor(() =>
expect(openPaneContentInSplitMock).toHaveBeenCalledTimes(1),
);
expect(openPaneContentInSplitMock).toHaveBeenCalledWith(
expect.objectContaining({
content: { kind: "new-thread", draftSlotId: "draft-slot-exact" },
enabled: true,
}),
);
expect(routeNavigateMock).not.toHaveBeenCalled();
});

it("keeps ordinary Enter on a persisted draft as normal navigation", async () => {
modeState.drafts = [
{
id: "draft-slot-normal",
title: "open this draft",
draft: { ...emptyPromptDraftState(), text: "open this draft" },
lastEditedAt: Date.now(),
destination: { projectId: "project-1", sectionId: null },
delete: vi.fn(),
},
];
renderPalette();
openThreadSearch();
const input = await screen.findByRole("combobox", {
name: "Search threads",
});
await screen.findByRole("option", { name: /open this draft/i });

fireEvent.keyDown(input, { key: "Enter" });

await waitFor(() => expect(routeNavigateMock).toHaveBeenCalledTimes(1));
expect(openPaneContentInSplitMock).not.toHaveBeenCalled();
expect(routeNavigateMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
state: expect.objectContaining({ draftSlotId: "draft-slot-normal" }),
}),
);
});

it("filters as the user types and keeps the selection on a live row", async () => {
renderPalette();
openPalette();
Expand Down
23 changes: 22 additions & 1 deletion apps/app/src/components/commands/ThreadSearchPaletteMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { useRouteNavigate } from "@/components/ui/app-route-anchor";
import { getRootComposeRoutePath, getThreadRoutePath } from "@/lib/route-paths";
import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state";
import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit";
import { openThreadInSplit } from "@/lib/split-layout/openThreadInSplit";
import {
buildPaletteThreadSearchRows,
Expand Down Expand Up @@ -154,6 +155,16 @@ export function ThreadSearchPaletteMode({
return;
}
if (row.draftSlotId !== null) {
if (split) {
openPaneContentInSplit({
store,
navigate,
content: { kind: "new-thread", draftSlotId: row.draftSlotId },
route: getRootComposeRoutePath(),
enabled: !isCompact,
});
return;
}
navigate(getRootComposeRoutePath(), {
state: withRootComposeDraftSlotId(
{ focusPrompt: true },
Expand Down Expand Up @@ -313,7 +324,17 @@ function ThreadSearchScopeFilter({
cycle(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Enter" || event.key === "Escape") {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (open) {
returnToInput();
} else {
setOpen(true);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
returnToInput();
Expand Down
14 changes: 14 additions & 0 deletions apps/app/src/components/pickers/MachinePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@bb/shared-ui/dropdown-menu";
import {
Expand Down Expand Up @@ -36,6 +37,9 @@ interface MachinePickerUIProps {
primaryHostId: string | null;
selectedHostId: string | null;
onChange: (hostId: string) => void;
/** Opens the Machines settings page to enroll another machine. */
onNewMachine?: () => void;
/** Render with the dim, hover-to-foreground treatment used inside the prompt box. */
muted?: boolean;
disabled?: boolean;
className?: string;
Expand All @@ -48,6 +52,7 @@ export function MachinePickerUI({
primaryHostId,
selectedHostId,
onChange,
onNewMachine,
muted,
disabled = false,
className,
Expand Down Expand Up @@ -157,6 +162,15 @@ export function MachinePickerUI({
</DropdownMenuItem>
);
})}
{onNewMachine ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onNewMachine}>
<Icon name="LaptopAdd" aria-hidden="true" />
New Machine
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
Expand Down
Loading
Loading