Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 61 additions & 33 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ import { flushSync } from "react-dom";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
import { isMacPlatform } from "../lib/utils";
import { RegisteredSidePanel } from "~/panels/bundledPanels";
import {
PanelHostContext,
threadBoundAnnotationSender,
type PanelHost,
type ThreadAnnotationSender,
} from "~/panels/panelHost";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { useShallow } from "zustand/react/shallow";
import {
Expand Down Expand Up @@ -701,10 +708,6 @@ function useDraftHeroLayoutTransition(
} as const;
}

const PreviewPanel = lazy(() =>
import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
);
const DiffPanel = lazy(() => import("./DiffPanel"));
const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
settings.browserAutoShowFloatingPreview;
const DevicePanel = lazy(() =>
Expand Down Expand Up @@ -11073,25 +11076,56 @@ export default function ChatView(props: ChatViewProps) {
pendingSidebarFileDrops,
]);

// Plain server threads share one ChatView, so the sender carries its thread
// and a host only forwards to a sender for the thread it was built for.
// Updated after commit so a discarded render cannot lend its `onSend`.
const annotationSenderRef = useRef<ThreadAnnotationSender | null>(null);
useLayoutEffect(() => {
annotationSenderRef.current = activeThreadKey
? {
threadKey: activeThreadKey,
send: (annotation, image) => {
void onSend(undefined, "auto", "foreground", { annotation, image });
},
}
: null;
});
// Memoized so mounted panels re-render only when a host field changes.
const panelHost = useMemo<PanelHost | null>(
() =>
activeThreadRef
? {
threadRef: activeThreadRef,
visible: rightPanelOpen,
composerDraftTarget,
workspaceMutationId,
sendAnnotation: threadBoundAnnotationSender(
() => annotationSenderRef.current,
scopedThreadKey(activeThreadRef),
),
}
: null,
[
activeThreadRef,
annotationSenderRef,
composerDraftTarget,
rightPanelOpen,
workspaceMutationId,
],
);

// Empty state: no active thread
if (!activeThread) {
return <NoActiveThreadState />;
}

const rightPanelContent = activeThreadRef ? (
const rightPanelSurfaceContent = activeThreadRef ? (
renderedRightPanelSurface?.kind === "preview" ? (
<Suspense fallback={null}>
<PreviewPanel
mode="embedded"
threadRef={activeThreadRef}
tabId={renderedRightPanelSurface.resourceId}
configuredUrls={configuredPreviewUrls}
visible={rightPanelOpen}
onSendAnnotation={(annotation, image) => {
void onSend(undefined, "auto", "foreground", { annotation, image });
}}
/>
</Suspense>
<RegisteredSidePanel
id="preview"
tabId={renderedRightPanelSurface.resourceId}
configuredUrls={configuredPreviewUrls}
/>
) : renderedRightPanelSurface?.kind === "terminal" ? (
<PersistentThreadTerminalPanel
visible={rightPanelOpen}
Expand All @@ -11112,14 +11146,7 @@ export default function ChatView(props: ChatViewProps) {
closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
/>
) : renderedRightPanelSurface?.kind === "diff" ? (
<Suspense fallback={null}>
<DiffPanel
key={activeThreadKey}
mode="embedded"
composerDraftTarget={composerDraftTarget}
workspaceMutationId={workspaceMutationId}
/>
</Suspense>
<RegisteredSidePanel key={activeThreadKey} id="diff" />
) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
<PullRequestDetailGhost />
) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
Expand Down Expand Up @@ -11236,6 +11263,13 @@ export default function ChatView(props: ChatViewProps) {
</Suspense>
) : null
) : null;
const rightPanelContent = (
<PanelHostContext value={panelHost}>{rightPanelSurfaceContent}</PanelHostContext>
);
const sidePanelLaunchers = {
preview: { available: canOperatePreview && browserAvailable, onOpen: createBrowserSurface },
diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurface },
};
const threadDetailsPanelProps: ThreadDetailsPanelProps = {
anchor: threadPanelPopoverAnchorRef,
handle: threadPanelPopoverHandle,
Expand Down Expand Up @@ -12056,17 +12090,14 @@ export default function ChatView(props: ChatViewProps) {
onCloseAllSurfaces={closeAllRightPanelSurfaces}
onMoveSurface={moveRightPanelSurface}
onCopyFilePath={copyRightPanelFilePath}
onAddBrowser={() => createBrowserSurface()}
panels={sidePanelLaunchers}
onAddBrowserInProfile={createBrowserSurface}
onAddTerminal={addTerminalSurface}
onAddDiff={addDiffSurface}
onAddFiles={addFilesSurface}
onAddPullRequest={addPullRequestSurface}
onAddPullRequests={addPullRequestsSurface}
onAddDevice={addDeviceSurface}
browserAvailable={canOperatePreview && browserAvailable}
terminalAvailable={activeProject !== null && canOperateTerminal}
diffAvailable={isServerThread && isGitRepo}
filesAvailable={activeProject !== null}
pullRequestAvailable={pullRequestSurfaceAvailable}
pullRequestsAvailable={pullRequestsSurfaceAvailable}
Expand Down Expand Up @@ -12115,17 +12146,14 @@ export default function ChatView(props: ChatViewProps) {
onCloseAllSurfaces={closeAllRightPanelSurfaces}
onMoveSurface={moveRightPanelSurface}
onCopyFilePath={copyRightPanelFilePath}
onAddBrowser={() => createBrowserSurface()}
panels={sidePanelLaunchers}
onAddBrowserInProfile={createBrowserSurface}
onAddTerminal={addTerminalSurface}
onAddDiff={addDiffSurface}
onAddFiles={addFilesSurface}
onAddPullRequest={addPullRequestSurface}
onAddPullRequests={addPullRequestsSurface}
onAddDevice={addDeviceSurface}
browserAvailable={canOperatePreview && browserAvailable}
terminalAvailable={activeProject !== null && canOperateTerminal}
diffAvailable={isServerThread && isGitRepo}
filesAvailable={activeProject !== null}
pullRequestAvailable={pullRequestSurfaceAvailable}
pullRequestsAvailable={pullRequestsSurfaceAvailable}
Expand Down
142 changes: 142 additions & 0 deletions apps/web/src/components/RightPanelTabs.browserProfile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// @vitest-environment jsdom

import {
BUILT_IN_BROWSER_PROFILES,
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
} from "@t3tools/contracts";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

// The profile list normally comes from client settings; the built-ins are enough to choose from.
vi.mock("~/browser/browserDefaults", async (importOriginal) => ({
...(await importOriginal<typeof import("~/browser/browserDefaults")>()),
useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }),
}));

import { RightPanelTabs } from "./RightPanelTabs";

let root: Root;
let container: HTMLDivElement;
let opened: string[];

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
// Browser is desktop-only; the launcher offers it once the preview bridge exists.
vi.stubGlobal("desktopBridge", { preview: {} });
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
// jsdom lacks the Web Animations API that the tab bar's scroll area waits on.
Element.prototype.getAnimations ??= () => [];
opened = [];
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

// Records Browser opens the way ChatView's createBrowserSurface does: one
// handler for the default open and the profile chooser, where an omitted
// profile means the default one.
function Harness() {
const openBrowser = (profileId?: string) => {
opened.push(profileId ?? DEFAULT_BROWSER_PROFILE_ID);
};
return (
<RightPanelTabs
mode="inline"
keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
getShortcutContext={() => ({
terminalFocus: false,
terminalOpen: false,
previewFocus: false,
previewOpen: false,
isWeb: true,
isDesktop: false,
})}
surfaces={[]}
environmentId={null}
activeSurfaceId={null}
pendingSurfaceIds={new Set()}
previewSessions={{}}
desktopByTabId={{}}
terminalLabelsById={new Map()}
onActivate={() => undefined}
onCloseSurface={() => undefined}
onCloseOtherSurfaces={() => undefined}
onCloseSurfacesToRight={() => undefined}
onCloseAllSurfaces={() => undefined}
onCopyFilePath={() => undefined}
panels={{
preview: { available: true, onOpen: openBrowser },
diff: { available: false, onOpen: () => undefined },
}}
onAddBrowserInProfile={openBrowser}
onAddTerminal={() => undefined}
onAddPullRequest={() => undefined}
onAddPullRequests={() => undefined}
onAddFiles={() => undefined}
onAddDevice={() => undefined}
terminalAvailable={false}
filesAvailable={false}
pullRequestAvailable={false}
pullRequestsAvailable={false}
deviceAvailable={false}
>
{null}
</RightPanelTabs>
);
}

function launcherRow(label: string): HTMLButtonElement {
const launcher = container.querySelector('[aria-label="Open a surface"]');
const row = [...(launcher?.querySelectorAll("button") ?? [])].find((button) =>
button.textContent?.startsWith(label),
);
if (!row) throw new Error(`No launcher row ${label}`);
return row;
}

function menuItem(label: string): HTMLElement {
const item = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
(element) => element.textContent === label,
);
if (!item) throw new Error(`No menu item ${label}`);
return item;
}

async function click(element: Element) {
await act(async () => {
element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true }));
element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
element.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}

describe("opening Browser from the launcher", () => {
it("opens the default profile from the row and another profile from the chooser", async () => {
await act(async () => root.render(<Harness />));

await click(launcherRow("Browser"));
expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]);

await click(container.querySelector('[aria-label="Open browser in a profile"]')!);
await click(menuItem("Incognito"));
expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]);
});
});
8 changes: 4 additions & 4 deletions apps/web/src/components/RightPanelTabs.keyboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,17 @@ async function renderPanel(overrides: Partial<ComponentProps<typeof RightPanelTa
onCloseSurfacesToRight={noop}
onCloseAllSurfaces={noop}
onCopyFilePath={noop}
onAddBrowser={noop}
panels={{
preview: { available: false, onOpen: noop },
diff: { available: false, onOpen: noop },
}}
onAddBrowserInProfile={noop}
onAddTerminal={noop}
onAddDiff={noop}
onAddFiles={addFiles}
onAddPullRequest={noop}
onAddPullRequests={noop}
onAddDevice={noop}
browserAvailable={false}
terminalAvailable={false}
diffAvailable={false}
filesAvailable
pullRequestAvailable={false}
pullRequestsAvailable={false}
Expand Down
Loading
Loading