From 653c206688262c080cba988a237acd67da9e714f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 22 Dec 2025 04:37:10 -0600 Subject: feat(desktop): mobile responsiveness --- packages/desktop/src/pages/layout.tsx | 319 ++++++++++++++++++++------------- packages/desktop/src/pages/session.tsx | 272 +++++++++++++++++++++------- 2 files changed, 408 insertions(+), 183 deletions(-) (limited to 'packages/desktop/src/pages') diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 1c8bb615c..489899f88 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,4 +1,16 @@ -import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js" +import { + createEffect, + createMemo, + createSignal, + For, + Match, + onCleanup, + onMount, + ParentProps, + Show, + Switch, + type JSX, +} from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" @@ -42,9 +54,29 @@ export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, activeDraggable: undefined as string | undefined, + mobileSidebarOpen: false, + mobileProjectsExpanded: {} as Record, }) + const mobileSidebar = { + open: () => store.mobileSidebarOpen, + show: () => setStore("mobileSidebarOpen", true), + hide: () => setStore("mobileSidebarOpen", false), + toggle: () => setStore("mobileSidebarOpen", (x) => !x), + } + + const mobileProjects = { + expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, + expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), + collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), + } + let scrollContainerRef: HTMLDivElement | undefined + const xlQuery = window.matchMedia("(min-width: 1280px)") + const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) + const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches) + xlQuery.addEventListener("change", handleViewportChange) + onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange)) const params = useParams() const globalSDK = useGlobalSDK() @@ -259,11 +291,13 @@ export default function Layout(props: ParentProps) { if (!directory) return const lastSession = store.lastSession[directory] navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) + mobileSidebar.hide() } function navigateToSession(session: Session | undefined) { if (!session) return navigate(`/${params.dir}/session/${session?.id}`) + mobileSidebar.hide() } function openProject(directory: string, navigate = true) { @@ -302,8 +336,12 @@ export default function Layout(props: ParentProps) { }) createEffect(() => { - const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 - document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + if (isLargeViewport()) { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + } else { + document.documentElement.style.setProperty("--dialog-left-margin", "0px") + } }) function getDraggableId(event: unknown): string | undefined { @@ -419,6 +457,7 @@ export default function Layout(props: ParentProps) { project: LocalProject depth?: number childrenMap: Map + mobile?: boolean }): JSX.Element => { const notification = useNotification() const depth = props.depth ?? 0 @@ -439,7 +478,7 @@ export default function Layout(props: ParentProps) { hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" style={{ "padding-left": `${16 + depth * 12}px` }} > - + @@ -499,6 +538,7 @@ export default function Layout(props: ParentProps) { project={props.project} depth={depth + 1} childrenMap={props.childrenMap} + mobile={props.mobile} /> )} @@ -506,8 +546,9 @@ export default function Layout(props: ParentProps) { ) } - const SortableProject = (props: { project: LocalProject }): JSX.Element => { + const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) + const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const slug = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => getFilename(props.project.worktree)) const [store, setProjectStore] = globalSync.child(props.project.worktree) @@ -531,21 +572,24 @@ export default function Layout(props: ParentProps) { setProjectStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.project.worktree) } + const isExpanded = createMemo(() => + props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, + ) const handleOpenChange = (open: boolean) => { - if (open) layout.projects.expand(props.project.worktree) - else layout.projects.collapse(props.project.worktree) + if (props.mobile) { + if (open) mobileProjects.expand(props.project.worktree) + else mobileProjects.collapse(props.project.worktree) + } else { + if (open) layout.projects.expand(props.project.worktree) + else layout.projects.collapse(props.project.worktree) + } } return ( // @ts-ignore
- - + + - + + + +
- - -
- p.worktree)}> - {(project) => } - -
- - - - -
-
- - -
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
- - - + p.worktree)}> + + {(project) => } + + +
+ + + + +
+
+ + +
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
- - - + - - - - - Open project - {command.keybind("project.open")} -
- } - inactive={layout.sidebar.opened()} - > +
+ + + - - {/* */} - {/* */} - {/* */} - + + + + + Open project + + {command.keybind("project.open")} + +
+ } + inactive={expanded()} + >
+ + + + + + + ) + } + + return ( +
+
+
+
+ + + + +
+
+
{ + if (e.target === e.currentTarget) mobileSidebar.hide() + }} + /> +
e.stopPropagation()} + > +
+
{props.children}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index dde5fa2ae..aa318c3ba 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -125,6 +125,11 @@ export default function Page() { activeTerminalDraggable: undefined as string | undefined, userInteracted: false, stepsExpanded: true, + mobileStepsExpanded: {} as Record, + mobileLastScrollTop: 0, + mobileLastScrollHeight: 0, + mobileAutoScrolled: false, + mobileUserScrolled: false, }) let inputRef!: HTMLDivElement @@ -533,72 +538,215 @@ export default function Page() { const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0) + let mobileScrollRef: HTMLDivElement | undefined + + const mobileWorking = createMemo(() => status().type !== "idle") + + function handleMobileScroll() { + if (!mobileScrollRef || store.mobileAutoScrolled) return + + const scrollTop = mobileScrollRef.scrollTop + const scrollHeight = mobileScrollRef.scrollHeight + + const scrolledUp = scrollTop < store.mobileLastScrollTop - 50 + if (scrolledUp && mobileWorking()) { + setStore("mobileUserScrolled", true) + setStore("userInteracted", true) + } + + batch(() => { + setStore("mobileLastScrollTop", scrollTop) + setStore("mobileLastScrollHeight", scrollHeight) + }) + } + + function handleMobileInteraction() { + if (mobileWorking()) { + setStore("mobileUserScrolled", true) + setStore("userInteracted", true) + } + } + + function scrollMobileToBottom() { + if (!mobileScrollRef || store.mobileUserScrolled || !mobileWorking()) return + setStore("mobileAutoScrolled", true) + requestAnimationFrame(() => { + mobileScrollRef?.scrollTo({ top: mobileScrollRef.scrollHeight, behavior: "smooth" }) + requestAnimationFrame(() => { + batch(() => { + setStore("mobileLastScrollTop", mobileScrollRef?.scrollTop ?? 0) + setStore("mobileLastScrollHeight", mobileScrollRef?.scrollHeight ?? 0) + setStore("mobileAutoScrolled", false) + }) + }) + }) + } + + // Reset mobile user scrolled when work completes + createEffect(() => { + if (!mobileWorking()) setStore("mobileUserScrolled", false) + }) + + // Auto-scroll when content changes + createEffect(() => { + // Track changes to messages/parts to trigger scroll + const msgs = visibleUserMessages() + const lastMsg = msgs.at(-1) + if (lastMsg && mobileWorking()) { + sync.data.part[lastMsg.id] + scrollMobileToBottom() + } + }) + + const MobileTurns = () => ( +
+
+ + {(message) => ( + setStore("mobileStepsExpanded", message.id, (x) => !x)} + onUserInteracted={() => setStore("userInteracted", true)} + classes={{ + root: "min-w-0 w-full relative", + content: + "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + container: "px-4", + }} + /> + )} + +
+
+ ) + + const NewSessionView = () => ( +
+
New session
+
+ +
+ {getDirectory(sync.data.path.directory)} + {getFilename(sync.data.path.directory)} +
+
+ + {(project) => ( +
+ +
+ Last modified  + + {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()} + +
+
+ )} +
+
+ ) + + const DesktopSessionContent = () => ( + + +
+ + + setStore("stepsExpanded", (x) => !x)} + onUserInteracted={() => setStore("userInteracted", true)} + classes={{ + root: "pb-20 flex-1 min-w-0", + content: "pb-20", + container: + "w-full " + + (!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"), + }} + /> + +
+
+ + + +
+ ) + return (
-
- {/* Session pane - always visible */} +
+ + +
+ +
+
+ 0}> + + + + Session + + + {diffs().length} Files Changed + + + + + + + + + +
+ +
+
+
+
+
+ { + inputRef = el + }} + /> +
+
+
+ +