summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 04:37:10 -0600
committerAdam <[email protected]>2025-12-22 05:46:07 -0600
commit653c206688262c080cba988a237acd67da9e714f (patch)
treee74c1d439ffe4a4ed31b318d0091dc702526c288 /packages
parent580f46b589e3cfdbf21d135ee61e2e258c76e46e (diff)
downloadopencode-653c206688262c080cba988a237acd67da9e714f.tar.gz
opencode-653c206688262c080cba988a237acd67da9e714f.zip
feat(desktop): mobile responsiveness
Diffstat (limited to 'packages')
-rw-r--r--packages/desktop/src/components/header.tsx56
-rw-r--r--packages/desktop/src/components/prompt-input.tsx6
-rw-r--r--packages/desktop/src/context/layout.tsx6
-rw-r--r--packages/desktop/src/pages/layout.tsx319
-rw-r--r--packages/desktop/src/pages/session.tsx272
-rw-r--r--packages/ui/src/components/icon.tsx1
-rw-r--r--packages/ui/src/components/message-part.tsx13
7 files changed, 458 insertions, 215 deletions
diff --git a/packages/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx
index 69c15449a..c5ecd9871 100644
--- a/packages/desktop/src/components/header.tsx
+++ b/packages/desktop/src/components/header.tsx
@@ -20,6 +20,7 @@ import { iife } from "@opencode-ai/util/iife"
export function Header(props: {
navigateToProject: (directory: string) => void
navigateToSession: (session: Session | undefined) => void
+ onMobileMenuToggle?: () => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
@@ -29,11 +30,19 @@ export function Header(props: {
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+ <button
+ type="button"
+ class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
+ onClick={props.onMobileMenuToggle}
+ >
+ <Icon name="menu" size="small" />
+ </button>
<A
href="/"
classList={{
+ "hidden xl:flex": true,
"w-12 shrink-0 px-4 py-3.5": true,
- "flex items-center justify-start self-stretch": true,
+ "items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
@@ -51,25 +60,27 @@ export function Header(props: {
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<>
- <div class="flex items-center gap-3">
- <div class="flex items-center gap-2">
- <Select
- options={layout.projects.list().map((project) => project.worktree)}
- current={currentDirectory()}
- label={(x) => getFilename(x)}
- onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
- class="text-14-regular text-text-base"
- variant="ghost"
- >
- {/* @ts-ignore */}
- {(i) => (
- <div class="flex items-center gap-2">
- <Icon name="folder" size="small" />
- <div class="text-text-strong">{getFilename(i)}</div>
- </div>
- )}
- </Select>
- <div class="text-text-weaker">/</div>
+ <div class="flex items-center gap-3 min-w-0">
+ <div class="flex items-center gap-2 min-w-0">
+ <div class="hidden xl:flex items-center gap-2">
+ <Select
+ options={layout.projects.list().map((project) => project.worktree)}
+ current={currentDirectory()}
+ label={(x) => getFilename(x)}
+ onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+ class="text-14-regular text-text-base"
+ variant="ghost"
+ >
+ {/* @ts-ignore */}
+ {(i) => (
+ <div class="flex items-center gap-2">
+ <Icon name="folder" size="small" />
+ <div class="text-text-strong">{getFilename(i)}</div>
+ </div>
+ )}
+ </Select>
+ <div class="text-text-weaker">/</div>
+ </div>
<Select
options={sessions()}
current={currentSession()}
@@ -77,12 +88,13 @@ export function Header(props: {
label={(x) => x.title}
value={(x) => x.id}
onSelect={props.navigateToSession}
- class="text-14-regular text-text-base max-w-md"
+ class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
+ class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
@@ -98,7 +110,7 @@ export function Header(props: {
</div>
<div class="flex items-center gap-4">
<Tooltip
- class="shrink-0"
+ class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index c548cea0e..cba75b212 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -972,7 +972,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
/>
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
- <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
+ <div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? "Enter shell command..."
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
@@ -1026,7 +1026,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
>
{local.model.current()?.name ?? "Select model"}
- <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
+ <span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
+ {local.model.current()?.provider.name}
+ </span>
<Icon name="chevron-down" size="small" />
</Button>
</Tooltip>
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 8bfc8aa21..17cd4785c 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -108,10 +108,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
},
expand(directory: string) {
- setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
+ const index = store.projects.findIndex((x) => x.worktree === directory)
+ if (index !== -1) setStore("projects", index, "expanded", true)
},
collapse(directory: string) {
- setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
+ const index = store.projects.findIndex((x) => x.worktree === directory)
+ if (index !== -1) setStore("projects", index, "expanded", false)
},
move(directory: string, toIndex: number) {
setStore("projects", (projects) => {
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<string, boolean>,
})
+ 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<string, Session[]>
+ 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` }}
>
- <Tooltip placement="right" value={props.session.title} gutter={10}>
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
@@ -486,7 +525,7 @@ export default function Layout(props: ParentProps) {
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
- <Tooltip placement="right" value="Archive session">
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session">
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</Tooltip>
</div>
@@ -499,6 +538,7 @@ export default function Layout(props: ParentProps) {
project={props.project}
depth={depth + 1}
childrenMap={props.childrenMap}
+ mobile={props.mobile}
/>
)}
</For>
@@ -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
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch>
- <Match when={layout.sidebar.opened()}>
- <Collapsible
- variant="ghost"
- open={props.project.expanded}
- class="gap-2 shrink-0"
- onOpenChange={handleOpenChange}
- >
+ <Match when={showExpanded()}>
+ <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
<Button
as={"div"}
variant="ghost"
@@ -556,7 +600,7 @@ export default function Layout(props: ParentProps) {
project={props.project}
class="group-hover/session:hidden"
expandable
- notify={!props.project.expanded}
+ notify={!isExpanded()}
/>
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</Collapsible.Trigger>
@@ -585,6 +629,7 @@ export default function Layout(props: ParentProps) {
slug={slug()}
project={props.project}
childrenMap={childSessionsByParent()}
+ mobile={props.mobile}
/>
)}
</For>
@@ -595,7 +640,7 @@ export default function Layout(props: ParentProps) {
>
<div class="flex items-center self-stretch w-full">
<div class="flex-1 min-w-0">
- <Tooltip placement="right" value="New session">
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
<A
href={`${slug()}/session`}
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
@@ -650,30 +695,12 @@ export default function Layout(props: ParentProps) {
)
}
- return (
- <div class="relative flex-1 min-h-0 flex flex-col">
- <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
- <div class="flex-1 min-h-0 flex">
- <div
- classList={{
- "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
- "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
- "border-r border-border-weak-base contain-strict": true,
- }}
- style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
- >
- <Show when={layout.sidebar.opened()}>
- <ResizeHandle
- direction="horizontal"
- size={layout.sidebar.width()}
- min={150}
- max={window.innerWidth * 0.3}
- collapseThreshold={80}
- onResize={layout.sidebar.resize}
- onCollapse={layout.sidebar.close}
- />
- </Show>
- <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+ const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
+ const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
+ return (
+ <>
+ <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+ <Show when={!sidebarProps.mobile}>
<Tooltip
class="shrink-0"
placement="right"
@@ -683,7 +710,7 @@ export default function Layout(props: ParentProps) {
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
</div>
}
- inactive={layout.sidebar.opened()}
+ inactive={expanded()}
>
<Button
variant="ghost"
@@ -715,110 +742,160 @@ export default function Layout(props: ParentProps) {
</Show>
</Button>
</Tooltip>
- <DragDropProvider
- onDragStart={handleDragStart}
- onDragEnd={handleDragEnd}
- onDragOver={handleDragOver}
- collisionDetector={closestCenter}
+ </Show>
+ <DragDropProvider
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div
+ ref={sidebarProps.mobile ? undefined : scrollContainerRef}
+ class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
- <DragDropSensors />
- <ConstrainDragXAxis />
- <div
- ref={scrollContainerRef}
- class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
- >
- <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
- <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
- </SortableProvider>
- </div>
- <DragOverlay>
- <ProjectDragOverlay />
- </DragOverlay>
- </DragDropProvider>
- </div>
- <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
- <Switch>
- <Match when={!providers.paid().length && layout.sidebar.opened()}>
- <div class="rounded-md bg-background-stronger shadow-xs-border-base">
- <div class="p-3 flex flex-col gap-2">
- <div class="text-12-medium text-text-strong">Getting started</div>
- <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
- <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
- </div>
- <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
- <Button
- class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
- size="large"
- icon="plus"
- onClick={connectProvider}
- >
- <Show when={layout.sidebar.opened()}>Connect provider</Show>
- </Button>
- </Tooltip>
+ <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
+ <For each={layout.projects.list()}>
+ {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
+ </For>
+ </SortableProvider>
+ </div>
+ <DragOverlay>
+ <ProjectDragOverlay />
+ </DragOverlay>
+ </DragDropProvider>
+ </div>
+ <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+ <Switch>
+ <Match when={!providers.paid().length && expanded()}>
+ <div class="rounded-md bg-background-stronger shadow-xs-border-base">
+ <div class="p-3 flex flex-col gap-2">
+ <div class="text-12-medium text-text-strong">Getting started</div>
+ <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
+ <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
- </Match>
- <Match when={true}>
- <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+ <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
- class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
- variant="ghost"
+ class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
size="large"
icon="plus"
onClick={connectProvider}
>
- <Show when={layout.sidebar.opened()}>Connect provider</Show>
+ Connect provider
</Button>
</Tooltip>
- </Match>
- </Switch>
- <Show when={platform.openDirectoryPickerDialog}>
- <Tooltip
- placement="right"
- value={
- <div class="flex items-center gap-2">
- <span>Open project</span>
- <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
- </div>
- }
- inactive={layout.sidebar.opened()}
- >
+ </div>
+ </Match>
+ <Match when={true}>
+ <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
- icon="folder-add-left"
- onClick={chooseProject}
+ icon="plus"
+ onClick={connectProvider}
>
- <Show when={layout.sidebar.opened()}>Open project</Show>
+ <Show when={expanded()}>Connect provider</Show>
</Button>
</Tooltip>
- </Show>
- {/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
- {/* <Button */}
- {/* disabled */}
- {/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
- {/* variant="ghost" */}
- {/* size="large" */}
- {/* icon="settings-gear" */}
- {/* > */}
- {/* <Show when={layout.sidebar.opened()}>Settings</Show> */}
- {/* </Button> */}
- {/* </Tooltip> */}
- <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
+ </Match>
+ </Switch>
+ <Show when={platform.openDirectoryPickerDialog}>
+ <Tooltip
+ placement="right"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Open project</span>
+ <Show when={!sidebarProps.mobile}>
+ <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
+ </Show>
+ </div>
+ }
+ inactive={expanded()}
+ >
<Button
- as={"a"}
- href="https://opencode.ai/desktop-feedback"
- target="_blank"
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
- icon="bubble-5"
+ icon="folder-add-left"
+ onClick={chooseProject}
>
- <Show when={layout.sidebar.opened()}>Share feedback</Show>
+ <Show when={expanded()}>Open project</Show>
</Button>
</Tooltip>
+ </Show>
+ <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
+ <Button
+ as={"a"}
+ href="https://opencode.ai/desktop-feedback"
+ target="_blank"
+ class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+ variant="ghost"
+ size="large"
+ icon="bubble-5"
+ >
+ <Show when={expanded()}>Share feedback</Show>
+ </Button>
+ </Tooltip>
+ </div>
+ </>
+ )
+ }
+
+ return (
+ <div class="relative flex-1 min-h-0 flex flex-col">
+ <Header
+ navigateToProject={navigateToProject}
+ navigateToSession={navigateToSession}
+ onMobileMenuToggle={mobileSidebar.toggle}
+ />
+ <div class="flex-1 min-h-0 flex">
+ <div
+ classList={{
+ "hidden xl:flex": true,
+ "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
+ "flex-col gap-5.5 items-start self-stretch justify-between": true,
+ "border-r border-border-weak-base contain-strict": true,
+ }}
+ style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+ >
+ <Show when={layout.sidebar.opened()}>
+ <ResizeHandle
+ direction="horizontal"
+ size={layout.sidebar.width()}
+ min={150}
+ max={window.innerWidth * 0.3}
+ collapseThreshold={80}
+ onResize={layout.sidebar.resize}
+ onCollapse={layout.sidebar.close}
+ />
+ </Show>
+ <SidebarContent />
+ </div>
+ <div class="xl:hidden">
+ <div
+ classList={{
+ "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
+ "opacity-100 pointer-events-auto": mobileSidebar.open(),
+ "opacity-0 pointer-events-none": !mobileSidebar.open(),
+ }}
+ onClick={(e) => {
+ if (e.target === e.currentTarget) mobileSidebar.hide()
+ }}
+ />
+ <div
+ classList={{
+ "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
+ "translate-x-0": mobileSidebar.open(),
+ "-translate-x-full": !mobileSidebar.open(),
+ }}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <SidebarContent mobile />
</div>
</div>
+
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
</div>
<Toast.Region />
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<string, boolean>,
+ 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 = () => (
+ <div
+ ref={mobileScrollRef}
+ onScroll={handleMobileScroll}
+ onClick={handleMobileInteraction}
+ class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
+ >
+ <div class="flex flex-col gap-45 items-start justify-start mt-4">
+ <For each={visibleUserMessages()}>
+ {(message) => (
+ <SessionTurn
+ sessionID={params.id!}
+ messageID={message.id}
+ stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
+ onStepsExpandedToggle={() => 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",
+ }}
+ />
+ )}
+ </For>
+ </div>
+ </div>
+ )
+
+ const NewSessionView = () => (
+ <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
+ <div class="text-20-medium text-text-weaker">New session</div>
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="folder" size="small" />
+ <div class="text-12-medium text-text-weak">
+ {getDirectory(sync.data.path.directory)}
+ <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+ </div>
+ </div>
+ <Show when={sync.project}>
+ {(project) => (
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="pencil-line" size="small" />
+ <div class="text-12-medium text-text-weak">
+ Last modified&nbsp;
+ <span class="text-text-strong">
+ {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+ </span>
+ </div>
+ </div>
+ )}
+ </Show>
+ </div>
+ )
+
+ const DesktopSessionContent = () => (
+ <Switch>
+ <Match when={params.id}>
+ <div class="flex items-start justify-start h-full min-h-0">
+ <SessionMessageRail
+ messages={visibleUserMessages()}
+ current={activeMessage()}
+ onMessageSelect={setActiveMessage}
+ wide={!showTabs()}
+ />
+ <Show when={activeMessage()}>
+ <SessionTurn
+ sessionID={params.id!}
+ messageID={activeMessage()!.id}
+ stepsExpanded={store.stepsExpanded}
+ onStepsExpandedToggle={() => 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"),
+ }}
+ />
+ </Show>
+ </div>
+ </Match>
+ <Match when={true}>
+ <NewSessionView />
+ </Match>
+ </Switch>
+ )
+
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
- <div class="min-h-0 grow w-full flex">
- {/* Session pane - always visible */}
+ <div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
+ <Switch>
+ <Match when={!params.id}>
+ <div class="flex-1 min-h-0 overflow-hidden">
+ <NewSessionView />
+ </div>
+ </Match>
+ <Match when={diffs().length > 0}>
+ <Tabs class="flex-1 min-h-0 flex flex-col pb-28">
+ <Tabs.List>
+ <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+ Session
+ </Tabs.Trigger>
+ <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+ {diffs().length} Files Changed
+ </Tabs.Trigger>
+ </Tabs.List>
+ <Tabs.Content value="session" class="flex-1 !overflow-hidden">
+ <MobileTurns />
+ </Tabs.Content>
+ <Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
+ <div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
+ <SessionReview
+ diffs={diffs()}
+ classes={{
+ root: "pb-32",
+ header: "px-4",
+ container: "px-4",
+ }}
+ />
+ </div>
+ </Tabs.Content>
+ </Tabs>
+ </Match>
+ <Match when={true}>
+ <div class="flex-1 min-h-0 overflow-hidden">
+ <MobileTurns />
+ </div>
+ </Match>
+ </Switch>
+ <div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
+ <div class="w-full">
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="hidden md:flex min-h-0 grow w-full">
<div
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
>
<div class="flex-1 min-h-0 overflow-hidden">
- <Switch>
- <Match when={params.id}>
- <div class="flex items-start justify-start h-full min-h-0">
- <SessionMessageRail
- messages={visibleUserMessages()}
- current={activeMessage()}
- onMessageSelect={setActiveMessage}
- wide={!showTabs()}
- />
- <Show when={activeMessage()}>
- <SessionTurn
- sessionID={params.id!}
- messageID={activeMessage()!.id}
- stepsExpanded={store.stepsExpanded}
- onStepsExpandedToggle={() => 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"),
- }}
- />
- </Show>
- </div>
- </Match>
- <Match when={true}>
- <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
- <div class="text-20-medium text-text-weaker">New session</div>
- <div class="flex justify-center items-center gap-3">
- <Icon name="folder" size="small" />
- <div class="text-12-medium text-text-weak">
- {getDirectory(sync.data.path.directory)}
- <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
- </div>
- </div>
- <Show when={sync.project}>
- {(project) => (
- <div class="flex justify-center items-center gap-3">
- <Icon name="pencil-line" size="small" />
- <div class="text-12-medium text-text-weak">
- Last modified&nbsp;
- <span class="text-text-strong">
- {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
- </span>
- </div>
- </div>
- )}
- </Show>
- </div>
- </Match>
- </Switch>
+ <DesktopSessionContent />
</div>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div
@@ -625,7 +773,6 @@ export default function Page() {
</Show>
</div>
- {/* Tabs pane - visible when there are diffs or file tabs */}
<Show when={showTabs()}>
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
<DragDropProvider
@@ -683,7 +830,7 @@ export default function Page() {
</div>
<Show when={diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
- <div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionReview
classes={{
root: "pb-40",
@@ -754,9 +901,10 @@ export default function Page() {
</div>
</Show>
</div>
+
<Show when={layout.terminal.opened()}>
<div
- class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+ class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
>
<ResizeHandle
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 8642be0f8..75a737d88 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -54,6 +54,7 @@ const icons = {
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
+ menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index f2fa0f320..4f0645bf8 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -5,9 +5,11 @@ import {
FilePart,
Message as MessageType,
Part as PartType,
+ ReasoningPart,
TextPart,
ToolPart,
UserMessage,
+ Todo,
} from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
@@ -111,7 +113,7 @@ export type ToolInfo = {
subtitle?: string
}
-export function getToolInfo(tool: string, input: Record<string, any> = {}): ToolInfo {
+export function getToolInfo(tool: string, input: any = {}): ToolInfo {
switch (tool) {
case "read":
return {
@@ -186,8 +188,7 @@ export function getToolInfo(tool: string, input: Record<string, any> = {}): Tool
}
function getToolPartInfo(part: ToolPart): ToolInfo {
- const state = part.state as any
- const input = state.input || {}
+ const input = part.state.input || {}
return getToolInfo(part.tool, input)
}
@@ -424,7 +425,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
}
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
- const part = props.part as any
+ const part = props.part as ReasoningPart
return (
<Show when={part.text.trim()}>
<div data-component="reasoning-part">
@@ -722,14 +723,14 @@ ToolRegistry.register({
trigger={{
title: "To-dos",
subtitle: props.input.todos
- ? `${props.input.todos.filter((t: any) => t.status === "completed").length}/${props.input.todos.length}`
+ ? `${props.input.todos.filter((t: Todo) => t.status === "completed").length}/${props.input.todos.length}`
: "",
}}
>
<Show when={props.input.todos?.length}>
<div data-component="todos">
<For each={props.input.todos}>
- {(todo: any) => (
+ {(todo: Todo) => (
<Checkbox readOnly checked={todo.status === "completed"}>
<div data-slot="message-part-todo-content" data-completed={todo.status === "completed"}>
{todo.content}