feat(app): add mobile bottom navigation (#32797)
This commit is contained in:
@@ -2,7 +2,10 @@
|
|||||||
<html lang="en" style="background-color: var(--background-base)">
|
<html lang="en" style="background-color: var(--background-base)">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>OpenCode</title>
|
<title>OpenCode</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
@@ -89,6 +90,7 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
|
||||||
@@ -345,6 +347,20 @@ export const SettingsGeneralV2: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
<Show when={mobile()}>
|
||||||
|
<SettingsRowV2
|
||||||
|
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||||
|
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-mobile-titlebar-bottom">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.mobileTitlebarPosition() === "bottom"}
|
||||||
|
onChange={(checked) => settings.general.setMobileTitlebarPosition(checked ? "bottom" : "top")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRowV2>
|
||||||
|
</Show>
|
||||||
</SettingsListV2>
|
</SettingsListV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/
|
|||||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
@@ -87,6 +88,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
|
||||||
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
const bottom = createMemo(
|
||||||
|
() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||||
|
)
|
||||||
|
|
||||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||||
@@ -98,6 +103,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
|
||||||
const minHeight = () => {
|
const minHeight = () => {
|
||||||
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
|
||||||
|
if (useV2Titlebar() && mobile()) {
|
||||||
|
const inset = bottom() ? "env(safe-area-inset-bottom, 0px)" : "env(safe-area-inset-top, 0px)"
|
||||||
|
return `calc(${height}px + ${inset})`
|
||||||
|
}
|
||||||
if (mac()) return `${height / zoom()}px`
|
if (mac()) return `${height / zoom()}px`
|
||||||
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
|
||||||
return undefined
|
return undefined
|
||||||
@@ -235,10 +244,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
"shrink-0 relative flex flex-row": true,
|
"shrink-0 relative flex flex-row": true,
|
||||||
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
"h-9 bg-v2-background-bg-deep overflow-visible": useV2Titlebar(),
|
||||||
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
|
"h-10 bg-background-base overflow-hidden": !useV2Titlebar(),
|
||||||
|
"order-last": bottom(),
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
"min-height": minHeight(),
|
"min-height": minHeight(),
|
||||||
"padding-left": mac() ? `${84 / zoom()}px` : 0,
|
"padding-top": useV2Titlebar() && mobile() && !bottom() ? "env(safe-area-inset-top, 0px)" : undefined,
|
||||||
|
"padding-bottom": bottom() ? "env(safe-area-inset-bottom, 0px)" : undefined,
|
||||||
|
"padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0,
|
||||||
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
|
||||||
"max-width": electronWindows()
|
"max-width": electronWindows()
|
||||||
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
|
||||||
@@ -426,10 +438,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 pr-3 pt-2"
|
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pr-3"
|
||||||
classList={{
|
classList={{
|
||||||
"pl-2": mac(),
|
"pt-2": !bottom(),
|
||||||
"pl-4": !mac(),
|
"pb-2": bottom(),
|
||||||
|
"md:pl-2": mac(),
|
||||||
|
"md:pl-4": !mac(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChannelIndicator />
|
<ChannelIndicator />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface Settings {
|
|||||||
editToolPartsExpanded: boolean
|
editToolPartsExpanded: boolean
|
||||||
showSessionProgressBar: boolean
|
showSessionProgressBar: boolean
|
||||||
showCustomAgents: boolean
|
showCustomAgents: boolean
|
||||||
|
mobileTitlebarPosition: "top" | "bottom"
|
||||||
newLayoutDesigns?: boolean
|
newLayoutDesigns?: boolean
|
||||||
}
|
}
|
||||||
appearance: {
|
appearance: {
|
||||||
@@ -118,6 +119,7 @@ const defaultSettings: Settings = {
|
|||||||
editToolPartsExpanded: false,
|
editToolPartsExpanded: false,
|
||||||
showSessionProgressBar: true,
|
showSessionProgressBar: true,
|
||||||
showCustomAgents: false,
|
showCustomAgents: false,
|
||||||
|
mobileTitlebarPosition: "top",
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -248,6 +250,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setShowCustomAgents(value: boolean) {
|
setShowCustomAgents(value: boolean) {
|
||||||
setStore("general", "showCustomAgents", value)
|
setStore("general", "showCustomAgents", value)
|
||||||
},
|
},
|
||||||
|
mobileTitlebarPosition: withFallback(
|
||||||
|
() => store.general?.mobileTitlebarPosition,
|
||||||
|
defaultSettings.general.mobileTitlebarPosition,
|
||||||
|
),
|
||||||
|
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||||
|
setStore("general", "mobileTitlebarPosition", value)
|
||||||
|
},
|
||||||
newLayoutDesigns,
|
newLayoutDesigns,
|
||||||
setNewLayoutDesigns(value: boolean) {
|
setNewLayoutDesigns(value: boolean) {
|
||||||
setStore("general", "newLayoutDesigns", value)
|
setStore("general", "newLayoutDesigns", value)
|
||||||
|
|||||||
@@ -841,6 +841,9 @@ export const dict = {
|
|||||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||||
"settings.general.row.showStatus.title": "Server status",
|
"settings.general.row.showStatus.title": "Server status",
|
||||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||||
|
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||||
|
"settings.general.row.mobileTitlebarBottom.description":
|
||||||
|
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||||
"settings.general.row.showCustomAgents.title": "Custom agents",
|
"settings.general.row.showCustomAgents.title": "Custom agents",
|
||||||
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
"settings.general.row.showCustomAgents.description": "Show the agent picker in the composer",
|
||||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||||
|
|||||||
@@ -1568,12 +1568,20 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const mobileTabs = (compact = false) => (
|
const mobileTabs = (compact = false, bottom = false) => (
|
||||||
<Tabs value={store.mobileTab} class="h-auto">
|
<Tabs value={store.mobileTab} class="h-auto">
|
||||||
<Tabs.List class={compact ? "!h-9" : undefined}>
|
<Tabs.List
|
||||||
|
classList={{
|
||||||
|
"!h-9": compact,
|
||||||
|
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": bottom,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="session"
|
value="session"
|
||||||
class="!w-1/2 !max-w-none"
|
classList={{
|
||||||
|
"!w-1/2 !max-w-none": true,
|
||||||
|
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
|
||||||
|
}}
|
||||||
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
|
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
|
||||||
onClick={() => setStore("mobileTab", "session")}
|
onClick={() => setStore("mobileTab", "session")}
|
||||||
>
|
>
|
||||||
@@ -1581,7 +1589,10 @@ export default function Page() {
|
|||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="changes"
|
value="changes"
|
||||||
class="!w-1/2 !max-w-none !border-r-0"
|
classList={{
|
||||||
|
"!w-1/2 !max-w-none !border-r-0": true,
|
||||||
|
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
|
||||||
|
}}
|
||||||
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
|
classes={{ button: compact ? "w-full !py-2" : "w-full" }}
|
||||||
onClick={() => setStore("mobileTab", "changes")}
|
onClick={() => setStore("mobileTab", "changes")}
|
||||||
>
|
>
|
||||||
@@ -1592,6 +1603,9 @@ export default function Page() {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)
|
)
|
||||||
|
const mobileTabsBottom = createMemo(
|
||||||
|
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full overflow-hidden flex flex-col">
|
<div class="relative size-full overflow-hidden flex flex-col">
|
||||||
@@ -1622,7 +1636,9 @@ export default function Page() {
|
|||||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns()}>{mobileTabs(true)}</Show>
|
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||||
|
{mobileTabs(true)}
|
||||||
|
</Show>
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={params.id && mobileChanges()}>
|
<Match when={params.id && mobileChanges()}>
|
||||||
@@ -1688,6 +1704,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
|
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{composerRegion("dock")}</Show>
|
||||||
|
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user