authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-16 16:39:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-16 16:39:40-07:00
loge56bb200fa977d016c751af1a65946f0273614e8
treedd2ea0b49f3d0c2f3bcfce03f83dec3f92f39902
parentc2ca9de33ab88d3a5f15530d41b9fe866e5c812f
signature Signed by PGP key B5690EEEBB952194

Add right-panel bulk close and tab context menu actions (#3116)


4 files changed, 266 insertions(+), 22 deletions(-)

apps/web/src/components/ChatView.tsx+109-20
......@@ -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+76-2
......@@ -1,11 +1,20 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
1import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";
22import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
33import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
4import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
4import {
5 type MouseEvent as ReactMouseEvent,
6 type ReactElement,
7 type ReactNode,
8 useCallback,
9 useEffect,
10 useRef,
11 useState,
12} from "react";
513
614import { isElectron } from "~/env";
715import type { RightPanelSurface } from "~/rightPanelStore";
816import { cn } from "~/lib/utils";
17import { readLocalApi } from "~/localApi";
918import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1019import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
1120import { 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
57type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";
58
4459function 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+41
......@@ -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+40
......@@ -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) => {