Skip to content

Commit 41a991f

Browse files
refactor(web): register the Preview side panel
Preview becomes the second panel on the side-panel registry that Diff started. Each definition now also carries the panel's title, icon, launcher letter, client support and unavailable copy, so the tabs, the empty launcher and the add menu read one ordered list instead of three hand-kept ones. Labels, letters, order and copy are unchanged. Panel props are inferred from each lazily loaded body, and the caller is a closed union, so another panel's props, unknown ids and widened ids do not compile. ChatView lends the rendered panel a small host (thread, right panel visibility, composer draft target, workspace mutation id and the annotation send) instead of drilling the same props into each body; the annotation send keeps the per-render closure it had before, so a pick that settles after navigation still sends from the thread it started in. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 46e8b68 commit 41a991f

14 files changed

Lines changed: 959 additions & 322 deletions

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
159159
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
160160
import { isMacPlatform } from "../lib/utils";
161161
import { RegisteredSidePanel } from "~/panels/bundledPanels";
162+
import { PanelHostContext, type PanelHost } from "~/panels/panelHost";
162163
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
163164
import { useShallow } from "zustand/react/shallow";
164165
import {
@@ -678,9 +679,6 @@ function useDraftHeroLayoutTransition(
678679
} as const;
679680
}
680681

681-
const PreviewPanel = lazy(() =>
682-
import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
683-
);
684682
const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
685683
settings.browserAutoShowFloatingPreview;
686684
const DevicePanel = lazy(() =>
@@ -10516,20 +10514,13 @@ export default function ChatView(props: ChatViewProps) {
1051610514
return <NoActiveThreadState />;
1051710515
}
1051810516

10519-
const rightPanelContent = activeThreadRef ? (
10517+
const rightPanelSurfaceContent = activeThreadRef ? (
1052010518
renderedRightPanelSurface?.kind === "preview" ? (
10521-
<Suspense fallback={null}>
10522-
<PreviewPanel
10523-
mode="embedded"
10524-
threadRef={activeThreadRef}
10525-
tabId={renderedRightPanelSurface.resourceId}
10526-
configuredUrls={configuredPreviewUrls}
10527-
visible={rightPanelOpen}
10528-
onSendAnnotation={(annotation, image) => {
10529-
void onSend(undefined, "auto", "foreground", { annotation, image });
10530-
}}
10531-
/>
10532-
</Suspense>
10519+
<RegisteredSidePanel
10520+
id="preview"
10521+
tabId={renderedRightPanelSurface.resourceId}
10522+
configuredUrls={configuredPreviewUrls}
10523+
/>
1053310524
) : renderedRightPanelSurface?.kind === "terminal" ? (
1053410525
<PersistentThreadTerminalPanel
1053510526
visible={rightPanelOpen}
@@ -10550,14 +10541,7 @@ export default function ChatView(props: ChatViewProps) {
1055010541
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
1055110542
/>
1055210543
) : renderedRightPanelSurface?.kind === "diff" ? (
10553-
<Suspense fallback={null}>
10554-
<RegisteredSidePanel
10555-
key={activeThreadKey}
10556-
id="diff"
10557-
composerDraftTarget={composerDraftTarget}
10558-
workspaceMutationId={workspaceMutationId}
10559-
/>
10560-
</Suspense>
10544+
<RegisteredSidePanel key={activeThreadKey} id="diff" />
1056110545
) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
1056210546
<PullRequestDetailGhost />
1056310547
) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
@@ -10674,6 +10658,25 @@ export default function ChatView(props: ChatViewProps) {
1067410658
</Suspense>
1067510659
) : null
1067610660
) : null;
10661+
const panelHost: PanelHost | null = activeThreadRef
10662+
? {
10663+
threadRef: activeThreadRef,
10664+
visible: rightPanelOpen,
10665+
composerDraftTarget,
10666+
workspaceMutationId,
10667+
// A pick that settles after navigation still sends through the thread it started in.
10668+
sendAnnotation: (annotation, image) => {
10669+
void onSend(undefined, "auto", "foreground", { annotation, image });
10670+
},
10671+
}
10672+
: null;
10673+
const rightPanelContent = (
10674+
<PanelHostContext value={panelHost}>{rightPanelSurfaceContent}</PanelHostContext>
10675+
);
10676+
const sidePanelLaunchers = {
10677+
preview: { available: true, onOpen: createBrowserSurface },
10678+
diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurface },
10679+
};
1067710680
const threadDetailsPanelProps: ThreadDetailsPanelProps = {
1067810681
anchor: threadPanelPopoverAnchorRef,
1067910682
handle: threadPanelPopoverHandle,
@@ -11485,17 +11488,14 @@ export default function ChatView(props: ChatViewProps) {
1148511488
onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
1148611489
onCloseAllSurfaces={closeAllRightPanelSurfaces}
1148711490
onCopyFilePath={copyRightPanelFilePath}
11488-
onAddBrowser={() => createBrowserSurface()}
11491+
panels={sidePanelLaunchers}
1148911492
onAddBrowserInProfile={createBrowserSurface}
1149011493
onAddTerminal={addTerminalSurface}
11491-
onAddDiff={addDiffSurface}
1149211494
onAddFiles={addFilesSurface}
1149311495
onAddPullRequest={addPullRequestSurface}
1149411496
onAddPullRequests={addPullRequestsSurface}
1149511497
onAddDevice={addDeviceSurface}
11496-
browserAvailable={isPreviewSupportedInRuntime()}
1149711498
terminalAvailable={activeProject !== null}
11498-
diffAvailable={isServerThread && isGitRepo}
1149911499
filesAvailable={activeProject !== null}
1150011500
pullRequestAvailable={pullRequestSurfaceAvailable}
1150111501
pullRequestsAvailable={pullRequestsSurfaceAvailable}
@@ -11540,17 +11540,14 @@ export default function ChatView(props: ChatViewProps) {
1154011540
onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
1154111541
onCloseAllSurfaces={closeAllRightPanelSurfaces}
1154211542
onCopyFilePath={copyRightPanelFilePath}
11543-
onAddBrowser={() => createBrowserSurface()}
11543+
panels={sidePanelLaunchers}
1154411544
onAddBrowserInProfile={createBrowserSurface}
1154511545
onAddTerminal={addTerminalSurface}
11546-
onAddDiff={addDiffSurface}
1154711546
onAddFiles={addFilesSurface}
1154811547
onAddPullRequest={addPullRequestSurface}
1154911548
onAddPullRequests={addPullRequestsSurface}
1155011549
onAddDevice={addDeviceSurface}
11551-
browserAvailable={isPreviewSupportedInRuntime()}
1155211550
terminalAvailable={activeProject !== null}
11553-
diffAvailable={isServerThread && isGitRepo}
1155411551
filesAvailable={activeProject !== null}
1155511552
pullRequestAvailable={pullRequestSurfaceAvailable}
1155611553
pullRequestsAvailable={pullRequestsSurfaceAvailable}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// @vitest-environment jsdom
2+
3+
import {
4+
BUILT_IN_BROWSER_PROFILES,
5+
DEFAULT_BROWSER_PROFILE_ID,
6+
INCOGNITO_BROWSER_PROFILE_ID,
7+
} from "@t3tools/contracts";
8+
import { act } from "react";
9+
import { createRoot, type Root } from "react-dom/client";
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11+
12+
// The profile list normally comes from client settings; the built-ins are enough to choose from.
13+
vi.mock("~/browser/browserDefaults", async (importOriginal) => ({
14+
...(await importOriginal<typeof import("~/browser/browserDefaults")>()),
15+
useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }),
16+
}));
17+
18+
import { RightPanelTabs } from "./RightPanelTabs";
19+
20+
let root: Root;
21+
let container: HTMLDivElement;
22+
let opened: string[];
23+
24+
beforeEach(() => {
25+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
26+
// Browser is desktop-only; the launcher offers it once the preview bridge exists.
27+
vi.stubGlobal("desktopBridge", { preview: {} });
28+
vi.stubGlobal(
29+
"ResizeObserver",
30+
class {
31+
observe() {}
32+
disconnect() {}
33+
},
34+
);
35+
// jsdom lacks the Web Animations API that the tab bar's scroll area waits on.
36+
Element.prototype.getAnimations ??= () => [];
37+
opened = [];
38+
container = document.createElement("div");
39+
document.body.append(container);
40+
root = createRoot(container);
41+
});
42+
43+
afterEach(async () => {
44+
await act(async () => root.unmount());
45+
container.remove();
46+
vi.unstubAllGlobals();
47+
});
48+
49+
// Records Browser opens the way ChatView's createBrowserSurface does: one
50+
// handler for the default open and the profile chooser, where an omitted
51+
// profile means the default one.
52+
function Harness() {
53+
const openBrowser = (profileId?: string) => {
54+
opened.push(profileId ?? DEFAULT_BROWSER_PROFILE_ID);
55+
};
56+
return (
57+
<RightPanelTabs
58+
mode="inline"
59+
surfaces={[]}
60+
environmentId={null}
61+
activeSurfaceId={null}
62+
pendingSurfaceIds={new Set()}
63+
previewSessions={{}}
64+
desktopByTabId={{}}
65+
terminalLabelsById={new Map()}
66+
onActivate={() => undefined}
67+
onCloseSurface={() => undefined}
68+
onCloseOtherSurfaces={() => undefined}
69+
onCloseSurfacesToRight={() => undefined}
70+
onCloseAllSurfaces={() => undefined}
71+
onCopyFilePath={() => undefined}
72+
panels={{
73+
preview: { available: true, onOpen: openBrowser },
74+
diff: { available: false, onOpen: () => undefined },
75+
}}
76+
onAddBrowserInProfile={openBrowser}
77+
onAddTerminal={() => undefined}
78+
onAddPullRequest={() => undefined}
79+
onAddPullRequests={() => undefined}
80+
onAddFiles={() => undefined}
81+
onAddDevice={() => undefined}
82+
terminalAvailable={false}
83+
filesAvailable={false}
84+
pullRequestAvailable={false}
85+
pullRequestsAvailable={false}
86+
deviceAvailable={false}
87+
>
88+
{null}
89+
</RightPanelTabs>
90+
);
91+
}
92+
93+
function launcherRow(label: string): HTMLButtonElement {
94+
const launcher = container.querySelector('[aria-label="Open a surface"]');
95+
const row = [...(launcher?.querySelectorAll("button") ?? [])].find((button) =>
96+
button.textContent?.startsWith(label),
97+
);
98+
if (!row) throw new Error(`No launcher row ${label}`);
99+
return row;
100+
}
101+
102+
function menuItem(label: string): HTMLElement {
103+
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
104+
(element) => element.textContent === label,
105+
);
106+
if (!item) throw new Error(`No menu item ${label}`);
107+
return item;
108+
}
109+
110+
async function click(element: Element) {
111+
await act(async () => {
112+
element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
113+
element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
114+
element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
115+
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
116+
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
117+
});
118+
}
119+
120+
describe("opening Browser from the launcher", () => {
121+
it("opens the default profile from the row and another profile from the chooser", async () => {
122+
await act(async () => root.render(<Harness />));
123+
124+
await click(launcherRow("Browser"));
125+
expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]);
126+
127+
await click(container.querySelector('[aria-label="Open browser in a profile"]')!);
128+
await click(menuItem("Incognito"));
129+
expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]);
130+
});
131+
});

‎apps/web/src/components/RightPanelTabs.test.tsx‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
22
import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
33
import { renderToStaticMarkup } from "react-dom/server";
4-
import { describe, expect, it } from "vite-plus/test";
4+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
55

66
import {
77
RightPanelTabs,
88
resolvePullRequestTabLink,
9+
rightPanelSurfaceActions,
910
shouldOpenDefaultBrowserProfileFromMenuClick,
1011
surfaceShortcutActionForKey,
1112
surfaceShortcutTargetsTypingContext,
@@ -20,6 +21,56 @@ describe("browser profile submenu", () => {
2021
});
2122
});
2223

24+
describe("right panel surface actions", () => {
25+
afterEach(() => {
26+
vi.unstubAllGlobals();
27+
});
28+
29+
const inputs = () => ({
30+
panels: {
31+
preview: { available: true, onOpen: () => undefined },
32+
diff: { available: true, onOpen: () => undefined },
33+
},
34+
onAddTerminal: () => undefined,
35+
onAddFiles: () => undefined,
36+
onAddPullRequest: () => undefined,
37+
onAddPullRequests: () => undefined,
38+
onAddDevice: () => undefined,
39+
terminalAvailable: true,
40+
filesAvailable: true,
41+
pullRequestAvailable: true,
42+
pullRequestsAvailable: true,
43+
deviceAvailable: true,
44+
});
45+
46+
it("keeps launcher order, letters and copy for registered and local surfaces", () => {
47+
const actions = rightPanelSurfaceActions(inputs());
48+
expect(actions.map((action) => [action.shortcut, action.label])).toEqual([
49+
["B", "Browser"],
50+
["T", "Terminal"],
51+
["F", "Files"],
52+
["D", "Diff"],
53+
["P", "Pull request"],
54+
["L", "Linked pull requests"],
55+
["M", "Device"],
56+
]);
57+
expect(actions.find((action) => action.id === "diff")).toMatchObject({
58+
unavailableHint: "Available for Git repositories.",
59+
unavailableReason: "Diff is only available for server threads in Git repositories.",
60+
});
61+
});
62+
63+
it("keeps desktop-only Preview unavailable on web even when the thread allows it", () => {
64+
vi.stubGlobal("window", {});
65+
expect(rightPanelSurfaceActions(inputs())[0]).toMatchObject({
66+
id: "preview",
67+
available: false,
68+
});
69+
vi.stubGlobal("window", { desktopBridge: { preview: {} } });
70+
expect(rightPanelSurfaceActions(inputs())[0]).toMatchObject({ id: "preview", available: true });
71+
});
72+
});
73+
2374
function shortcutEvent(
2475
key: string,
2576
overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},
@@ -115,17 +166,17 @@ function renderTabs(
115166
onCloseSurfacesToRight={() => undefined}
116167
onCloseAllSurfaces={() => undefined}
117168
onCopyFilePath={() => undefined}
118-
onAddBrowser={() => undefined}
169+
panels={{
170+
preview: { available: true, onOpen: () => undefined },
171+
diff: { available: false, onOpen: () => undefined },
172+
}}
119173
onAddBrowserInProfile={() => undefined}
120174
onAddTerminal={() => undefined}
121175
onAddPullRequest={() => undefined}
122176
onAddPullRequests={() => undefined}
123-
onAddDiff={() => undefined}
124177
onAddFiles={() => undefined}
125178
onAddDevice={() => undefined}
126-
browserAvailable
127179
terminalAvailable={false}
128-
diffAvailable={false}
129180
filesAvailable={false}
130181
pullRequestAvailable={false}
131182
pullRequestsAvailable={false}

0 commit comments

Comments
 (0)