diff options
| author | Adam <[email protected]> | 2025-12-22 04:37:10 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-22 05:46:07 -0600 |
| commit | 653c206688262c080cba988a237acd67da9e714f (patch) | |
| tree | e74c1d439ffe4a4ed31b318d0091dc702526c288 /packages | |
| parent | 580f46b589e3cfdbf21d135ee61e2e258c76e46e (diff) | |
| download | opencode-653c206688262c080cba988a237acd67da9e714f.tar.gz opencode-653c206688262c080cba988a237acd67da9e714f.zip | |
feat(desktop): mobile responsiveness
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/desktop/src/components/header.tsx | 56 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 6 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 6 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 319 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 272 | ||||
| -rw-r--r-- | packages/ui/src/components/icon.tsx | 1 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 13 |
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 + <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 - <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} |
