-
+
);
@@ -134,10 +160,12 @@ function DraftActionsMenu({
function DraftActionsContextMenu({
children,
onDelete,
+ onOpenInSplit,
onOpenChange,
}: {
children: ReactNode;
onDelete: () => void;
+ onOpenInSplit?: () => void;
onOpenChange: (open: boolean) => void;
}) {
const isCompactViewport = useIsCompactViewport();
@@ -147,7 +175,13 @@ function DraftActionsContextMenu({
}
+ items={
+
+ }
>
{children}
@@ -158,7 +192,11 @@ function DraftActionsContextMenu({
{children}
-
+
);
@@ -167,13 +205,20 @@ function DraftActionsContextMenu({
function SidebarDraftRow({
draft,
onOpenDraft,
+ splitEnabled,
}: {
draft: SidebarDraftRowItem;
onOpenDraft: (draftId: string) => void;
+ splitEnabled: boolean;
}) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [contextOpen, setContextOpen] = useState(false);
const actionsOpen = dropdownOpen || contextOpen;
+ const draftSplit = usePaneContentSplitDrag({
+ content: { kind: "new-thread", draftSlotId: draft.id },
+ enabled: splitEnabled,
+ label: draft.title,
+ });
const row = (
@@ -229,6 +275,7 @@ function SidebarDraftRow({
return (
{row}
@@ -245,6 +292,7 @@ export function SidebarDraftRows({
drafts,
onOpenDraft,
}: SidebarDraftRowsProps) {
+ const splitEnabled = useSplitWorkspaceActive();
if (drafts.length === 0) {
return null;
}
@@ -256,6 +304,7 @@ export function SidebarDraftRows({
key={draft.id}
draft={draft}
onOpenDraft={onOpenDraft}
+ splitEnabled={splitEnabled}
/>
))}
diff --git a/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts b/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts
index b1ef791783..286d360a2d 100644
--- a/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts
+++ b/apps/app/src/hooks/useNewThreadDraftLeaveToast.test.ts
@@ -36,7 +36,6 @@ describe("new-thread draft leave toast", () => {
() =>
useNewThreadDraftLeaveToast({
getCurrentDraft: () => draft,
- isSplitPane: false,
}),
{ wrapper: StrictModeWrapper },
);
@@ -56,32 +55,60 @@ describe("new-thread draft leave toast", () => {
shouldAnnounceNewThreadDraftLeave({
draft,
draftRowsVisible: false,
- isSplitPane: false,
}),
).toBe(true);
expect(
shouldAnnounceNewThreadDraftLeave({
draft,
draftRowsVisible: true,
- isSplitPane: false,
}),
).toBe(false);
});
- it("does not announce empty or split-pane drafts in Phase 2", () => {
+ it("does not announce empty drafts", () => {
expect(
shouldAnnounceNewThreadDraftLeave({
draft: EMPTY_DRAFT,
draftRowsVisible: false,
- isSplitPane: false,
}),
).toBe(false);
+ });
+
+ it("announces split-pane drafts when their built-in row is hidden", () => {
expect(
shouldAnnounceNewThreadDraftLeave({
draft: { ...EMPTY_DRAFT, text: "Split work" },
draftRowsVisible: false,
- isSplitPane: true,
}),
- ).toBe(false);
+ ).toBe(true);
+ });
+
+ it("coalesces composers removed by the same leave event into one toast", async () => {
+ const leftDraft = { ...EMPTY_DRAFT, text: "Left work" };
+ const rightDraft = { ...EMPTY_DRAFT, text: "Right work" };
+ const left = renderHook(
+ () =>
+ useNewThreadDraftLeaveToast({
+ getCurrentDraft: () => leftDraft,
+ }),
+ { wrapper: StrictModeWrapper },
+ );
+ const right = renderHook(
+ () =>
+ useNewThreadDraftLeaveToast({
+ getCurrentDraft: () => rightDraft,
+ }),
+ { wrapper: StrictModeWrapper },
+ );
+
+ await act(async () => {
+ left.unmount();
+ right.unmount();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(mockToastMessage).toHaveBeenCalledTimes(1);
+ expect(mockToastMessage).toHaveBeenCalledWith("Saved to Drafts");
});
});
diff --git a/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts b/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts
index e602cb09f5..0fd2d83510 100644
--- a/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts
+++ b/apps/app/src/hooks/useNewThreadDraftLeaveToast.ts
@@ -7,36 +7,41 @@ import { builtInSidebarDraftRowsVisibleAtom } from "@/components/sidebar/sidebar
export function shouldAnnounceNewThreadDraftLeave({
draft,
draftRowsVisible,
- isSplitPane,
}: {
draft: PromptDraftState;
draftRowsVisible: boolean;
- isSplitPane: boolean;
}): boolean {
- return !isSplitPane && !draftRowsVisible && !isPromptDraftEmpty(draft);
+ return !draftRowsVisible && !isPromptDraftEmpty(draft);
+}
+
+let savedDraftToastScheduled = false;
+
+function scheduleSavedDraftToast(): void {
+ if (savedDraftToastScheduled) return;
+ savedDraftToastScheduled = true;
+ queueMicrotask(() => {
+ savedDraftToastScheduled = false;
+ appToast.message("Saved to Drafts");
+ });
}
/**
- * Announces a persisted page-composer draft when navigation removes it from
- * view. Split-pane replacement/close owns the same rule in Phase 4, so this
- * hook intentionally ignores composers currently hosted in a split pane.
+ * Announces a persisted composer draft when navigation, split replacement, or
+ * pane close removes it from view. Toasts scheduled in the same leave event
+ * coalesce so replacing several composers announces the save once.
*/
export function useNewThreadDraftLeaveToast({
getCurrentDraft,
- isSplitPane,
}: {
getCurrentDraft: () => PromptDraftState;
- isSplitPane: boolean;
}): void {
const draftRowsVisible = useAtomValue(builtInSidebarDraftRowsVisibleAtom);
const draftRowsVisibleRef = useRef(draftRowsVisible);
const getCurrentDraftRef = useRef(getCurrentDraft);
- const isSplitPaneRef = useRef(isSplitPane);
const mountedRef = useRef(false);
draftRowsVisibleRef.current = draftRowsVisible;
getCurrentDraftRef.current = getCurrentDraft;
- isSplitPaneRef.current = isSplitPane;
useEffect(() => {
mountedRef.current = true;
@@ -45,7 +50,6 @@ export function useNewThreadDraftLeaveToast({
const shouldAnnounce = shouldAnnounceNewThreadDraftLeave({
draft: getCurrentDraftRef.current(),
draftRowsVisible: draftRowsVisibleRef.current,
- isSplitPane: isSplitPaneRef.current,
});
if (!shouldAnnounce) return;
@@ -54,7 +58,7 @@ export function useNewThreadDraftLeaveToast({
// instance mounted again before an informational toast is emitted.
queueMicrotask(() => {
if (!mountedRef.current) {
- appToast.message("Saved to Drafts");
+ scheduleSavedDraftToast();
}
});
};
diff --git a/apps/app/src/lib/command-palette/palette-app-commands.test.ts b/apps/app/src/lib/command-palette/palette-app-commands.test.ts
index ca90f7d23c..50eb365b40 100644
--- a/apps/app/src/lib/command-palette/palette-app-commands.test.ts
+++ b/apps/app/src/lib/command-palette/palette-app-commands.test.ts
@@ -76,11 +76,6 @@ describe("buildAppCommandActions", () => {
});
});
- it("leaves the shortcut null for a command the user has not bound", () => {
- const { actions } = build(["thread.rename"]);
- expect(actions[0]?.shortcut).toBeNull();
- });
-
it("dispatches with the element that was focused before the palette opened", () => {
const target = { id: "composer" } as unknown as EventTarget;
const dispatch = vi.fn();
diff --git a/apps/app/src/lib/split-layout/ops.test.ts b/apps/app/src/lib/split-layout/ops.test.ts
index 020dae913c..a15502647d 100644
--- a/apps/app/src/lib/split-layout/ops.test.ts
+++ b/apps/app/src/lib/split-layout/ops.test.ts
@@ -10,6 +10,7 @@ import {
normalize,
removePane,
replacePaneContent,
+ replaceWithTwoPaneLayout,
resizeSplit,
setFocus,
splitPane,
@@ -68,6 +69,67 @@ function expectNormalizedSizes(layout: SplitLayout): void {
}
describe("split layout operations", () => {
+ it("creates two equal new-thread panes and focuses the left pane", () => {
+ const leftContent = newThreadContent("left-draft");
+ const rightContent = newThreadContent("right-draft");
+
+ const layout = replaceWithTwoPaneLayout(
+ null,
+ leftContent,
+ rightContent,
+ );
+
+ expect(layout).toEqual({
+ root: {
+ type: "split",
+ dir: "row",
+ sizes: [0.5, 0.5],
+ children: [
+ { type: "pane", paneId: "pane-1", content: leftContent },
+ { type: "pane", paneId: "pane-2", content: rightContent },
+ ],
+ },
+ focusedPaneId: "pane-1",
+ });
+ });
+
+ it.each([1, 2, MAX_PANES])(
+ "replaces a %i-pane workspace without retaining old panes or content",
+ (paneCount) => {
+ const current = layoutAtPaneCount(paneCount);
+ const previousPanes = listPanes(current.root);
+ const previousPaneIds = new Set(previousPanes.map((item) => item.paneId));
+ const leftContent = newThreadContent(`left-draft-${paneCount}`);
+ const rightContent = newThreadContent(`right-draft-${paneCount}`);
+
+ const replacement = replaceWithTwoPaneLayout(
+ current,
+ leftContent,
+ rightContent,
+ );
+ const replacementPanes = listPanes(replacement.root);
+
+ expect(countPanes(replacement.root)).toBe(2);
+ expect(replacement.root).toMatchObject({
+ type: "split",
+ dir: "row",
+ sizes: [0.5, 0.5],
+ });
+ expect(replacementPanes[0]?.content).toBe(leftContent);
+ expect(replacementPanes[1]?.content).toBe(rightContent);
+ expect(replacement.focusedPaneId).toBe(replacementPanes[0]?.paneId);
+ expect(
+ replacementPanes.every(
+ (item) => !previousPaneIds.has(item.paneId),
+ ),
+ ).toBe(true);
+ expect(
+ replacementPanes.some((item) => previousPanes.includes(item)),
+ ).toBe(false);
+ expect(countPanes(current.root)).toBe(paneCount);
+ },
+ );
+
it("keeps independent new-thread slots distinct and finds the exact binding", () => {
const withFirstDraft = splitPane(
singlePaneLayout(),
diff --git a/apps/app/src/lib/split-layout/ops.ts b/apps/app/src/lib/split-layout/ops.ts
index 9b7e766e1f..43ac1e8674 100644
--- a/apps/app/src/lib/split-layout/ops.ts
+++ b/apps/app/src/lib/split-layout/ops.ts
@@ -116,8 +116,14 @@ function replacePaneNode(
};
}
-function nextPaneId(root: LayoutNode): string {
- const existingIds = new Set(listPanes(root).map((pane) => pane.paneId));
+function nextPaneId(
+ root: LayoutNode | null,
+ reservedIds: readonly string[] = [],
+): string {
+ const existingIds = new Set([
+ ...(root === null ? [] : listPanes(root).map((pane) => pane.paneId)),
+ ...reservedIds,
+ ]);
let sequence = 1;
while (existingIds.has(`pane-${sequence}`)) {
sequence += 1;
@@ -125,6 +131,31 @@ function nextPaneId(root: LayoutNode): string {
return `pane-${sequence}`;
}
+/** Replaces any existing workspace with two equally sized panes. The caller
+ * owns the content identities, while this operation guarantees that neither
+ * pane reuses an ID from the layout being replaced. */
+export function replaceWithTwoPaneLayout(
+ layout: SplitLayout | null,
+ leftContent: PaneContent,
+ rightContent: PaneContent,
+): SplitLayout {
+ const previousRoot = layout?.root ?? null;
+ const leftPaneId = nextPaneId(previousRoot);
+ const rightPaneId = nextPaneId(previousRoot, [leftPaneId]);
+ return {
+ root: {
+ type: "split",
+ dir: "row",
+ sizes: [0.5, 0.5],
+ children: [
+ { type: "pane", paneId: leftPaneId, content: leftContent },
+ { type: "pane", paneId: rightPaneId, content: rightContent },
+ ],
+ },
+ focusedPaneId: leftPaneId,
+ };
+}
+
function splitDirection(side: SplitSide): SplitNode["dir"] {
return side === "left" || side === "right" ? "row" : "col";
}
diff --git a/apps/app/src/views/RootComposeView.test.ts b/apps/app/src/views/RootComposeView.test.ts
index 7e296f7a1e..5a47274115 100644
--- a/apps/app/src/views/RootComposeView.test.ts
+++ b/apps/app/src/views/RootComposeView.test.ts
@@ -27,6 +27,7 @@ import {
buildRootComposeTerminalSessions,
buildMobileRecentThreads,
canCreateRootComposeTerminal,
+ finishRootComposeThreadCreate,
hasSingleUseRootComposeTargetState,
readSectionIdFromLocationState,
readRootComposeSectionTargetFromLocationState,
@@ -38,6 +39,7 @@ import {
shouldNavigateAfterThreadCreate,
} from "./RootComposeView";
import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state";
+import { getThreadRoutePath } from "@/lib/route-paths";
import { resolveRootComposeProjectFileRouting } from "./RootComposePanelTabContent";
import {
resolveProjectSourceWorktreeDisabledReason,
@@ -870,6 +872,80 @@ describe("shouldNavigateAfterThreadCreate", () => {
});
});
+describe("finishRootComposeThreadCreate", () => {
+ const thread = { projectId: "proj_created", threadId: "thr_created" };
+
+ it("uses page navigation for the standalone composer", () => {
+ const navigatedPaths: string[] = [];
+
+ finishRootComposeThreadCreate({
+ navigate: (path) => navigatedPaths.push(path),
+ paneContext: {
+ paneId: "main",
+ navigateInPane: () => {
+ throw new Error("Standalone composer must not use pane navigation");
+ },
+ },
+ shouldNavigateToCreatedThread: true,
+ thread,
+ });
+
+ expect(navigatedPaths).toEqual([getThreadRoutePath(thread)]);
+ });
+
+ it.each(["pane-left", "pane-right"])(
+ "replaces only the submitted workspace pane when navigation is on (%s)",
+ (paneId) => {
+ const navigatedPaths: string[] = [];
+ const paneThreads: typeof thread[] = [];
+ let resetCount = 0;
+
+ finishRootComposeThreadCreate({
+ navigate: (path) => navigatedPaths.push(path),
+ paneContext: {
+ paneId,
+ navigateInPane: (nextThread) => paneThreads.push(nextThread),
+ resetNewThreadPane: () => {
+ resetCount += 1;
+ },
+ },
+ shouldNavigateToCreatedThread: true,
+ thread,
+ });
+
+ expect(paneThreads).toEqual([thread]);
+ expect(navigatedPaths).toEqual([]);
+ expect(resetCount).toBe(0);
+ },
+ );
+
+ it.each(["pane-left", "pane-right"])(
+ "refreshes only the submitted workspace pane when navigation is off (%s)",
+ (paneId) => {
+ let resetCount = 0;
+
+ finishRootComposeThreadCreate({
+ navigate: () => {
+ throw new Error("Workspace pane must not use page navigation");
+ },
+ paneContext: {
+ paneId,
+ navigateInPane: () => {
+ throw new Error("Navigation is disabled");
+ },
+ resetNewThreadPane: () => {
+ resetCount += 1;
+ },
+ },
+ shouldNavigateToCreatedThread: false,
+ thread,
+ });
+
+ expect(resetCount).toBe(1);
+ },
+ );
+});
+
describe("resolveProjectSourceWorktreeDisabledReason", () => {
it("explains why non-git and commitless sources cannot create worktrees", () => {
expect(resolveProjectSourceWorktreeDisabledReason(undefined)).toBeNull();
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index 0f53d90166..d8e887aa9b 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -94,6 +94,7 @@ import {
getProjectComposeRoutePath,
getRootComposeRoutePath,
isRoutePath,
+ type ThreadRoutePathArgs,
} from "@/lib/route-paths";
import { getBrowserUrlHost } from "@/lib/browser-url";
import {
@@ -178,7 +179,10 @@ import {
useAppCommandHandler,
useAppCommandShortcut,
} from "@/components/commands/AppCommandProvider";
-import { useOptionalPaneContext } from "./thread-detail/PaneContext";
+import {
+ useOptionalPaneContext,
+ type PaneContextValue,
+} from "./thread-detail/PaneContext";
import { useNewThreadDraftLeaveToast } from "@/hooks/useNewThreadDraftLeaveToast";
import { RootComposePanelCommandHandlers } from "./RootComposePanelCommandHandlers";
import {
@@ -340,6 +344,39 @@ export function shouldNavigateAfterThreadCreate({
return isForkDraft || navigateToThreadAfterCreate;
}
+interface FinishRootComposeThreadCreateArgs {
+ navigate: (path: string) => void;
+ paneContext: Pick<
+ PaneContextValue,
+ "navigateInPane" | "paneId" | "resetNewThreadPane"
+ > | null;
+ shouldNavigateToCreatedThread: boolean;
+ thread: ThreadRoutePathArgs;
+}
+
+export function finishRootComposeThreadCreate({
+ navigate,
+ paneContext,
+ shouldNavigateToCreatedThread,
+ thread,
+}: FinishRootComposeThreadCreateArgs): void {
+ const isWorkspacePane =
+ paneContext !== null && paneContext.paneId !== "main";
+
+ if (shouldNavigateToCreatedThread) {
+ if (isWorkspacePane) {
+ paneContext.navigateInPane(thread);
+ } else {
+ navigate(getThreadRoutePath(thread));
+ }
+ return;
+ }
+
+ if (isWorkspacePane) {
+ paneContext.resetNewThreadPane?.();
+ }
+}
+
function readForkThreadCreateSeedFromLocationState(
state: unknown,
): ForkThreadCreateSeed | null {
@@ -612,14 +649,15 @@ function RootComposeSlotView({ draftSlotId }: RootComposeViewProps) {
setLastCreatedThreadId(thread.id);
setForkSeed(null);
setRootComposeSectionId(null);
- if (shouldNavigateToCreatedThread) {
- navigate(
- getThreadRoutePath({
- projectId: thread.projectId,
- threadId: thread.id,
- }),
- );
- }
+ finishRootComposeThreadCreate({
+ navigate,
+ paneContext,
+ shouldNavigateToCreatedThread,
+ thread: {
+ projectId: thread.projectId,
+ threadId: thread.id,
+ },
+ });
},
[
createThread,
@@ -627,6 +665,7 @@ function RootComposeSlotView({ draftSlotId }: RootComposeViewProps) {
queryClient,
navigate,
navigateToThreadAfterCreate,
+ paneContext,
rootComposeSectionId,
],
);
@@ -746,7 +785,6 @@ function RootComposeSurface({
} = composer;
useNewThreadDraftLeaveToast({
getCurrentDraft: promptDraft.getCurrent,
- isSplitPane: paneContext?.isSplitPane === true,
});
const rootPanelEnvironmentId =
parsedEnvironment?.type === "reuse"
@@ -913,12 +951,18 @@ function RootComposeSurface({
"focusPrompt" in location.state &&
location.state.focusPrompt === true;
useEffect(() => {
- if (!shouldFocusPrompt || isPointerCoarse) return;
+ if (!shouldFocusPrompt || !isFocusedPane || isPointerCoarse) return;
const handle = window.requestAnimationFrame(() => {
promptBoxRef.current?.focusEnd();
});
return () => window.cancelAnimationFrame(handle);
- }, [isPointerCoarse, location.key, promptBoxRef, shouldFocusPrompt]);
+ }, [
+ isFocusedPane,
+ isPointerCoarse,
+ location.key,
+ promptBoxRef,
+ shouldFocusPrompt,
+ ]);
const mobileRecentThreads = useMemo(
() => buildMobileRecentThreads({ sidebarNavigation }),
@@ -2023,7 +2067,7 @@ function RootComposeSurface({
const promptBox = renderPromptBox({
id: "root-compose-prompt",
- autoFocus: !isProviderCliVersionBlocked,
+ autoFocus: isFocusedPane && !isProviderCliVersionBlocked,
banner: promptBanner,
header: promptHeader,
blockedReason: isProviderCliVersionBlocked
diff --git a/apps/app/src/views/thread-detail/PaneContext.tsx b/apps/app/src/views/thread-detail/PaneContext.tsx
index bc884367ff..eaeeb4925a 100644
--- a/apps/app/src/views/thread-detail/PaneContext.tsx
+++ b/apps/app/src/views/thread-detail/PaneContext.tsx
@@ -66,6 +66,10 @@ export interface PaneContextValue {
*/
ownsWindowTopLeft: boolean;
navigateInPane: (thread: ThreadRoutePathArgs) => void;
+ /** Replaces this composer with a fresh empty draft slot after a successful
+ * create when the user keeps new threads in place. Split panes provide it;
+ * page and thread surfaces omit it. */
+ resetNewThreadPane?: () => void;
/**
* Starts a pane-reorder drag from the pane header via the shared split-drag
* layer (move to an edge / swap on center). Only provided when the layout is
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
index ada22b580a..1979f0db69 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
@@ -36,6 +36,7 @@ import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage";
import { createBbDesktopApi } from "@/test/bb-desktop-test-utils";
import { resourceRouteLabelAtom } from "@/components/layout/resourceRouteLabelAtom";
import { readRootComposeDraftSlotId } from "@/lib/root-compose-location-state";
+import { readNewThreadDraftSlots } from "@/lib/prompt-draft-slots";
import {
resetPluginSlotStoreForTest,
setPluginSlotRegistrations,
@@ -93,6 +94,11 @@ function HostedComposerScopeProbe({ threadId }: { threadId: string }) {
function RootComposeFixture({ draftSlotId }: { draftSlotId: string }) {
const pane = useContext(PaneContext);
const [isPanelOpen, setIsPanelOpen] = useState(false);
+ const draft = usePromptDraftStorage({
+ kind: "new-thread",
+ slotId: draftSlotId,
+ destination: { projectId: PERSONAL_PROJECT_ID, sectionId: null },
+ });
const panelModel = useMemo(
() => ({
composerHost: null,
@@ -110,7 +116,61 @@ function RootComposeFixture({ draftSlotId }: { draftSlotId: string }) {
panelModel,
);
return (
-
+
+
);
}
@@ -444,6 +504,29 @@ function newThreadContentFor(draftSlotId: string): PaneContent {
const newThreadContent = newThreadContentFor("draft-slot-default");
+function twoPaneComposeLayout(focusedPaneId: string): SplitLayout {
+ return {
+ root: {
+ type: "split",
+ dir: "row",
+ sizes: [0.5, 0.5],
+ children: [
+ {
+ type: "pane",
+ paneId: "pane-left",
+ content: newThreadContentFor("draft-slot-left"),
+ },
+ {
+ type: "pane",
+ paneId: "pane-right",
+ content: newThreadContentFor("draft-slot-right"),
+ },
+ ],
+ },
+ focusedPaneId,
+ };
+}
+
function pluginContent(panelPath: string): PaneContent {
return {
kind: "plugin-panel",
@@ -1423,6 +1506,243 @@ describe("SplitThreadArea", () => {
);
});
+ it.each([
+ {
+ label: "left",
+ paneId: "pane-left",
+ slotId: "draft-slot-left",
+ siblingPaneId: "pane-right",
+ siblingSlotId: "draft-slot-right",
+ },
+ {
+ label: "right",
+ paneId: "pane-right",
+ slotId: "draft-slot-right",
+ siblingPaneId: "pane-left",
+ siblingSlotId: "draft-slot-left",
+ },
+ ])(
+ "replaces the $label composer in place after send when navigation is on",
+ async ({ paneId, slotId, siblingPaneId, siblingSlotId }) => {
+ const store = renderSplitArea({
+ path: "/",
+ layout: twoPaneComposeLayout(paneId),
+ routeContent: newThreadContentFor(slotId),
+ });
+ fireEvent.change(screen.getByRole("textbox", { name: `Draft ${slotId}` }), {
+ target: { value: "Submitted work" },
+ });
+ fireEvent.change(
+ screen.getByRole("textbox", { name: `Draft ${siblingSlotId}` }),
+ { target: { value: "Untouched sibling" } },
+ );
+
+ fireEvent.click(screen.getByTestId(`navigate-compose-${slotId}`));
+
+ await waitFor(() => {
+ const layout = store.get(splitLayoutAtom);
+ const panes = layout === null ? [] : listPanes(layout.root);
+ expect(panes.find((pane) => pane.paneId === paneId)?.content).toEqual({
+ kind: "thread",
+ projectId: PERSONAL_PROJECT_ID,
+ threadId: `created-${slotId}`,
+ });
+ expect(
+ panes.find((pane) => pane.paneId === siblingPaneId)?.content,
+ ).toEqual(newThreadContentFor(siblingSlotId));
+ expect(layout?.focusedPaneId).toBe(paneId);
+ });
+ expect(
+ (
+ screen.getByRole("textbox", {
+ name: `Draft ${siblingSlotId}`,
+ }) as HTMLTextAreaElement
+ ).value,
+ ).toBe("Untouched sibling");
+ expect(readNewThreadDraftSlots().map((slot) => slot.id)).not.toContain(
+ slotId,
+ );
+ },
+ );
+
+ it.each([
+ {
+ label: "left",
+ paneId: "pane-left",
+ slotId: "draft-slot-left",
+ siblingPaneId: "pane-right",
+ siblingSlotId: "draft-slot-right",
+ },
+ {
+ label: "right",
+ paneId: "pane-right",
+ slotId: "draft-slot-right",
+ siblingPaneId: "pane-left",
+ siblingSlotId: "draft-slot-left",
+ },
+ ])(
+ "refreshes the $label composer with a fresh slot when navigation is off",
+ async ({ paneId, slotId, siblingPaneId, siblingSlotId }) => {
+ const store = renderSplitArea({
+ path: "/",
+ layout: twoPaneComposeLayout(paneId),
+ routeContent: newThreadContentFor(slotId),
+ });
+ fireEvent.change(screen.getByRole("textbox", { name: `Draft ${slotId}` }), {
+ target: { value: "Submitted work" },
+ });
+ fireEvent.change(
+ screen.getByRole("textbox", { name: `Draft ${siblingSlotId}` }),
+ { target: { value: "Untouched sibling" } },
+ );
+
+ fireEvent.click(screen.getByTestId(`reset-compose-${slotId}`));
+
+ let freshSlotId: string | null = null;
+ await waitFor(() => {
+ const layout = store.get(splitLayoutAtom);
+ const panes = layout === null ? [] : listPanes(layout.root);
+ const targetContent = panes.find(
+ (pane) => pane.paneId === paneId,
+ )?.content;
+ expect(targetContent?.kind).toBe("new-thread");
+ if (targetContent?.kind === "new-thread") {
+ freshSlotId = targetContent.draftSlotId;
+ expect(freshSlotId).not.toBe(slotId);
+ }
+ expect(
+ panes.find((pane) => pane.paneId === siblingPaneId)?.content,
+ ).toEqual(newThreadContentFor(siblingSlotId));
+ expect(layout?.focusedPaneId).toBe(paneId);
+ });
+ expect(freshSlotId).not.toBeNull();
+ expect(screen.getByTestId("location").dataset.draftSlotId).toBe(
+ freshSlotId ?? undefined,
+ );
+ expect(
+ (
+ screen.getByRole("textbox", {
+ name: `Draft ${siblingSlotId}`,
+ }) as HTMLTextAreaElement
+ ).value,
+ ).toBe("Untouched sibling");
+ expect(readNewThreadDraftSlots().map((slot) => slot.id)).not.toContain(
+ slotId,
+ );
+ },
+ );
+
+ it.each([
+ { kind: "text", expectedAttachments: 0 },
+ { kind: "attachment", expectedAttachments: 1 },
+ ])(
+ "keeps a closed compose pane whose only input is $kind",
+ async ({ kind, expectedAttachments }) => {
+ const layout = twoPaneComposeLayout("pane-left");
+ renderSplitArea({
+ path: "/",
+ layout,
+ routeContent: newThreadContentFor("draft-slot-left"),
+ });
+ const leftPane = document.querySelector(
+ '[data-split-pane-id="pane-left"]',
+ );
+ expect(leftPane).not.toBeNull();
+ if (leftPane === null) return;
+
+ if (kind === "text") {
+ fireEvent.change(
+ within(leftPane).getByRole("textbox", {
+ name: "Draft draft-slot-left",
+ }),
+ { target: { value: "Keep after close" } },
+ );
+ } else {
+ fireEvent.click(
+ within(leftPane).getByTestId("add-attachment-draft-slot-left"),
+ );
+ }
+ fireEvent.click(
+ within(leftPane).getByRole("button", { name: "Close pane" }),
+ );
+
+ await waitFor(() =>
+ expect(screen.queryByRole("textbox", { name: "Draft draft-slot-left" })).toBeNull(),
+ );
+ const persisted = readNewThreadDraftSlots().find(
+ (slot) => slot.id === "draft-slot-left",
+ );
+ expect(persisted).toBeDefined();
+ expect(persisted?.draft.text).toBe(
+ kind === "text" ? "Keep after close" : "",
+ );
+ expect(persisted?.draft.attachments).toHaveLength(expectedAttachments);
+ },
+ );
+
+ it("leaves no draft row after closing an empty compose pane", async () => {
+ renderSplitArea({
+ path: "/",
+ layout: twoPaneComposeLayout("pane-left"),
+ routeContent: newThreadContentFor("draft-slot-left"),
+ });
+ const leftPane = document.querySelector(
+ '[data-split-pane-id="pane-left"]',
+ );
+ expect(leftPane).not.toBeNull();
+ if (leftPane === null) return;
+
+ fireEvent.click(
+ within(leftPane).getByRole("button", { name: "Close pane" }),
+ );
+
+ await waitFor(() =>
+ expect(screen.queryByRole("textbox", { name: "Draft draft-slot-left" })).toBeNull(),
+ );
+ expect(readNewThreadDraftSlots().map((slot) => slot.id)).not.toContain(
+ "draft-slot-left",
+ );
+ });
+
+ it("restores both compose drafts after the split remounts", () => {
+ const layout = twoPaneComposeLayout("pane-left");
+ renderSplitArea({
+ path: "/",
+ layout,
+ routeContent: newThreadContentFor("draft-slot-left"),
+ });
+ fireEvent.change(
+ screen.getByRole("textbox", { name: "Draft draft-slot-left" }),
+ { target: { value: "Restored left" } },
+ );
+ fireEvent.change(
+ screen.getByRole("textbox", { name: "Draft draft-slot-right" }),
+ { target: { value: "Restored right" } },
+ );
+
+ cleanup();
+ renderSplitArea({
+ path: "/",
+ layout,
+ routeContent: newThreadContentFor("draft-slot-left"),
+ });
+
+ expect(
+ (
+ screen.getByRole("textbox", {
+ name: "Draft draft-slot-left",
+ }) as HTMLTextAreaElement
+ ).value,
+ ).toBe("Restored left");
+ expect(
+ (
+ screen.getByRole("textbox", {
+ name: "Draft draft-slot-right",
+ }) as HTMLTextAreaElement
+ ).value,
+ ).toBe("Restored right");
+ });
+
it("passes the route slot to the standalone compose surface", () => {
viewportState.compact = true;
const routeContent = newThreadContentFor("draft-slot-standalone");
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx
index f0e2e75538..4d972855ae 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx
@@ -19,6 +19,7 @@ import {
import { useNavigate } from "react-router-dom";
import { useRouteState } from "@/hooks/useRouteState";
import {
+ getRootComposeRoutePath,
getThreadRoutePath,
type ThreadRoutePathArgs,
} from "@/lib/route-paths";
@@ -122,6 +123,8 @@ import {
} from "@/components/ui/context-selection";
import { PaneMaximizeButton } from "./PaneMaximizeButton";
import { wsManager } from "@/lib/ws";
+import { createNewThreadDraftSlotId } from "@/lib/prompt-draft-slots";
+import { withRootComposeDraftSlotId } from "@/lib/root-compose-location-state";
const LazyPluginPanelRightPanelHost = lazy(() =>
import("@/components/plugin/PluginPanelRightPanelHost").then(
@@ -175,6 +178,7 @@ type BeginPaneDrag = (
const EMPTY_PATH: SplitPath = [];
type NavigateInPane = (paneId: string, thread: ThreadRoutePathArgs) => void;
+type ResetNewThreadPane = (paneId: string) => void;
/**
* Renders the 1–8 thread panes that live in the main content area. It bridges
@@ -436,6 +440,30 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) {
[navigate, setLayout],
);
+ const resetNewThreadPane = useCallback(
+ (paneId) => {
+ const current = store.get(splitLayoutAtom);
+ if (current === null || findPane(current.root, paneId) === null) return;
+ const draftSlotId = createNewThreadDraftSlotId();
+ const replaced = replacePaneContent(current, paneId, {
+ kind: "new-thread",
+ draftSlotId,
+ });
+ const next =
+ current.focusedPaneId === paneId
+ ? replaced
+ : setFocus(replaced, current.focusedPaneId);
+ store.set(splitLayoutAtom, next);
+ if (current.focusedPaneId === paneId) {
+ void navigate(getRootComposeRoutePath(), {
+ replace: true,
+ state: withRootComposeDraftSlotId(null, draftSlotId),
+ });
+ }
+ },
+ [navigate, store],
+ );
+
// Focusing a pane rewrites the URL with replace (focus changes shouldn't spam
// history), and the focused pane becomes the address bar's owner.
const focusPane = useCallback(
@@ -698,6 +726,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) {
isTopRow
ownsWindowTopLeft
onNavigateInPane={navigateInPane}
+ onResetNewThreadPane={resetNewThreadPane}
/>
>
);
@@ -736,6 +765,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) {
onMovePaneToSide={movePaneToSide}
onResize={resize}
onNavigateInPane={navigateInPane}
+ onResetNewThreadPane={resetNewThreadPane}
onBeginPaneDrag={beginPaneDrag}
onPruneStalePane={pruneStalePane}
/>
@@ -820,6 +850,7 @@ interface SplitTreeProps {
fraction: number,
) => void;
onNavigateInPane: NavigateInPane;
+ onResetNewThreadPane: ResetNewThreadPane;
onBeginPaneDrag: BeginPaneDrag;
onPruneStalePane: (paneId: string) => void;
}
@@ -883,6 +914,7 @@ function SplitTree(props: SplitTreeProps) {
: isTopRow && isLeftEdge
}
onNavigateInPane={props.onNavigateInPane}
+ onResetNewThreadPane={props.onResetNewThreadPane}
onBeginPaneDrag={props.onBeginPaneDrag}
/>
{/* Recede inactive pane bodies without adding another boundary. Pane
@@ -962,6 +994,7 @@ interface WorkspacePaneContentProps {
isTopRow: boolean;
ownsWindowTopLeft: boolean;
onNavigateInPane: NavigateInPane;
+ onResetNewThreadPane?: ResetNewThreadPane;
// Absent for the single-pane surface — a lone pane has nothing to reorder.
onBeginPaneDrag?: BeginPaneDrag;
}
@@ -981,6 +1014,7 @@ function WorkspacePaneContent({
isTopRow,
ownsWindowTopLeft,
onNavigateInPane,
+ onResetNewThreadPane,
onBeginPaneDrag,
}: WorkspacePaneContentProps) {
const navigateInPane = useCallback(
@@ -995,6 +1029,13 @@ function WorkspacePaneContent({
: undefined,
[onBeginPaneDrag, paneId],
);
+ const resetNewThreadPane = useMemo(
+ () =>
+ onResetNewThreadPane
+ ? () => onResetNewThreadPane(paneId)
+ : undefined,
+ [onResetNewThreadPane, paneId],
+ );
const secondaryPanelHost = useMemo(
() =>
secondaryPanelRegistry === null
@@ -1020,6 +1061,7 @@ function WorkspacePaneContent({
isTopRow,
ownsWindowTopLeft,
navigateInPane,
+ resetNewThreadPane,
beginPaneDrag,
}),
[
@@ -1030,6 +1072,7 @@ function WorkspacePaneContent({
isTopRow,
ownsWindowTopLeft,
navigateInPane,
+ resetNewThreadPane,
onRequestClose,
isMaximized,
onToggleMaximize,