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
18 changes: 17 additions & 1 deletion apps/app/src/lib/split-layout/atoms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { createTabScopedStorage, type SyncStorage } from "@/lib/browser-storage";
import {
createBooleanPreferenceAtom,
createTabScopedStorage,
type SyncStorage,
} from "@/lib/browser-storage";
import type { ThreadRoutePathArgs } from "@/lib/route-paths";
import { findPane, listPanes, removePane } from "./ops";
import {
Expand Down Expand Up @@ -62,6 +66,18 @@ export const maximizedPaneIdAtom = atomWithStorage<string | null>(
{ getOnInit: true },
);

export const DIM_INACTIVE_SPLITS_STORAGE_KEY =
"bb.splitLayout.dimInactiveSplits";

/**
* User preference for visually receding every split except the focused one.
* Unlike the arrangement itself, this is shared across tabs and sessions.
*/
export const dimInactiveSplitsAtom = createBooleanPreferenceAtom(
DIM_INACTIVE_SPLITS_STORAGE_KEY,
true,
);

export interface ClosePanesForThreadsResult {
/** True when at least one pane closed. */
removedAny: boolean;
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ describe("WebSocketManager thread-open signals", () => {
type: "thread-pane-action",
projectId: "proj_1",
threadId: "thr_1",
action: "maximize",
action: "spotlight",
} as const;
dispatchRaw(signal);

Expand Down
43 changes: 43 additions & 0 deletions apps/app/src/views/thread-detail/SplitDimmingButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Button } from "@bb/shared-ui/button";
import { Icon } from "@bb/shared-ui/icon";
import { cn } from "@bb/shared-ui/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip";
import { useAtom } from "jotai";
import { HEADER_PANE_ACTION_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader";
import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@/components/ui/chromeStyleTokens";
import { dimInactiveSplitsAtom } from "@/lib/split-layout/atoms";
import { usePaneContext } from "./PaneContext";

export function SplitDimmingButton() {
const { isFocused, isSplitPane } = usePaneContext();
const [dimsInactiveSplits, setDimsInactiveSplits] = useAtom(
dimInactiveSplitsAtom,
);

if (!isSplitPane || !isFocused) return null;

const label = dimsInactiveSplits ? "Clear spotlight" : "Spotlight this split";

return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
HEADER_PANE_ACTION_ICON_BUTTON_CLASS,
CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS,
"aria-pressed:bg-state-active aria-pressed:text-foreground",
)}
aria-label={label}
aria-pressed={dimsInactiveSplits}
onClick={() => setDimsInactiveSplits((current) => !current)}
>
<Icon name={dimsInactiveSplits ? "Idea" : "LightbulbOff"} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
133 changes: 131 additions & 2 deletions apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { TooltipProvider } from "@bb/shared-ui/tooltip";
import type { BbDesktopInfo } from "@bb/desktop-contract";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { maximizedPaneIdAtom, splitLayoutAtom } from "@/lib/split-layout/atoms";
import {
DIM_INACTIVE_SPLITS_STORAGE_KEY,
dimInactiveSplitsAtom,
maximizedPaneIdAtom,
splitLayoutAtom,
} from "@/lib/split-layout/atoms";
import { wsManager } from "@/lib/ws";
import {
listPanes,
movePane,
Expand All @@ -38,6 +44,7 @@ import {
} from "@/components/plugin/plugin-composer-host";
import { PaneContext, usePaneSecondaryPanelRegistration } from "./PaneContext";
import { SplitThreadArea } from "./SplitThreadArea";
import { SplitDimmingButton } from "./SplitDimmingButton";
import { applyThreadOpenToLayout } from "./splitThreadNavigation";

// Per-thread archived/deleted state consulted by the mocked useThread, driving
Expand Down Expand Up @@ -261,6 +268,7 @@ vi.mock("./ThreadDetailView", () => ({
data-focused={pane?.isFocused ? "true" : "false"}
data-window-top-left-owner={pane?.ownsWindowTopLeft ? "true" : "false"}
>
{pane?.isSplitPane ? <SplitDimmingButton /> : null}
<div
data-testid={`drag-${threadId}`}
onPointerDown={(event) => pane?.beginPaneDrag?.(event, threadId)}
Expand Down Expand Up @@ -516,7 +524,7 @@ function renderSplitArea(options: {
store.set(maximizedPaneIdAtom, options.maximizedPaneId);
}
render(
<TooltipProvider>
<TooltipProvider delayDuration={0}>
<JotaiProvider store={store}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[options.path]}>
Expand Down Expand Up @@ -561,6 +569,55 @@ afterEach(() => {
});

describe("SplitThreadArea", () => {
it("applies spotlight pane actions to the targeted open split and preference", async () => {
const store = renderSplitArea({
path: threadPath("thr-b"),
layout: twoPaneLayout("pane-2"),
});
store.set(dimInactiveSplitsAtom, false);

act(() => {
wsManager.handleIncomingMessage(
JSON.stringify({
type: "thread-pane-action",
projectId: PERSONAL_PROJECT_ID,
threadId: "thr-a",
action: "spotlight",
}),
);
});

await waitFor(() => {
expect(store.get(splitLayoutAtom)?.focusedPaneId).toBe("pane-1");
expect(store.get(dimInactiveSplitsAtom)).toBe(true);
expect(screen.getByTestId("location").textContent).toBe(
threadPath("thr-a"),
);
});

act(() => {
wsManager.handleIncomingMessage(
JSON.stringify({
type: "thread-pane-action",
projectId: PERSONAL_PROJECT_ID,
threadId: "thr-b",
action: "clear-spotlight",
}),
);
});

await waitFor(() => {
expect(store.get(splitLayoutAtom)?.focusedPaneId).toBe("pane-2");
expect(store.get(dimInactiveSplitsAtom)).toBe(false);
expect(screen.getByTestId("location").textContent).toBe(
threadPath("thr-b"),
);
});
expect(window.localStorage.getItem(DIM_INACTIVE_SPLITS_STORAGE_KEY)).toBe(
"false",
);
});

it("maximizes without changing the split tree and restores mounted pane state", async () => {
const initialLayout = twoPaneLayout("pane-1");
const store = renderSplitArea({
Expand Down Expand Up @@ -899,6 +956,78 @@ describe("SplitThreadArea", () => {
expect(separator.firstElementChild?.classList).toContain("w-3");
});

it("shows one persisted dimming toggle only for splits and updates every pane immediately", async () => {
renderSplitArea({ path: threadPath("thr-a") });
expect(
screen.queryByRole("button", { name: "Clear spotlight" }),
).toBeNull();

cleanup();
renderSplitArea({
path: threadPath("thr-a"),
layout: twoPaneLayout("pane-1"),
});

const toggle = screen.getByRole("button", {
name: "Clear spotlight",
});
expect(
screen.getAllByRole("button", {
name: "Clear spotlight",
}),
).toHaveLength(1);
expect(toggle.getAttribute("aria-pressed")).toBe("true");
expect(toggle.querySelector('[data-icon="Idea"]')).not.toBeNull();

fireEvent.focus(toggle);
await waitFor(() => {
expect(
screen
.getAllByRole("tooltip")
.some((tooltip) => tooltip.textContent === "Clear spotlight"),
).toBe(true);
});

fireEvent.click(toggle);
expect(toggle.getAttribute("aria-pressed")).toBe("false");
expect(toggle.getAttribute("aria-label")).toBe("Spotlight this split");
expect(toggle.querySelector('[data-icon="LightbulbOff"]')).not.toBeNull();
expect(window.localStorage.getItem(DIM_INACTIVE_SPLITS_STORAGE_KEY)).toBe(
"false",
);
for (const scrim of document.querySelectorAll("[data-pane-focus-scrim]")) {
expect(scrim.classList).toContain("bg-transparent");
expect(scrim.classList).not.toContain("bg-background/30");
}
fireEvent.blur(toggle);
fireEvent.focus(toggle);
await waitFor(() => {
expect(
screen
.getAllByRole("tooltip")
.some((tooltip) => tooltip.textContent === "Spotlight this split"),
).toBe(true);
});

cleanup();
renderSplitArea({
path: threadPath("thr-a"),
layout: twoPaneLayout("pane-1"),
});
const reloadedToggle = screen.getByRole("button", {
name: "Spotlight this split",
});
expect(reloadedToggle.getAttribute("aria-pressed")).toBe("false");
expect(
reloadedToggle.querySelector('[data-icon="LightbulbOff"]'),
).not.toBeNull();
expect(
document
.querySelector('[data-split-pane-id="pane-2"] [data-pane-focus-scrim]')
?.classList.contains("bg-transparent"),
).toBe(true);
});

it("keeps the divider above pane headers so stacked splits stay resizable", () => {
renderSplitArea({
path: "/",
Expand Down
24 changes: 21 additions & 3 deletions apps/app/src/views/thread-detail/SplitThreadArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import { BbHttpError } from "@/lib/sdk";
import { useThread } from "@/hooks/queries/thread-queries";
import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled";
import { useSplitWorkspaceActive } from "@/hooks/useSplitWorkspaceActive";
import { maximizedPaneIdAtom, splitLayoutAtom } from "@/lib/split-layout/atoms";
import {
dimInactiveSplitsAtom,
maximizedPaneIdAtom,
splitLayoutAtom,
} from "@/lib/split-layout/atoms";
import {
clampSplitPairFraction,
computePaneRects,
Expand Down Expand Up @@ -108,6 +112,7 @@ import {
CONTEXT_SELECTION_SURFACE_CLASS,
} from "@/components/ui/context-selection";
import { PaneMaximizeButton } from "./PaneMaximizeButton";
import { SplitDimmingButton } from "./SplitDimmingButton";
import { wsManager } from "@/lib/ws";

// A `pointerdown`-relative move threshold before a pane-header drag engages.
Expand Down Expand Up @@ -228,6 +233,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) {
const navigate = useNavigate();
const store = useStore();
const [storedLayout, setLayout] = useAtom(splitLayoutAtom);
const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom);
const [maximizedPaneId, setMaximizedPaneIdAtom] =
useAtom(maximizedPaneIdAtom);
const secondaryPanelRegistry = useMemo(
Expand Down Expand Up @@ -319,6 +325,9 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) {
if (next.maximizedPaneId !== previousMaximizedPaneId) {
setMaximizedPaneId(next.maximizedPaneId);
}
if (next.dimInactiveSplits !== null) {
store.set(dimInactiveSplitsAtom, next.dimInactiveSplits);
}
}),
[navigate, setMaximizedPaneId, store, threadSplitsEnabled],
);
Expand Down Expand Up @@ -642,6 +651,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) {
isTopRow
isLeftEdge
isRightEdge
dimsInactiveSplits={dimsInactiveSplits}
focusedPaneId={effectiveMaximizedPaneId ?? layout.focusedPaneId}
maximizedPaneId={effectiveMaximizedPaneId}
secondaryPanelRegistry={secondaryPanelRegistry}
Expand Down Expand Up @@ -715,6 +725,7 @@ function SplitPaneCommandHandlers({
interface SplitTreeProps {
node: LayoutNode;
path: SplitPath;
dimsInactiveSplits: boolean;
/** Whether this subtree touches the workspace's top edge. */
isTopRow: boolean;
/** Whether this subtree touches the workspace's left edge. */
Expand Down Expand Up @@ -807,7 +818,9 @@ function SplitTree(props: SplitTreeProps) {
data-pane-focus-scrim=""
className={cn(
"pointer-events-none absolute inset-0 z-20 transition-colors",
isFocused ? "bg-transparent" : "bg-background/30",
isFocused || !props.dimsInactiveSplits
? "bg-transparent"
: "bg-background/30",
)}
/>
</div>
Expand Down Expand Up @@ -1011,6 +1024,7 @@ function NonThreadPaneContent({
}) {
const { navPanels } = usePluginSlots();
const resourceRouteLabel = useAtomValue(resourceRouteLabelAtom);
const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom);
const { reservesWindowPanelToggle, isFocused } = useOptionalPaneContext() ?? {
reservesWindowPanelToggle: false,
isFocused: true,
Expand Down Expand Up @@ -1047,6 +1061,7 @@ function NonThreadPaneContent({
};
const actions = (
<>
<SplitDimmingButton />
{panel ? (
<PluginPanelHeaderActions
panel={panel}
Expand Down Expand Up @@ -1133,7 +1148,10 @@ function NonThreadPaneContent({
<p
className={cn(
"relative truncate text-sm font-normal transition-colors",
isBoundedPane && !isFocused && CONTEXT_INACTIVE_TEXT_CLASS,
isBoundedPane &&
!isFocused &&
dimsInactiveSplits &&
CONTEXT_INACTIVE_TEXT_CLASS,
)}
>
New thread
Expand Down
Loading
Loading