Skip to content

Commit 107bc12

Browse files
committed
feat: add mobile workspace fullscreen toggle
1 parent c6b3600 commit 107bc12

4 files changed

Lines changed: 109 additions & 1 deletion

File tree

packages/web/src/features/workspace/views/mobile/mobile-topbar.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import type { Workspace } from "@coder-studio/core";
22
import { Menu, Settings2 } from "lucide-react";
33
import { useTranslation } from "../../../../lib/i18n";
4+
import type { WorkspaceFullscreenController } from "../../actions/use-workspace-fullscreen";
5+
import { WorkspaceFullscreenButton } from "../../components/workspace-fullscreen-button";
46

57
interface MobileTopBarProps {
68
activeWorkspace: Workspace | null;
79
drawerOpen: boolean;
10+
fullscreenController?: WorkspaceFullscreenController;
811
onOpenSettings: () => void;
912
onToggleDrawer: () => void;
1013
}
1114

1215
export function MobileTopBar({
1316
activeWorkspace,
1417
drawerOpen,
18+
fullscreenController,
1519
onOpenSettings,
1620
onToggleDrawer,
1721
}: MobileTopBarProps) {
@@ -53,6 +57,12 @@ export function MobileTopBar({
5357
>
5458
<Settings2 size={18} />
5559
</button>
60+
<WorkspaceFullscreenButton
61+
controller={fullscreenController}
62+
className="mobile-topbar__icon-button"
63+
iconSize={18}
64+
dataTestId="mobile-fullscreen-toggle"
65+
/>
5666
</div>
5767
</header>
5868
);

packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useAtomValue, useSetAtom } from "jotai";
2-
import { type CSSProperties, useEffect, useState } from "react";
2+
import { type CSSProperties, useEffect, useRef, useState } from "react";
33
import { useNavigate } from "react-router-dom";
44
import { pendingFocusSessionAtom, visibleMobileSessionIdAtom } from "../../../../atoms/app-ui";
55
import { useTranslation } from "../../../../lib/i18n";
@@ -10,6 +10,7 @@ import { ConfigDriftBanner } from "../../../config-drift-banner";
1010
import { MobileSupervisorBadge } from "../../../supervisor/views/mobile/mobile-supervisor-badge";
1111
import { MobileSupervisorSheet } from "../../../supervisor/views/mobile/mobile-supervisor-sheet";
1212
import { TerminalPanel } from "../../../terminal-panel";
13+
import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen";
1314
import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model";
1415
import { WorkspaceLaunchModal } from "../shared/workspace-launch-modal";
1516
import { useMobileLayoutMode } from "./hooks/use-mobile-layout-mode";
@@ -23,6 +24,8 @@ import { MobileTopBar } from "./mobile-topbar";
2324
import { MobileWorkspaceDrawer } from "./mobile-workspace-drawer";
2425

2526
export function WorkspaceMobileView() {
27+
const fullscreenRootRef = useRef<HTMLDivElement>(null);
28+
const fullscreenController = useWorkspaceFullscreen(fullscreenRootRef);
2629
const t = useTranslation();
2730
const navigate = useNavigate();
2831
const pendingFocusSessionId = useAtomValue(pendingFocusSessionAtom);
@@ -139,6 +142,7 @@ export function WorkspaceMobileView() {
139142

140143
return (
141144
<div
145+
ref={fullscreenRootRef}
142146
className={`mobile-shell mobile-shell--${layoutMode} mobile-shell--motion-${motionMode}`}
143147
data-testid="mobile-shell"
144148
data-layout-mode={layoutMode}
@@ -147,6 +151,7 @@ export function WorkspaceMobileView() {
147151
<MobileTopBar
148152
activeWorkspace={workspace}
149153
drawerOpen={drawerOpen}
154+
fullscreenController={fullscreenController}
150155
onOpenSettings={() => {
151156
setAgentSheetOpen(false);
152157
navigate("/settings");

packages/web/src/shells/mobile-shell/index.test.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,65 @@ function installVisualViewport(height: number, offsetTop = 0) {
269269
return visualViewport;
270270
}
271271

272+
function installFullscreenApiForMobileShell() {
273+
let fullscreenElement: Element | null = null;
274+
275+
const requestFullscreen = vi.fn().mockImplementation(function (this: HTMLElement) {
276+
fullscreenElement = this;
277+
document.dispatchEvent(new Event("fullscreenchange"));
278+
return Promise.resolve();
279+
});
280+
281+
const exitFullscreen = vi.fn().mockImplementation(async () => {
282+
fullscreenElement = null;
283+
document.dispatchEvent(new Event("fullscreenchange"));
284+
});
285+
286+
Object.defineProperty(document, "fullscreenEnabled", {
287+
configurable: true,
288+
value: true,
289+
});
290+
291+
Object.defineProperty(document, "fullscreenElement", {
292+
configurable: true,
293+
get: () => fullscreenElement,
294+
});
295+
296+
Object.defineProperty(document, "exitFullscreen", {
297+
configurable: true,
298+
value: exitFullscreen,
299+
});
300+
301+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
302+
configurable: true,
303+
value: requestFullscreen,
304+
});
305+
306+
return { requestFullscreen, exitFullscreen };
307+
}
308+
309+
function removeFullscreenApiForMobileShell() {
310+
Object.defineProperty(document, "fullscreenEnabled", {
311+
configurable: true,
312+
value: false,
313+
});
314+
315+
Object.defineProperty(document, "fullscreenElement", {
316+
configurable: true,
317+
get: () => null,
318+
});
319+
320+
Object.defineProperty(document, "exitFullscreen", {
321+
configurable: true,
322+
value: undefined,
323+
});
324+
325+
Object.defineProperty(HTMLElement.prototype, "requestFullscreen", {
326+
configurable: true,
327+
value: undefined,
328+
});
329+
}
330+
272331
function installMatchMediaMock(predicate: (query: string) => boolean) {
273332
const originalMatchMedia = window.matchMedia;
274333
const listeners = new Map<string, Set<(event: MediaQueryListEvent) => void>>();
@@ -704,6 +763,37 @@ describe("MobileShell Phase 2 workspace", () => {
704763
expect(screen.getByText("SettingsPage")).toBeInTheDocument();
705764
});
706765

766+
it("shows a fullscreen toggle to the right of settings when the browser supports fullscreen", async () => {
767+
installFullscreenApiForMobileShell();
768+
renderMobileShell({ initialEntry: "/workspace" });
769+
770+
const settingsButton = screen.getByRole("button", { name: "Open settings" });
771+
const fullscreenButton = await screen.findByRole("button", { name: "Enter Fullscreen" });
772+
773+
expect(settingsButton.nextElementSibling).toBe(fullscreenButton);
774+
});
775+
776+
it("hides the fullscreen toggle on mobile when the browser does not support fullscreen", async () => {
777+
removeFullscreenApiForMobileShell();
778+
renderMobileShell({ initialEntry: "/workspace" });
779+
780+
expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
781+
expect(screen.queryByRole("button", { name: "Enter Fullscreen" })).toBeNull();
782+
});
783+
784+
it("switches the mobile fullscreen button to exit mode after entering fullscreen", async () => {
785+
const api = installFullscreenApiForMobileShell();
786+
const user = userEvent.setup();
787+
renderMobileShell({ initialEntry: "/workspace" });
788+
789+
await user.click(await screen.findByRole("button", { name: "Enter Fullscreen" }));
790+
791+
await waitFor(() => {
792+
expect(api.requestFullscreen).toHaveBeenCalledTimes(1);
793+
expect(screen.getByRole("button", { name: "Exit Fullscreen" })).toBeInTheDocument();
794+
});
795+
});
796+
707797
it("keeps welcome route as full-page content outside the workspace scaffold", () => {
708798
renderMobileShell({ initialEntry: "/", withWorkspaces: false });
709799

packages/web/src/styles/components.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7706,6 +7706,9 @@ textarea.input {
77067706

77077707
.mobile-topbar__actions {
77087708
position: relative;
7709+
display: inline-flex;
7710+
align-items: center;
7711+
gap: var(--sp-2);
77097712
}
77107713

77117714
.mobile-shell__recovery-strip {

0 commit comments

Comments
 (0)