Skip to content
Draft
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
37 changes: 29 additions & 8 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ import {
getThreadRoutePath,
isProjectlessProjectId,
PLUGIN_PANEL_ROUTE_PATH,
PROJECTLESS_THREAD_DIFF_ROUTE_PATH,
SETTINGS_ROUTE_PATH,
TERMINAL_ROUTE_PATH,
THREAD_DIFF_ROUTE_PATH,
} from "@/lib/route-paths";
import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject";
import { IframeDragGuardOverlay } from "@/lib/iframe-drag-guard";
Expand All @@ -78,8 +81,11 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { useMobileVisualViewportHeight } from "./useMobileVisualViewportHeight";
import { wsManager } from "@/lib/ws";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import { findPaneByThread } from "@/lib/split-layout";
import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation";
import { findContentTab } from "@/lib/split-layout";
import {
applyThreadOpenToLayout,
threadPaneContent,
} from "@/views/thread-detail/splitThreadNavigation";
import { useThreadSplitsEnabled } from "@/hooks/useThreadSplitsEnabled";
import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory";

Expand Down Expand Up @@ -443,19 +449,24 @@ export function AppLayout({ children }: AppLayoutProps) {
return;
}
const current = store.get(splitLayoutAtom);
const thread = {
projectId: signal.projectId,
threadId: signal.threadId,
};
const content = threadPaneContent(thread);
const alreadyOpen =
current !== null &&
findPaneByThread(current.root, signal.projectId, signal.threadId) !==
null;
current !== null && findContentTab(current.root, content) !== null;
const next = applyThreadOpenToLayout(
current,
{ projectId: signal.projectId, threadId: signal.threadId },
thread,
isCompactViewport ? "replace" : signal.split,
);
if (next !== current) {
store.set(splitLayoutAtom, next);
}
void navigate(route, alreadyOpen ? { replace: true } : undefined);
if (findContentTab(next.root, content) !== null) {
void navigate(route, alreadyOpen ? { replace: true } : undefined);
}
}),
[isCompactViewport, navigate, store, threadSplitsEnabled],
);
Expand Down Expand Up @@ -554,7 +565,17 @@ export function AppLayout({ children }: AppLayoutProps) {
const showHeader =
!isThreadView &&
!isRootView &&
!(threadSplitsEnabled && pluginPanelMatch !== null);
// Splittable workspace routes render inside SplitThreadArea, where each
// pane's tab strip is the top chrome; the shared AppHeader would sit
// above it as an empty 48px band.
!(
threadSplitsEnabled &&
(pluginPanelMatch !== null ||
matchPath(TERMINAL_ROUTE_PATH, location.pathname) !== null ||
matchPath(PROJECTLESS_THREAD_DIFF_ROUTE_PATH, location.pathname) !==
null ||
matchPath(THREAD_DIFF_ROUTE_PATH, location.pathname) !== null)
);
const [desktopInfo] = useState(getBbDesktopInfo);
const desktopWindowState = useDesktopWindowState();
const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo);
Expand Down
41 changes: 22 additions & 19 deletions apps/app/src/components/plugin/plugin-slot-mounts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ import {
} from "./PluginPanelActions";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import type { PromptDraftState } from "@/lib/prompt-draft";
import type { PaneContent } from "@/lib/split-layout";

function pane(paneId: string, content: PaneContent) {
const tabId = `${paneId}-t1`;
return {
type: "pane" as const,
paneId,
tabs: [{ tabId, content, preview: false }],
activeTabId: tabId,
};
}

function registrationSet(
overrides: Partial<PluginRegistrationSet>,
Expand Down Expand Up @@ -1379,25 +1390,17 @@ describe("PluginNavSidebarItems + PluginPanelView", () => {
dir: "row",
sizes: [0.5, 0.5],
children: [
{
type: "pane",
paneId: "pane-plugin",
content: {
kind: "plugin-panel",
pluginId: "demo",
panelPath: "board",
subPath: "card/1",
},
},
{
type: "pane",
paneId: "pane-thread",
content: {
kind: "thread",
projectId: "proj_test",
threadId: "thr_test",
},
},
pane("pane-plugin", {
kind: "plugin-panel",
pluginId: "demo",
panelPath: "board",
subPath: "card/1",
}),
pane("pane-thread", {
kind: "thread",
projectId: "proj_test",
threadId: "thr_test",
}),
],
},
});
Expand Down
31 changes: 17 additions & 14 deletions apps/app/src/components/sidebar/SidebarThreadSearchPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isThreadSearchKeyboardEventTarget } from "./AppSidebar";
import { ProjectListActionButtons } from "./ProjectList";
import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import type { PaneContent } from "@/lib/split-layout";
import {
getSidebarThreadSearchOptionId,
haveSameSidebarThreadSearchNavigationItems,
Expand All @@ -31,6 +32,16 @@ vi.mock("@/hooks/queries/thread-queries", () => ({

const mockUseThreadSearch = vi.mocked(useThreadSearch);

function pane(paneId: string, content: PaneContent) {
const tabId = `${paneId}-t1`;
return {
type: "pane" as const,
paneId,
tabs: [{ tabId, content, preview: false }],
activeTabId: tabId,
};
}

function createThreadListEntry({
sectionId = null,
id,
Expand Down Expand Up @@ -424,20 +435,12 @@ describe("ProjectListActionButtons", () => {
dir: "row",
sizes: [0.5, 0.5],
children: [
{
type: "pane",
paneId: "pane-compose",
content: { kind: "new-thread" },
},
{
type: "pane",
paneId: "pane-thread",
content: {
kind: "thread",
projectId: "proj_test",
threadId: "thr_test",
},
},
pane("pane-compose", { kind: "new-thread" }),
pane("pane-thread", {
kind: "thread",
projectId: "proj_test",
threadId: "thr_test",
}),
],
},
});
Expand Down
16 changes: 10 additions & 6 deletions apps/app/src/components/sidebar/ThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -464,12 +464,15 @@ function ThreadRowComponent({
{ kind: "thread", projectId, threadId: thread.id },
threadSplitsEnabled,
);
const { onPointerDown: onSplitDragPointerDown, openInSplit } =
useThreadRowSplitDrag({
projectId,
threadId: thread.id,
title: labelTitle,
});
const {
onPointerDown: onSplitDragPointerDown,
openInSplit,
commitThreadTab,
} = useThreadRowSplitDrag({
projectId,
threadId: thread.id,
title: labelTitle,
});
// Splits are disabled on compact viewports; the drag hook signals that by
// withholding its pointer handler, so gate the click/menu entry points on it.
const splitAvailable = onSplitDragPointerDown !== undefined;
Expand Down Expand Up @@ -571,6 +574,7 @@ function ThreadRowComponent({
to={getThreadRoutePath({ projectId, threadId: thread.id })}
data-sidebar-thread-shortcut-target=""
data-sidebar-thread-id={thread.id}
onDoubleClick={commitThreadTab}
onClick={(event) => {
// Selecting a thread/agent row restores its conversation without
// disturbing any other thread's collapsed conversation state.
Expand Down
16 changes: 11 additions & 5 deletions apps/app/src/components/sidebar/paneContentSplitIndicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import {
computePaneRects,
contentMatches,
countPanes,
findPaneByContent,
listPanes,
type PaneContent,
type PaneRect,
Expand Down Expand Up @@ -53,20 +53,26 @@ export function usePaneContentSplitIndicator(
) {
return NO_INDICATOR;
}
const pane = findPaneByContent(layout.root, content);
if (pane === null) {
const panes = listPanes(layout.root);
if (
!panes.some((pane) =>
pane.tabs.some((tab) => contentMatches(tab.content, content)),
)
) {
return NO_INDICATOR;
}
const rects = computePaneRects(layout.root);
const miniMap: MiniMapSlot[] = listPanes(layout.root).flatMap((entry) => {
const miniMap: MiniMapSlot[] = panes.flatMap((entry) => {
const rect = rects.get(entry.paneId);
return rect === undefined
? []
: [
{
paneId: entry.paneId,
rect,
isMe: entry.paneId === pane.paneId,
isMe: entry.tabs.some((tab) =>
contentMatches(tab.content, content),
),
isFocused: entry.paneId === layout.focusedPaneId,
},
];
Expand Down
57 changes: 28 additions & 29 deletions apps/app/src/components/sidebar/usePaneContentSplitDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ import { useCallback, type PointerEvent as ReactPointerEvent } from "react";
import { useStore } from "jotai";
import { useNavigate } from "react-router-dom";
import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import {
getPluginPanelRoutePath,
getRootComposeRoutePath,
getThreadRoutePath,
} from "@/lib/route-paths";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import {
activateTab,
commitTab,
countPanes,
findPaneByContent,
findContentTab,
listPanes,
MAX_PANES,
replacePaneContent,
openTab,
setFocus,
splitPane,
type PaneContent,
Expand All @@ -25,20 +22,11 @@ import {
shouldEngageSidebarSplitDrag,
type SplitDragFallbackTarget,
} from "@/lib/split-drag";
import { paneContentRoute } from "@/views/thread-detail/splitThreadNavigation";

const SIDEBAR_SELECTOR = '[data-sidebar="sidebar"]';
const MAIN_CONTENT_SELECTOR = "main";

function routeForContent(content: PaneContent): string {
if (content.kind === "thread") return getThreadRoutePath(content);
if (content.kind === "new-thread") return getRootComposeRoutePath();
return getPluginPanelRoutePath({
pluginId: content.pluginId,
path: content.panelPath,
subPath: content.subPath,
});
}

/** Prototype drag/cmd-click source for non-thread pages. */
export function usePaneContentSplitDrag({
content,
Expand All @@ -54,21 +42,27 @@ export function usePaneContentSplitDrag({
const isCompact = useIsCompactViewport();

const openInSplit = useCallback(() => {
const route = routeForContent(content);
const route = paneContentRoute(content);
const layout = store.get(splitLayoutAtom);
if (!enabled || isCompact || layout === null) {
navigate(route);
return;
}
const existing = findPaneByContent(layout.root, content);
const next =
const existing = findContentTab(layout.root, content);
let next =
existing !== null
? setFocus(layout, existing.paneId)
? activateTab(layout, existing.pane.paneId, existing.tab.tabId)
: countPanes(layout.root) >= MAX_PANES
? replacePaneContent(layout, layout.focusedPaneId, content)
? openTab(layout, layout.focusedPaneId, content)
: splitPane(layout, layout.focusedPaneId, "right", content);
if (existing !== null) {
next = commitTab(next, existing.pane.paneId, existing.tab.tabId);
next = setFocus(next, existing.pane.paneId);
}
if (next !== layout) store.set(splitLayoutAtom, next);
navigate(route, existing !== null ? { replace: true } : undefined);
if (findContentTab(next.root, content) !== null) {
navigate(route, existing !== null ? { replace: true } : undefined);
}
}, [content, enabled, isCompact, navigate, store]);

const onPointerDown = useCallback(
Expand Down Expand Up @@ -100,23 +94,28 @@ export function usePaneContentSplitDrag({
if (layout === null) return null;
return decideThreadDrop({
zone,
threadAlreadyOpen: findPaneByContent(layout.root, content) !== null,
threadAlreadyOpen: findContentTab(layout.root, content) !== null,
atMaxPanes: countPanes(layout.root) >= MAX_PANES,
});
},
onDrop: (target) => {
const layout = store.get(splitLayoutAtom);
if (layout === null) return;
const existing = findPaneByContent(layout.root, content);
const next =
const existing = findContentTab(layout.root, content);
let next =
existing !== null
? setFocus(layout, existing.paneId)
? activateTab(layout, existing.pane.paneId, existing.tab.tabId)
: target.zone === "center"
? replacePaneContent(layout, target.paneId, content)
? openTab(layout, target.paneId, content)
: splitPane(layout, target.paneId, target.zone, content);
if (existing !== null) {
next = commitTab(next, existing.pane.paneId, existing.tab.tabId);
next = setFocus(next, existing.pane.paneId);
}
if (next !== layout) store.set(splitLayoutAtom, next);
if (findContentTab(next.root, content) === null) return;
navigate(
routeForContent(content),
paneContentRoute(content),
existing !== null ? { replace: true } : undefined,
);
},
Expand Down
Loading
Loading