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 @@ -133,6 +133,7 @@ vi.mock("@/hooks/queries/sidebar-navigation-query", () => ({
}));

vi.mock("@/hooks/queries/thread-queries", () => ({
didThreadDetailBootstrapRefreshAfterMount: () => true,
useThread: (...args: unknown[]) => mockUseThread(...args),
useThreadDetailBootstrap: (...args: unknown[]) =>
mockUseThreadDetailBootstrap(...args),
Expand Down
6 changes: 5 additions & 1 deletion apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AppPageHeader, HEADER_ICON_BUTTON_CLASS } from "./AppPageHeader";
import { stripProjectThreads } from "@/hooks/queries/project-queries";
import { useSidebarNavigation } from "@/hooks/queries/sidebar-navigation-query";
import {
didThreadDetailBootstrapRefreshAfterMount,
getLatestPendingInteraction,
useThread,
useThreadDetailBootstrap,
Expand Down Expand Up @@ -633,7 +634,10 @@ export function AppLayout({ children }: AppLayoutProps) {
enabled:
Boolean(threadId) && (!isThreadView || hasThreadDetailBootstrapSettled),
refetchOnMount:
isThreadView && threadDetailBootstrapQuery.isSuccess ? true : "always",
isThreadView &&
didThreadDetailBootstrapRefreshAfterMount(threadDetailBootstrapQuery)
? false
: "always",
});
const threadDisplayTitle = thread
? getThreadDisplayTitle(thread)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export const baseProps: ThreadMetadataContentProps = {
parentThreads,
canAssignToParent: true,
canTakeOverThread: false,
isLoadingParentThreads: false,
isParentThreadsError: false,
environment: makeEnvironment(),
environmentDisplayHost: localEnvironmentDisplayHost,
workspaceStatus: makeWorkspaceStatus(),
Expand All @@ -95,6 +97,8 @@ export const baseProps: ThreadMetadataContentProps = {
isLoadingMergeBaseBranchOptions: false,
updateThreadPending: false,
onAssignParent: noop,
onParentSelectorOpenChange: noop,
onRetryParentThreads: noop,
onMergeBaseBranchChange: noop,
onChangedFileClick: noop,
};
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ export function ParentSelector() {
parentThreads={parentThreads}
canAssignToParent
canTakeOverThread={false}
isLoadingParentThreads={false}
isParentThreadsError={false}
updateThreadPending={false}
onAssignParent={noop}
onParentSelectorOpenChange={noop}
onRetryParentThreads={noop}
/>
</RowStage>
</StoryRow>
Expand All @@ -74,8 +78,12 @@ export function ParentSelector() {
parentThreads={[]}
canAssignToParent={false}
canTakeOverThread={false}
isLoadingParentThreads={false}
isParentThreadsError={false}
updateThreadPending={false}
onAssignParent={noop}
onParentSelectorOpenChange={noop}
onRetryParentThreads={noop}
/>
</RowStage>
</StoryRow>
Expand All @@ -88,8 +96,12 @@ export function ParentSelector() {
parentThreads={parentThreads}
canAssignToParent={false}
canTakeOverThread
isLoadingParentThreads={false}
isParentThreadsError={false}
updateThreadPending={false}
onAssignParent={noop}
onParentSelectorOpenChange={noop}
onRetryParentThreads={noop}
/>
</RowStage>
</StoryRow>
Expand All @@ -102,8 +114,12 @@ export function ParentSelector() {
parentThreads={parentThreads}
canAssignToParent
canTakeOverThread={false}
isLoadingParentThreads={false}
isParentThreadsError={false}
updateThreadPending={false}
onAssignParent={noop}
onParentSelectorOpenChange={noop}
onRetryParentThreads={noop}
defaultOpen
/>
</RowStage>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// @vitest-environment jsdom

import { fireEvent, render, screen } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { Environment, Thread } from "@bb/domain";
import { describe, expect, it } from "vitest";
import { EnvironmentRow } from "./ThreadMetadataContent";
import { describe, expect, it, vi } from "vitest";
import { EnvironmentRow, ParentSelectorRow } from "./ThreadMetadataContent";
import { parentThreads } from "./ThreadMetadataContent.fixtures";

const localHost = { locality: "local", identity: null } as const;

Expand Down Expand Up @@ -101,3 +105,80 @@ describe("EnvironmentRow", () => {
);
});
});

describe("ParentSelectorRow", () => {
it("requests candidates only when the parent menu opens", async () => {
const onOpenChange = vi.fn();
render(
<MemoryRouter>
<ParentSelectorRow
thread={makeThread({ environmentId: null })}
projectId="proj_test"
parentThreadDisplayName={null}
parentThreads={[]}
canAssignToParent
canTakeOverThread={false}
isLoadingParentThreads
isParentThreadsError={false}
updateThreadPending={false}
onAssignParent={vi.fn()}
onParentSelectorOpenChange={onOpenChange}
onRetryParentThreads={vi.fn()}
/>
</MemoryRouter>,
);

expect(onOpenChange).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole("button"), {
button: 0,
ctrlKey: false,
});

expect(onOpenChange).toHaveBeenCalledWith(true);
expect(await screen.findByText("Loading threads…")).toBeTruthy();
});

it("offers a retry after candidate loading fails and shows recovered results", async () => {
const onRetry = vi.fn();
const row = (isError: boolean, candidates = parentThreads) => (
<MemoryRouter>
<ParentSelectorRow
thread={makeThread({ environmentId: null })}
projectId="proj_test"
parentThreadDisplayName={null}
parentThreads={candidates}
canAssignToParent
canTakeOverThread={false}
isLoadingParentThreads={false}
isParentThreadsError={isError}
updateThreadPending={false}
onAssignParent={vi.fn()}
onParentSelectorOpenChange={vi.fn()}
onRetryParentThreads={onRetry}
/>
</MemoryRouter>
);
const result = render(row(true, []));

fireEvent.pointerDown(screen.getByRole("button"), {
button: 0,
ctrlKey: false,
});
fireEvent.click(await screen.findByText("Retry loading threads"));
expect(onRetry).toHaveBeenCalledTimes(1);

result.rerender(row(false));
const trigger = screen
.getAllByRole("button")
.reverse()
.find((candidate) => candidate.getAttribute("aria-haspopup") === "menu");
if (!trigger) {
throw new Error("missing parent selector trigger");
}
fireEvent.pointerDown(trigger, {
button: 0,
ctrlKey: false,
});
expect(await screen.findByText("Codex Parent")).toBeTruthy();
});
});
87 changes: 56 additions & 31 deletions apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,12 @@ export interface ParentSelectorRowProps {
parentThreads: readonly ThreadListEntry[];
canAssignToParent: boolean;
canTakeOverThread: boolean;
isLoadingParentThreads: boolean;
isParentThreadsError: boolean;
updateThreadPending: boolean;
onAssignParent: (parentThreadId: string | null) => void;
onParentSelectorOpenChange: (open: boolean) => void;
onRetryParentThreads: () => void;
/** Force the assignment dropdown open on first render. Used by stories. */
defaultOpen?: boolean;
}
Expand All @@ -106,8 +110,12 @@ export function ParentSelectorRow({
parentThreads,
canAssignToParent,
canTakeOverThread,
isLoadingParentThreads,
isParentThreadsError,
updateThreadPending,
onAssignParent,
onParentSelectorOpenChange,
onRetryParentThreads,
defaultOpen,
}: ParentSelectorRowProps) {
const parentThreadId = thread.parentThreadId ?? undefined;
Expand Down Expand Up @@ -166,17 +174,14 @@ export function ParentSelectorRow({
</Button>
</div>
) : (
<DropdownMenu defaultOpen={defaultOpen}>
<DropdownMenu
defaultOpen={defaultOpen}
onOpenChange={onParentSelectorOpenChange}
>
<DropdownMenuTrigger asChild>
<div
role="button"
tabIndex={
updateThreadPending ||
(parentSelectorOptions.length <= 1 &&
parentSelectorValue === "none")
? -1
: 0
}
tabIndex={updateThreadPending ? -1 : 0}
className={cn(
"-mx-1 inline-flex h-5 w-fit max-w-full min-w-0 items-center gap-1 rounded-sm px-1 leading-tight text-foreground outline-none ring-sidebar-ring transition-colors hover:bg-state-hover data-[state=open]:bg-state-hover focus-visible:ring-2",
COARSE_POINTER_TEXT_SM_CLASS,
Expand All @@ -201,27 +206,37 @@ export function ParentSelectorRow({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-40 max-w-72">
<DropdownMenuLabel>Assign parent thread</DropdownMenuLabel>
{parentSelectorOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => {
onAssignParent(option.value === "none" ? null : option.value);
}}
className="flex items-center justify-between gap-3"
>
<span className="truncate" title={option.label}>
{option.label}
</span>
<Icon
name="Check"
className={
parentSelectorValue === option.value
? cn("opacity-100", COARSE_POINTER_ICON_SIZE_CLASS)
: cn("opacity-0", COARSE_POINTER_ICON_SIZE_CLASS)
}
/>
{isLoadingParentThreads ? (
<DropdownMenuItem disabled>Loading threads…</DropdownMenuItem>
) : isParentThreadsError ? (
<DropdownMenuItem onSelect={onRetryParentThreads}>
Retry loading threads
</DropdownMenuItem>
))}
) : (
parentSelectorOptions.map((option) => (
<DropdownMenuItem
key={option.value}
onSelect={() => {
onAssignParent(
option.value === "none" ? null : option.value,
);
}}
className="flex items-center justify-between gap-3"
>
<span className="truncate" title={option.label}>
{option.label}
</span>
<Icon
name="Check"
className={
parentSelectorValue === option.value
? cn("opacity-100", COARSE_POINTER_ICON_SIZE_CLASS)
: cn("opacity-0", COARSE_POINTER_ICON_SIZE_CLASS)
}
/>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)}
Expand Down Expand Up @@ -361,9 +376,7 @@ export function WorkspacePathRow({ environment }: WorkspacePathRowProps) {

return (
<DetailRow
label={
<DetailRowIconLabel icon="Folder">Directory</DetailRowIconLabel>
}
label={<DetailRowIconLabel icon="Folder">Directory</DetailRowIconLabel>}
valueClassName="min-w-0"
>
<CopyableInlineLabel
Expand Down Expand Up @@ -878,6 +891,8 @@ export interface ThreadMetadataContentProps {
parentThreads: readonly ThreadListEntry[];
canAssignToParent: boolean;
canTakeOverThread: boolean;
isLoadingParentThreads: boolean;
isParentThreadsError: boolean;
environment: Environment | null;
environmentDisplayHost: EnvironmentDisplayHostContext;
workspaceStatus: WorkspaceStatus | undefined;
Expand All @@ -892,6 +907,8 @@ export interface ThreadMetadataContentProps {
updateThreadPending: boolean;
storage?: ThreadStorageRowProps;
onAssignParent: (parentThreadId: string | null) => void;
onParentSelectorOpenChange: (open: boolean) => void;
onRetryParentThreads: () => void;
onMergeBaseBranchChange: (branch: string) => void;
onMergeBasePickerOpenChange?: (open: boolean) => void;
onMergeBaseBranchSearchQueryChange?: (query: string) => void;
Expand Down Expand Up @@ -988,6 +1005,8 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) {
parentThreads,
canAssignToParent,
canTakeOverThread,
isLoadingParentThreads,
isParentThreadsError,
environment,
environmentDisplayHost,
workspaceStatus,
Expand All @@ -1002,6 +1021,8 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) {
updateThreadPending,
storage,
onAssignParent,
onParentSelectorOpenChange,
onRetryParentThreads,
onMergeBaseBranchChange,
onMergeBasePickerOpenChange,
onMergeBaseBranchSearchQueryChange,
Expand All @@ -1018,8 +1039,12 @@ export function ThreadMetadataContent(props: ThreadMetadataContentProps) {
parentThreads={parentThreads}
canAssignToParent={canAssignToParent}
canTakeOverThread={canTakeOverThread}
isLoadingParentThreads={isLoadingParentThreads}
isParentThreadsError={isParentThreadsError}
updateThreadPending={updateThreadPending}
onAssignParent={onAssignParent}
onParentSelectorOpenChange={onParentSelectorOpenChange}
onRetryParentThreads={onRetryParentThreads}
/>
<ForksRow thread={thread} projectId={projectId} />
<EnvironmentRow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,6 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"hostQueryKey",
"hostsQueryKey",
"threadQueryKey",
"threadTimelineQueryKey",
],
"hooks/cache-owners/thread-tabs-cache-owner.ts": ["threadTabsQueryKey"],
"hooks/cache-owners/thread-list-cache-owner.ts": [
Expand Down
Loading
Loading