1import type { ToolCallDiffTarget } from "../../diffPanelStore";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import type { ThreadPreviewState } from "../../previewStateStore";
4import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
5import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
6import { useStableCallback } from "../../hooks/useStableCallback";
7import { type ScopedThreadRef, type RunId } from "@t3tools/contracts";
8import { scopedThreadKey } from "@t3tools/client-runtime/environment";
9import { useEffect } from "react";
10import {
11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";
14import { useDiffPanelStore } from "../../diffPanelStore";
15import { type ThreadPanelPresentation } from "../../rightPanelLayout";
16import {
17 selectThreadPanelOpen,
18 type RightPanelSurface,
19 useRightPanelStore,
20} from "../../rightPanelStore";
21import { requestWorkspaceContentFocus, requestWorkspaceFocus } from "../../lib/workspaceFocus";
22import { setActivePreviewTab } from "../../previewStateStore";
23import { isBrowserSettingsReadError } from "../../browser/openFileInPreview";
24import { addBrowserSurface } from "../preview/addBrowserSurface";
25import { focusActivePanelTerminal, focusTerminal } from "../../lib/terminalFocus";
26import { subscribePreviewAction } from "../preview/previewActionBus";
27import { floatDeviceSurface } from "../preview/agentOpenedPreviews";
28import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../../previewMiniPlayerStore";
29import type { threadPullRequestPanelTarget } from "../pullRequest/pullRequestDetail.logic";
30import type { useDeviceState } from "../../state/device";
31import { stackedThreadToast, toastManager } from "../ui/toast";
32import { openDeviceSetupDialog } from "./ChatOverlayHosts";
33import { previewEnvironment } from "../../state/preview";
34import { useAtomCommand } from "../../state/use-atom-command";
35import type { ChatShortcutCommands } from "./useChatShortcuts";
36interface RightPanelActionsInput {
37 readonly activeThreadRef: ScopedThreadRef | null;
38 readonly activeProject: EnvironmentProject | null;
39 readonly activeRightPanelSurface: RightPanelSurface | null;
40 readonly activePreviewState: ThreadPreviewState;
41 readonly deviceState: ReturnType<typeof useDeviceState>["state"];
42 readonly deviceStateLoaded: boolean;
43 readonly pullRequestPanelTarget: ReturnType<typeof threadPullRequestPanelTarget> | null;
44 readonly threadPanelPresentation: ThreadPanelPresentation;
45 readonly isServerThread: boolean;
46 /** A side chat pane; the main view already answers preview bus actions. */
47 readonly embedded: boolean;
48 readonly isGitRepo: boolean;
49 readonly browserAvailable: boolean;
50 readonly canOperatePreview: boolean;
51 readonly diffOpen: boolean;
52 readonly previewPanelOpen: boolean;
53 readonly rightPanelOpen: boolean;
54 readonly canMaximizeRightPanel: boolean;
55 readonly rightPanelMaximized: boolean;
56 readonly supportsPullRequests: boolean;
57 readonly pullRequestsSurfaceAvailable: boolean;
58 readonly gitCwd: string | null;
59 readonly activeProjectCwd: string | null;
60 readonly onDiffPanelOpen: (() => void) | undefined;
61 readonly closeRightPanelSurface: (surface: RightPanelSurface) => void;
62}
63
64/**
65 * Opening, closing, and arranging the right panel's surfaces: browser, diff, files, pull
66 * requests, device, and terminal tabs.
67 */
68export function useRightPanelActions(input: RightPanelActionsInput) {
69 const {
70 closeRightPanelSurface,
71 activeThreadRef,
72 activeProject,
73 activeRightPanelSurface,
74 activePreviewState,
75 deviceState,
76 deviceStateLoaded,
77 pullRequestPanelTarget,
78 threadPanelPresentation,
79 isServerThread,
80 embedded,
81 isGitRepo,
82 browserAvailable,
83 canOperatePreview,
84 diffOpen,
85 previewPanelOpen,
86 rightPanelOpen,
87 canMaximizeRightPanel,
88 rightPanelMaximized,
89 supportsPullRequests,
90 pullRequestsSurfaceAvailable,
91 gitCwd,
92 activeProjectCwd,
93 onDiffPanelOpen,
94 } = input;
95
96 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
97 const onToggleDiff = () => {
98 if (!isServerThread) {
99 return;
100 }
101 if (!diffOpen) {
102 onDiffPanelOpen?.();
103 }
104 if (activeThreadRef) {
105 useRightPanelStore.getState().toggle(activeThreadRef, "diff");
106 }
107 };
108 const onToggleDiffStable = useStableCallback(onToggleDiff);
109 const createBrowserSurface = (profileId?: string) => {
110 if (!activeThreadRef || !canOperatePreview) return;
111 void addBrowserSurface({
112 threadRef: activeThreadRef,
113 openPreview,
114 ...(profileId === undefined ? {} : { profileId }),
115 }).then((result) => {
116 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
117 const error = squashAtomCommandFailure(result);
118 if (isBrowserSettingsReadError(error)) {
119 toastManager.add(
120 stackedThreadToast({
121 type: "error",
122 title: "Unable to open browser",
123 description: error.message,
124 }),
125 );
126 }
127 });
128 };
129 const createBrowserSurfaceStable = useStableCallback(createBrowserSurface);
130 const addDiffSurface = () => {
131 if (!activeThreadRef || !isServerThread || !isGitRepo) return;
132 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
133 useRightPanelStore.getState().open(activeThreadRef, "diff");
134 onDiffPanelOpen?.();
135 };
136 const addDiffSurfaceStable = useStableCallback(addDiffSurface);
137 const openChangesFromThreadPanel = () => {
138 addDiffSurface();
139 };
140 const openChangesFromThreadPanelStable = useStableCallback(openChangesFromThreadPanel);
141 const addFilesSurface = () => {
142 if (!activeThreadRef || !activeProject) return;
143 useRightPanelStore.getState().open(activeThreadRef, "files");
144 };
145 const addFilesSurfaceStable = useStableCallback(addFilesSurface);
146 const addPullRequestsSurface = () => {
147 if (!activeThreadRef || !pullRequestsSurfaceAvailable) return;
148 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
149 };
150 const addPullRequestsSurfaceStable = useStableCallback(addPullRequestsSurface);
151 const addDeviceSurface = () => {
152 if (!activeThreadRef) return;
153 if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") {
154 openDeviceSetupDialog(scopedThreadKey(activeThreadRef));
155 return;
156 }
157 useRightPanelStore.getState().open(activeThreadRef, "device");
158 };
159 const addDeviceSurfaceStable = useStableCallback(addDeviceSurface);
160 const openFileSurface = (relativePath: string) => {
161 if (!activeThreadRef || !activeProject) return;
162 useRightPanelStore.getState().openFile(activeThreadRef, relativePath);
163 };
164 const openFileSurfaceStable = useStableCallback(openFileSurface);
165 const closePreviewPanel = () => {
166 if (activeThreadRef) {
167 if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
168 usePreviewMiniPlayerStore
169 .getState()
170 .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId));
171 } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) {
172 floatDeviceSurface(
173 activeThreadRef,
174 { kind: "device", ...activeRightPanelSurface.target },
175 deviceStateLoaded ? deviceState : null,
176 );
177 }
178 useRightPanelStore.getState().setMaximized(activeThreadRef, false);
179 useRightPanelStore.getState().close(activeThreadRef);
180 }
181 };
182 const closePreviewPanelStable = useStableCallback(closePreviewPanel);
183 const togglePreviewPanel = () => {
184 if (!activeThreadRef || !browserAvailable) return;
185 if (previewPanelOpen) {
186 closePreviewPanel();
187 return;
188 }
189 if (!canOperatePreview) return;
190 const activeTabId = activePreviewState.activeTabId;
191 if (activeTabId) {
192 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
193 } else {
194 createBrowserSurface();
195 }
196 };
197 const togglePreviewPanelStable = useStableCallback(togglePreviewPanel);
198 const activateRightPanelSurface = (surface: RightPanelSurface) => {
199 if (!activeThreadRef) return;
200 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
201 if (surface.kind === "preview" && surface.resourceId) {
202 setActivePreviewTab(activeThreadRef, surface.resourceId);
203 }
204 if (surface.kind === "terminal") {
205 focusTerminal(activeThreadRef, surface.activeTerminalId);
206 }
207 if (surface.kind === "diff" && !diffOpen) {
208 onDiffPanelOpen?.();
209 }
210 };
211 const activateRightPanelSurfaceStable = useStableCallback(activateRightPanelSurface);
212 const toggleRightPanel = () => {
213 if (!activeThreadRef) return;
214 if (rightPanelOpen) {
215 closePreviewPanel();
216 return;
217 }
218 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
219 focusActivePanelTerminal(activeThreadRef);
220 };
221 const toggleRightPanelStable = useStableCallback(toggleRightPanel);
222 const toggleThreadPanel = () => {
223 if (!activeThreadRef) return;
224 const store = useRightPanelStore.getState();
225 if (threadPanelPresentation === "inline") {
226 const open = selectThreadPanelOpen(
227 store.threadPanelVisibilityByThreadKey,
228 activeThreadRef,
229 "inline",
230 );
231 if (!open) requestWorkspaceFocus({ area: "details" });
232 else if (document.activeElement?.closest('[data-workspace-area="details"]'))
233 requestWorkspaceContentFocus();
234 }
235 store.toggleThreadPanel(activeThreadRef, threadPanelPresentation);
236 };
237 const toggleThreadPanelStable = useStableCallback(toggleThreadPanel);
238 const toggleRightPanelMaximized = () => {
239 if (!canMaximizeRightPanel || !activeThreadRef) return;
240 if (!rightPanelMaximized) requestWorkspaceFocus({ area: "panel" });
241 useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);
242 };
243 const toggleRightPanelMaximizedStable = useStableCallback(toggleRightPanelMaximized);
244 const moveRightPanelSurface = (surfaceId: string, toIndex: number) => {
245 if (activeThreadRef)
246 useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex);
247 };
248 const moveRightPanelSurfaceStable = useStableCallback(moveRightPanelSurface);
249 const copyRightPanelFilePath = (relativePath: string) => {
250 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
251 toastManager.add(
252 stackedThreadToast({
253 type: "error",
254 title: "Failed to copy path",
255 description: "Clipboard API unavailable.",
256 }),
257 );
258 return;
259 }
260
261 const clipboardPath = formatFilePathForClipboard(
262 relativePath,
263 resolvePathLinkTarget(relativePath, gitCwd ?? activeProjectCwd ?? ""),
264 );
265 void navigator.clipboard.writeText(clipboardPath).then(
266 () => {
267 toastManager.add({
268 type: "success",
269 title: "Path copied",
270 description: clipboardPath,
271 });
272 },
273 (error) => {
274 toastManager.add(
275 stackedThreadToast({
276 type: "error",
277 title: "Failed to copy path",
278 description: error instanceof Error ? error.message : "An error occurred.",
279 }),
280 );
281 },
282 );
283 };
284 const copyRightPanelFilePathStable = useStableCallback(copyRightPanelFilePath);
285 const addPullRequestSurface = () => {
286 if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null)
287 return;
288 useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget);
289 };
290 const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface);
291 const onOpenTurnDiff = (runId: RunId, filePath?: string) => {
292 if (!isServerThread || !activeThreadRef) return;
293 useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
294 useRightPanelStore.getState().open(activeThreadRef, "diff");
295 onDiffPanelOpen?.();
296 };
297 const onOpenToolDiffStable = useStableCallback((target: ToolCallDiffTarget) => {
298 if (!isServerThread || !activeThreadRef) return;
299 useDiffPanelStore.getState().selectToolCall(activeThreadRef, target);
300 useRightPanelStore.getState().open(activeThreadRef, "diff");
301 onDiffPanelOpen?.();
302 });
303 const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff);
304 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the preview action bus to toggle the preview panel
305 useEffect(
306 () =>
307 embedded
308 ? undefined
309 : subscribePreviewAction((action) => {
310 if (action === "toggle-panel") togglePreviewPanelStable();
311 }),
312 [embedded, togglePreviewPanelStable],
313 );
314
315 const shortcuts = {
316 "rightPanel.toggle": () => toggleRightPanel(),
317 "rightPanel.toggleMaximized": ({ event }) => {
318 if (!event.repeat) toggleRightPanelMaximized();
319 },
320 "threadPanel.toggle": () => toggleThreadPanel(),
321 "rightPanel.close": ({ event }) => {
322 if (!activeRightPanelSurface) return false;
323 if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface);
324 },
325 "diff.toggle": () => onToggleDiff(),
326 } satisfies ChatShortcutCommands;
327
328 return {
329 shortcuts,
330 activateRightPanelSurfaceStable,
331 addDeviceSurfaceStable,
332 addDiffSurfaceStable,
333 addFilesSurfaceStable,
334 addPullRequestSurfaceStable,
335 addPullRequestsSurfaceStable,
336 closePreviewPanelStable,
337 copyRightPanelFilePathStable,
338 createBrowserSurface,
339 createBrowserSurfaceStable,
340 moveRightPanelSurfaceStable,
341 onOpenToolDiffStable,
342 onOpenTurnDiffStable,
343 onToggleDiffStable,
344 openChangesFromThreadPanelStable,
345 openFileSurfaceStable,
346 toggleRightPanelMaximizedStable,
347 toggleRightPanelStable,
348 toggleThreadPanelStable,
349 };
350}