Compare commits

...

2 Commits

Author SHA1 Message Date
Kyle Carberry 7b8f87d325 feat(site): add landscape mode for Desktop panel on mobile
On mobile, the Desktop tab in the right panel now shows a fullscreen
button. Entering fullscreen triggers landscape orientation via the
Screen Orientation API so the VNC desktop view fills the screen.
Exiting fullscreen automatically restores portrait.

- manifest.json: adds "orientation": "portrait" for PWA portrait lock.
- useAgentsPWA: locks portrait on mount (best-effort, ignored where
  unsupported).
- useDesktopLandscape hook: locks/unlocks landscape orientation tied
  to a boolean flag. Restores portrait on unmount.
- SidebarTabView: shows fullscreen button on mobile when Desktop tab
  is active, and a minimize button when expanded.
- AgentChatPageView: calls useDesktopLandscape tied to
  isRightPanelExpanded && desktop tab active.
2026-04-01 12:36:33 +00:00
Kyle Carberry ee84b2aba9 feat(site): add landscape mode for Desktop panel on mobile
Adds a YouTube-style orientation toggle scoped to the Desktop tab in
the agents right panel. The app is portrait-locked by default (via
manifest.json and Screen Orientation API) and when the Desktop VNC
panel is active on mobile, a "Landscape" button appears overlaid on
the canvas. Tapping it unlocks orientation to landscape so the
desktop view fills the screen. The tab bar also shows a smartphone
icon to exit landscape when active.

- New useDesktopMode hook: manages landscape orientation via Screen
  Orientation API. Automatically restores portrait on unmount or
  when the user exits.
- manifest.json: adds "orientation": "portrait" for PWA portrait lock.
- useAgentsPWA: locks portrait on mount via Screen Orientation API
  (best-effort, silently ignored where unsupported).
- DesktopPanel: shows a "Landscape" button (mobile only) when the
  VNC session is connected.
- SidebarTabView: shows a smartphone exit button in the tab bar
  when landscape is active on the Desktop tab.
2026-04-01 12:15:25 +00:00
5 changed files with 136 additions and 3 deletions
@@ -29,6 +29,7 @@ import { ChatTopBar } from "./components/ChatTopBar";
import { GitPanel } from "./components/GitPanel/GitPanel";
import { RightPanel } from "./components/RightPanel/RightPanel";
import { SidebarTabView } from "./components/Sidebar/SidebarTabView";
import { useDesktopLandscape } from "./hooks/useDesktopMode";
import type { ChatDetailError } from "./utils/usageLimitMessage";
type ChatStoreHandle = ReturnType<typeof useChatStore>["store"];
@@ -216,6 +217,11 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
// the user clicks the inline desktop preview card).
const [sidebarTabId, setSidebarTabId] = useState<string | null>(null);
// Lock landscape orientation when the Desktop tab is fullscreened
// on mobile. Exiting fullscreen (isRightPanelExpanded = false)
// automatically restores portrait.
useDesktopLandscape(isRightPanelExpanded && sidebarTabId === "desktop");
const handleOpenDesktop = () => {
onSetShowSidebarPanel(true);
setSidebarTabId("desktop");
@@ -1,6 +1,7 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
FullscreenIcon,
MaximizeIcon,
MinimizeIcon,
PanelLeftIcon,
@@ -159,6 +160,12 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
scrollRight: scrollTabsRight,
} = useTabScroll();
// Whether to show the mobile fullscreen button for the
// desktop tab. Visible only on touch devices when the
// Desktop tab is active and the panel is not yet expanded.
const showDesktopFullscreen =
effectiveTabId === "desktop" && desktopChatId && !isExpanded;
if (tabs.length === 0 && !desktopChatId) {
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-surface-primary">
@@ -307,8 +314,8 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
</span>
</div>
)}
{/* Right side: close (mobile) / expand (desktop) */}
{onClose && (
{/* Right side: close (mobile) / fullscreen (mobile desktop) / expand (desktop) */}
{onClose && !isExpanded && (
<Button
variant="subtle"
size="icon"
@@ -319,6 +326,31 @@ export const SidebarTabView: FC<SidebarTabViewProps> = ({
<XIcon />
</Button>
)}
{/* Mobile fullscreen for Desktop tab — enters expanded
mode which the parent ties to landscape orientation. */}
{showDesktopFullscreen && (
<Button
variant="subtle"
size="icon"
onClick={onToggleExpanded}
aria-label="Fullscreen desktop"
className="h-7 w-7 shrink-0 text-content-secondary hover:text-content-primary md:hidden"
>
<FullscreenIcon />
</Button>
)}
{/* Exit fullscreen — visible on mobile when expanded */}
{isExpanded && (
<Button
variant="subtle"
size="icon"
onClick={onToggleExpanded}
aria-label="Exit fullscreen"
className="h-7 w-7 shrink-0 text-content-secondary hover:text-content-primary md:hidden"
>
<MinimizeIcon />
</Button>
)}
<Button
variant="subtle"
size="icon"
@@ -3,7 +3,9 @@ import { useEffect } from "react";
/**
* Injects PWA-related <head> tags while the Agents page is mounted
* (manifest, apple-touch-icon, mobile-web-app metas) and tweaks the
* viewport to prevent zooming. Everything is cleaned up on unmount.
* viewport to prevent zooming. On mobile it also locks orientation
* to portrait; the Desktop panel can override this at runtime.
* Everything is cleaned up on unmount.
*/
export function useAgentsPWA() {
useEffect(() => {
@@ -57,6 +59,24 @@ export function useAgentsPWA() {
"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no";
}
// -- Orientation lock ---------------------------------------------------
// Lock portrait by default for a native-app feel on mobile.
// The useDesktopMode hook overrides this when the user opens
// the Desktop panel in landscape. The lock is best-effort —
// it only works in PWA standalone mode on Android and is
// silently ignored everywhere else.
let orientationLocked = false;
try {
screen.orientation
.lock("portrait-primary")
.then(() => {
orientationLocked = true;
})
.catch(() => {});
} catch {
// screen.orientation.lock may not exist at all.
}
// -- Cleanup ------------------------------------------------------------
return () => {
for (const el of injected) {
@@ -65,6 +85,13 @@ export function useAgentsPWA() {
if (viewport) {
viewport.content = prevViewportContent;
}
if (orientationLocked) {
try {
screen.orientation.unlock();
} catch {
// Ignored.
}
}
};
}, []);
}
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useRef } from "react";
/**
* Try to lock the screen orientation. This only works in certain
* contexts (PWA standalone mode on Android, fullscreen) and throws
* or rejects everywhere else. All failures are silently ignored.
*/
async function tryOrientationLock(
orientation: OrientationLockType,
): Promise<boolean> {
try {
await screen.orientation.lock(orientation);
return true;
} catch {
// Expected to fail on iOS Safari, non-fullscreen browsers,
// and desktop browsers. This is not an error condition.
return false;
}
}
function tryOrientationUnlock(): void {
try {
screen.orientation.unlock();
} catch {
// Same as above — expected to fail in most contexts.
}
}
/**
* Manages landscape orientation for the Desktop panel fullscreen
* mode on mobile. The caller tells the hook whether landscape
* should be active; the hook handles the Screen Orientation API
* and cleans up on unmount.
*
* Portrait is restored automatically when `isLandscape` flips
* back to false or when the component unmounts.
*/
export function useDesktopLandscape(isLandscape: boolean): void {
const wasLandscapeRef = useRef(false);
const restorePortrait = useCallback(() => {
void tryOrientationLock("portrait-primary").then((locked) => {
if (!locked) {
tryOrientationUnlock();
}
});
}, []);
useEffect(() => {
if (isLandscape) {
void tryOrientationLock("landscape");
wasLandscapeRef.current = true;
} else if (wasLandscapeRef.current) {
restorePortrait();
wasLandscapeRef.current = false;
}
return () => {
// Restore portrait on unmount so navigating away
// returns the device to its normal orientation.
if (wasLandscapeRef.current) {
restorePortrait();
wasLandscapeRef.current = false;
}
};
}, [isLandscape, restorePortrait]);
}
+1
View File
@@ -5,6 +5,7 @@
"start_url": "/agents",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#17172E",
"theme_color": "#17172E",
"icons": [