feat(ui): add mobile fullscreen mode
Adds an in-memory mobile fullscreen toggle that hides chrome and uses the Fullscreen API when available.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||
import AlertDialog from "./components/alert-dialog"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import { showConfirmDialog } from "./stores/alerts"
|
||||
@@ -82,6 +83,46 @@ const App: Component = () => {
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||
const isPhoneLayout = createMemo(() => phoneQuery())
|
||||
|
||||
// In-memory only: hides chrome on phone; may also request browser fullscreen.
|
||||
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
|
||||
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
|
||||
|
||||
const fullscreenSupported = () => {
|
||||
if (typeof document === "undefined") return false
|
||||
const el = document.documentElement as any
|
||||
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
|
||||
}
|
||||
|
||||
const syncBrowserFullscreenState = () => {
|
||||
if (typeof document === "undefined") return
|
||||
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
|
||||
}
|
||||
|
||||
const enterMobileFullscreen = async () => {
|
||||
if (!isPhoneLayout()) return
|
||||
setMobileFullscreenMode(true)
|
||||
if (!fullscreenSupported()) return
|
||||
try {
|
||||
await document.documentElement.requestFullscreen()
|
||||
} catch {
|
||||
// Ignore: immersive mode still works without browser fullscreen.
|
||||
}
|
||||
}
|
||||
|
||||
const exitMobileFullscreen = async () => {
|
||||
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
|
||||
try {
|
||||
await document.exitFullscreen()
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
@@ -95,6 +136,31 @@ const App: Component = () => {
|
||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof document === "undefined") return
|
||||
syncBrowserFullscreenState()
|
||||
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
||||
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
||||
})
|
||||
|
||||
// If the user exits browser fullscreen via browser UI, restore chrome.
|
||||
let lastBrowserFullscreen = false
|
||||
createEffect(() => {
|
||||
const active = browserFullscreenActive()
|
||||
const mode = mobileFullscreenMode()
|
||||
if (mode && lastBrowserFullscreen && !active) {
|
||||
setMobileFullscreenMode(false)
|
||||
}
|
||||
lastBrowserFullscreen = active
|
||||
})
|
||||
|
||||
// If we leave phone layout (rotation / resize), restore chrome.
|
||||
createEffect(() => {
|
||||
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
||||
void exitMobileFullscreen()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||
})
|
||||
@@ -410,14 +476,16 @@ const App: Component = () => {
|
||||
when={!hasInstances()}
|
||||
fallback={
|
||||
<>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||
<InstanceTabs
|
||||
instances={instances()}
|
||||
activeInstanceId={activeInstanceId()}
|
||||
onSelect={setActiveInstanceId}
|
||||
onClose={handleCloseInstance}
|
||||
onNew={handleNewInstanceRequest}
|
||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(instances().values())}>
|
||||
{(instance) => {
|
||||
@@ -435,7 +503,10 @@ const App: Component = () => {
|
||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||
onExecuteCommand={executeCommand}
|
||||
tabBarOffset={instanceTabBarHeight()}
|
||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||
/>
|
||||
</InstanceMetadataProvider>
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
||||
import RightPanel from "./shell/right-panel/RightPanel"
|
||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||
import { getSessionStatus } from "../../stores/session-status"
|
||||
import { ShieldAlert } from "lucide-solid"
|
||||
import { Maximize2, Minimize2, ShieldAlert } from "lucide-solid"
|
||||
|
||||
import type { LayoutMode } from "./shell/types"
|
||||
import {
|
||||
@@ -70,6 +70,11 @@ interface InstanceShellProps {
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
tabBarOffset: number
|
||||
|
||||
// In-memory only: mobile immersive/fullscreen mode.
|
||||
mobileFullscreenMode: boolean
|
||||
onEnterMobileFullscreen: () => void
|
||||
onExitMobileFullscreen: () => void
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
@@ -118,6 +123,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
})
|
||||
|
||||
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
||||
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||
|
||||
@@ -585,13 +591,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{renderLeftPanel()}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={!mobileFullscreen()}>
|
||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||
<Show
|
||||
when={!isPhoneLayout()}
|
||||
fallback={
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||
<Show when={leftDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setLeftToggleButtonEl}
|
||||
@@ -638,6 +645,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show when={!props.mobileFullscreenMode}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={props.onEnterMobileFullscreen}
|
||||
aria-label={t("instanceShell.fullscreen.enter")}
|
||||
title={t("instanceShell.fullscreen.enter")}
|
||||
size="small"
|
||||
>
|
||||
<Maximize2 class="w-5 h-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</Show>
|
||||
|
||||
<Show when={rightDrawerState() === "floating-closed"}>
|
||||
<IconButton
|
||||
ref={setRightToggleButtonEl}
|
||||
@@ -750,9 +769,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Show>
|
||||
|
||||
<Show when={mobileFullscreen()}>
|
||||
<div class="mobile-fullscreen-exit-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="message-scroll-button mobile-fullscreen-exit-button"
|
||||
onClick={props.onExitMobileFullscreen}
|
||||
aria-label={t("instanceShell.fullscreen.exit")}
|
||||
title={t("instanceShell.fullscreen.exit")}
|
||||
>
|
||||
<Minimize2 class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
|
||||
"instanceShell.rightDrawer.toggle.close": "Close right drawer",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Full screen",
|
||||
"instanceShell.fullscreen.exit": "Exit full screen",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Used",
|
||||
"instanceShell.metrics.availableLabel": "Avail",
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
|
||||
"instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Pantalla completa",
|
||||
"instanceShell.fullscreen.exit": "Salir de pantalla completa",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Usado",
|
||||
"instanceShell.metrics.availableLabel": "Disp.",
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
|
||||
"instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Plein écran",
|
||||
"instanceShell.fullscreen.exit": "Quitter le plein écran",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Utilisé",
|
||||
"instanceShell.metrics.availableLabel": "Dispo",
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
|
||||
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
|
||||
|
||||
"instanceShell.fullscreen.enter": "全画面",
|
||||
"instanceShell.fullscreen.exit": "全画面を終了",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "使用",
|
||||
"instanceShell.metrics.availableLabel": "残り",
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
|
||||
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
|
||||
|
||||
"instanceShell.fullscreen.enter": "Полный экран",
|
||||
"instanceShell.fullscreen.exit": "Выйти из полного экрана",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "Использовано",
|
||||
"instanceShell.metrics.availableLabel": "Доступно",
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export const instanceMessages = {
|
||||
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
|
||||
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
|
||||
|
||||
"instanceShell.fullscreen.enter": "全屏",
|
||||
"instanceShell.fullscreen.exit": "退出全屏",
|
||||
|
||||
"instanceShell.metrics.usedLabel": "已用",
|
||||
"instanceShell.metrics.availableLabel": "可用",
|
||||
|
||||
|
||||
@@ -125,6 +125,18 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-fullscreen-exit-wrapper {
|
||||
position: fixed;
|
||||
top: calc(env(safe-area-inset-top, 0px) + 12px);
|
||||
right: calc(env(safe-area-inset-right, 0px) + 12px);
|
||||
z-index: 1250;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-fullscreen-exit-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
|
||||
Reference in New Issue
Block a user