Skip to content

Commit e56bb20

Browse files
Add right-panel bulk close and tab context menu actions (pingdotgg#3116)
1 parent c2ca9de commit e56bb20

4 files changed

Lines changed: 266 additions & 22 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 109 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3100,26 +3100,19 @@ function ChatViewContent(props: ChatViewProps) {
31003100
threadKey === routeThreadKey ? null : routeThreadKey,
31013101
);
31023102
};
3103-
const closeRightPanelSurface = useCallback(
3104-
(surface: RightPanelSurface) => {
3103+
const cleanupRightPanelSurfaces = useCallback(
3104+
(surfaces: readonly RightPanelSurface[]) => {
31053105
if (!activeThreadRef) return;
3106-
if (surface.kind === "preview" && surface.resourceId) {
3107-
usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId);
3108-
const api = readEnvironmentApi(activeThreadRef.environmentId);
3109-
void api?.preview
3110-
.close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId })
3111-
.catch(() => undefined);
3112-
}
3113-
useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
3114-
const nextActiveSurface = selectActiveRightPanelSurface(
3115-
useRightPanelStore.getState().byThreadKey,
3116-
activeThreadRef,
3117-
);
3118-
if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) {
3119-
usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId);
3120-
}
3121-
if (surface.kind === "terminal") {
3122-
const api = readEnvironmentApi(activeThreadRef.environmentId);
3106+
3107+
const api = readEnvironmentApi(activeThreadRef.environmentId);
3108+
for (const surface of surfaces) {
3109+
if (surface.kind === "preview" && surface.resourceId) {
3110+
usePreviewStateStore.getState().removeSession(activeThreadRef, surface.resourceId);
3111+
void api?.preview
3112+
.close({ threadId: activeThreadRef.threadId, tabId: surface.resourceId })
3113+
.catch(() => undefined);
3114+
}
3115+
if (surface.kind !== "terminal") continue;
31233116
for (const terminalId of surface.terminalIds) {
31243117
void api?.terminal
31253118
.close({
@@ -3130,7 +3123,8 @@ function ChatViewContent(props: ChatViewProps) {
31303123
.catch(() => undefined);
31313124
}
31323125
}
3133-
if (surface.kind === "diff" && diffOpen) {
3126+
3127+
if (diffOpen && surfaces.some((surface) => surface.kind === "diff")) {
31343128
void navigate({
31353129
to: "/$environmentId/$threadId",
31363130
params: { environmentId, threadId },
@@ -3141,6 +3135,93 @@ function ChatViewContent(props: ChatViewProps) {
31413135
},
31423136
[activeThreadRef, diffOpen, environmentId, navigate, threadId],
31433137
);
3138+
const syncActivePreviewSurface = useCallback(() => {
3139+
if (!activeThreadRef) return;
3140+
const nextActiveSurface = selectActiveRightPanelSurface(
3141+
useRightPanelStore.getState().byThreadKey,
3142+
activeThreadRef,
3143+
);
3144+
if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) {
3145+
usePreviewStateStore.getState().setActiveTab(activeThreadRef, nextActiveSurface.resourceId);
3146+
}
3147+
}, [activeThreadRef]);
3148+
const closeRightPanelSurface = useCallback(
3149+
(surface: RightPanelSurface) => {
3150+
if (!activeThreadRef) return;
3151+
cleanupRightPanelSurfaces([surface]);
3152+
useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
3153+
syncActivePreviewSurface();
3154+
},
3155+
[activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface],
3156+
);
3157+
const closeOtherRightPanelSurfaces = useCallback(
3158+
(surface: RightPanelSurface) => {
3159+
if (!activeThreadRef) return;
3160+
const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);
3161+
cleanupRightPanelSurfaces(surfaces);
3162+
useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id);
3163+
syncActivePreviewSurface();
3164+
},
3165+
[
3166+
activeThreadRef,
3167+
cleanupRightPanelSurfaces,
3168+
rightPanelState.surfaces,
3169+
syncActivePreviewSurface,
3170+
],
3171+
);
3172+
const closeRightPanelSurfacesToRight = useCallback(
3173+
(surface: RightPanelSurface) => {
3174+
if (!activeThreadRef) return;
3175+
const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
3176+
if (surfaceIndex < 0) return;
3177+
const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);
3178+
cleanupRightPanelSurfaces(surfaces);
3179+
useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id);
3180+
syncActivePreviewSurface();
3181+
},
3182+
[
3183+
activeThreadRef,
3184+
cleanupRightPanelSurfaces,
3185+
rightPanelState.surfaces,
3186+
syncActivePreviewSurface,
3187+
],
3188+
);
3189+
const closeAllRightPanelSurfaces = useCallback(() => {
3190+
if (!activeThreadRef) return;
3191+
cleanupRightPanelSurfaces(rightPanelState.surfaces);
3192+
useRightPanelStore.getState().closeAllSurfaces(activeThreadRef);
3193+
}, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]);
3194+
const copyRightPanelFilePath = useCallback((relativePath: string) => {
3195+
if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
3196+
toastManager.add(
3197+
stackedThreadToast({
3198+
type: "error",
3199+
title: "Failed to copy path",
3200+
description: "Clipboard API unavailable.",
3201+
}),
3202+
);
3203+
return;
3204+
}
3205+
3206+
void navigator.clipboard.writeText(relativePath).then(
3207+
() => {
3208+
toastManager.add({
3209+
type: "success",
3210+
title: "Path copied",
3211+
description: relativePath,
3212+
});
3213+
},
3214+
(error) => {
3215+
toastManager.add(
3216+
stackedThreadToast({
3217+
type: "error",
3218+
title: "Failed to copy path",
3219+
description: error instanceof Error ? error.message : "An error occurred.",
3220+
}),
3221+
);
3222+
},
3223+
);
3224+
}, []);
31443225
const persistThreadSettingsForNextTurn = useCallback(
31453226
async (input: {
31463227
threadId: ThreadId;
@@ -4851,6 +4932,10 @@ function ChatViewContent(props: ChatViewProps) {
48514932
terminalLabelsById={activeTerminalLabelsById}
48524933
onActivate={activateRightPanelSurface}
48534934
onCloseSurface={closeRightPanelSurface}
4935+
onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
4936+
onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
4937+
onCloseAllSurfaces={closeAllRightPanelSurfaces}
4938+
onCopyFilePath={copyRightPanelFilePath}
48544939
onAddBrowser={createBrowserSurface}
48554940
onAddTerminal={addTerminalSurface}
48564941
onAddDiff={addDiffSurface}
@@ -4929,6 +5014,10 @@ function ChatViewContent(props: ChatViewProps) {
49295014
terminalLabelsById={activeTerminalLabelsById}
49305015
onActivate={activateRightPanelSurface}
49315016
onCloseSurface={closeRightPanelSurface}
5017+
onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
5018+
onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
5019+
onCloseAllSurfaces={closeAllRightPanelSurfaces}
5020+
onCopyFilePath={copyRightPanelFilePath}
49325021
onAddBrowser={createBrowserSurface}
49335022
onAddTerminal={addTerminalSurface}
49345023
onAddDiff={addDiffSurface}

apps/web/src/components/RightPanelTabs.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1-
import type { PreviewSessionSnapshot } from "@t3tools/contracts";
1+
import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";
22
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
33
import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
4-
import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
4+
import {
5+
type MouseEvent as ReactMouseEvent,
6+
type ReactElement,
7+
type ReactNode,
8+
useCallback,
9+
useEffect,
10+
useRef,
11+
useState,
12+
} from "react";
513

614
import { isElectron } from "~/env";
715
import type { RightPanelSurface } from "~/rightPanelStore";
816
import { cn } from "~/lib/utils";
17+
import { readLocalApi } from "~/localApi";
918
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1019
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
1120
import { ScrollArea } from "~/components/ui/scroll-area";
@@ -25,6 +34,10 @@ interface RightPanelTabsProps {
2534
terminalLabelsById: ReadonlyMap<string, string>;
2635
onActivate: (surface: RightPanelSurface) => void;
2736
onCloseSurface: (surface: RightPanelSurface) => void;
37+
onCloseOtherSurfaces: (surface: RightPanelSurface) => void;
38+
onCloseSurfacesToRight: (surface: RightPanelSurface) => void;
39+
onCloseAllSurfaces: () => void;
40+
onCopyFilePath: (relativePath: string) => void;
2841
onAddBrowser: () => void;
2942
onAddTerminal: () => void;
3043
onAddDiff: () => void;
@@ -41,6 +54,8 @@ const SURFACE_DISABLED_REASONS = {
4154
diff: "Diff is only available for server threads in Git repositories.",
4255
} as const;
4356

57+
type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";
58+
4459
function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
4560
return (
4661
<Tooltip>
@@ -257,6 +272,64 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
257272
const { resolvedTheme } = useTheme();
258273
const tabListRef = useRef<HTMLDivElement>(null);
259274

275+
const handleTabContextMenu = useCallback(
276+
async (event: ReactMouseEvent, surface: RightPanelSurface) => {
277+
event.preventDefault();
278+
event.stopPropagation();
279+
280+
const api = readLocalApi();
281+
if (!api) return;
282+
283+
const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id);
284+
if (surfaceIndex < 0) return;
285+
286+
const items: ContextMenuItem<TabContextMenuAction>[] = [];
287+
if (surface.kind === "file") {
288+
items.push({ id: "copy-path", label: "Copy path" });
289+
}
290+
items.push(
291+
{ id: "close", label: "Close" },
292+
{
293+
id: "close-others",
294+
label: "Close others",
295+
disabled: props.surfaces.length <= 1,
296+
},
297+
{
298+
id: "close-to-right",
299+
label: "Close to the right",
300+
disabled: surfaceIndex >= props.surfaces.length - 1,
301+
},
302+
{
303+
id: "close-all",
304+
label: "Close all",
305+
disabled: props.surfaces.length === 0,
306+
},
307+
);
308+
309+
const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY });
310+
switch (action) {
311+
case "copy-path":
312+
if (surface.kind === "file") props.onCopyFilePath(surface.relativePath);
313+
break;
314+
case "close":
315+
props.onCloseSurface(surface);
316+
break;
317+
case "close-others":
318+
props.onCloseOtherSurfaces(surface);
319+
break;
320+
case "close-to-right":
321+
props.onCloseSurfacesToRight(surface);
322+
break;
323+
case "close-all":
324+
props.onCloseAllSurfaces();
325+
break;
326+
case null:
327+
break;
328+
}
329+
},
330+
[props],
331+
);
332+
260333
useEffect(() => {
261334
const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']");
262335
activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
@@ -291,6 +364,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
291364
<div
292365
key={surface.id}
293366
data-active-tab={active}
367+
onContextMenu={(event) => void handleTabContextMenu(event, surface)}
294368
className={cn(
295369
"group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm",
296370
active

apps/web/src/rightPanelStore.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,47 @@ describe("rightPanelStore", () => {
304304
});
305305
});
306306

307+
it("closing other surfaces keeps the selected surface active", () => {
308+
useRightPanelStore.getState().openBrowser(refA, "tab-a");
309+
useRightPanelStore.getState().openFile(refA, "src/index.ts");
310+
useRightPanelStore.getState().openTerminal(refA, "term-1");
311+
312+
useRightPanelStore.getState().closeOtherSurfaces(refA, "file:src/index.ts");
313+
314+
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
315+
isOpen: true,
316+
activeSurfaceId: "file:src/index.ts",
317+
surfaces: [{ id: "file:src/index.ts", kind: "file", relativePath: "src/index.ts" }],
318+
});
319+
});
320+
321+
it("closing surfaces to the right activates the selected surface when active was removed", () => {
322+
useRightPanelStore.getState().openBrowser(refA, "tab-a");
323+
useRightPanelStore.getState().openFile(refA, "src/index.ts");
324+
useRightPanelStore.getState().openTerminal(refA, "term-1");
325+
326+
useRightPanelStore.getState().closeSurfacesToRight(refA, "browser:tab-a");
327+
328+
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
329+
isOpen: true,
330+
activeSurfaceId: "browser:tab-a",
331+
surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }],
332+
});
333+
});
334+
335+
it("closing all surfaces leaves the panel open and empty", () => {
336+
useRightPanelStore.getState().openBrowser(refA, "tab-a");
337+
useRightPanelStore.getState().openFile(refA, "src/index.ts");
338+
339+
useRightPanelStore.getState().closeAllSurfaces(refA);
340+
341+
expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({
342+
isOpen: true,
343+
activeSurfaceId: null,
344+
surfaces: [],
345+
});
346+
});
347+
307348
it("reconciles browser surfaces without deleting other surface kinds", () => {
308349
useRightPanelStore.getState().openTerminal(refA, "term-1");
309350
useRightPanelStore.getState().openBrowser(refA, "tab-a");

apps/web/src/rightPanelStore.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ interface RightPanelStoreState {
5858
closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
5959
activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
6060
closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
61+
closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
62+
closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
63+
closeAllSurfaces: (ref: ScopedThreadRef) => void;
6164
reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void;
6265
reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;
6366
show: (ref: ScopedThreadRef) => void;
@@ -334,6 +337,43 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
334337
return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null };
335338
}),
336339
})),
340+
closeOtherSurfaces: (ref, surfaceId) =>
341+
set((state) => ({
342+
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
343+
const surface = current.surfaces.find((entry) => entry.id === surfaceId);
344+
if (!surface || current.surfaces.length === 1) return current;
345+
return {
346+
...current,
347+
isOpen: true,
348+
surfaces: [surface],
349+
activeSurfaceId: surface.id,
350+
};
351+
}),
352+
})),
353+
closeSurfacesToRight: (ref, surfaceId) =>
354+
set((state) => ({
355+
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
356+
const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
357+
if (index < 0 || index === current.surfaces.length - 1) return current;
358+
const surfaces = current.surfaces.slice(0, index + 1);
359+
const activeStillExists = surfaces.some(
360+
(surface) => surface.id === current.activeSurfaceId,
361+
);
362+
return {
363+
...current,
364+
surfaces,
365+
activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,
366+
};
367+
}),
368+
})),
369+
closeAllSurfaces: (ref) =>
370+
set((state) => ({
371+
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
372+
current.surfaces.length === 0
373+
? current
374+
: { ...current, isOpen: true, surfaces: [], activeSurfaceId: null },
375+
),
376+
})),
337377
reconcileBrowserSurfaces: (ref, tabIds) =>
338378
set((state) => ({
339379
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {

0 commit comments

Comments
 (0)