feat(app): add mobile bottom navigation (#32797)

This commit is contained in:
Brendan Allan
2026-06-23 17:29:22 +08:00
committed by GitHub
parent 77522697b0
commit b0063709e6
6 changed files with 72 additions and 10 deletions
+4 -1
View File
@@ -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>
) )
+18 -4
View File
@@ -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 />
+9
View File
@@ -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)
+3
View File
@@ -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",
+22 -5
View File
@@ -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()}>