diff options
| author | adamelmore <[email protected]> | 2026-01-12 08:38:29 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-15 07:29:13 -0600 |
| commit | 9f66a45970d1edf12ae9b3e7a22d77711b5e51c3 (patch) | |
| tree | a34f43cddd3fc53a731af7c2fb631f3ec05c012c /packages/app | |
| parent | 779610d668e239c07014c9d350d47e2213c07b26 (diff) | |
| download | opencode-9f66a45970d1edf12ae9b3e7a22d77711b5e51c3.tar.gz opencode-9f66a45970d1edf12ae9b3e7a22d77711b5e51c3.zip | |
feat(app): new layout
Diffstat (limited to 'packages/app')
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 402 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 9 | ||||
| -rw-r--r-- | packages/app/src/context/platform.tsx | 3 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 842 |
4 files changed, 636 insertions, 620 deletions
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index cfc6eb438..b2e7fafeb 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,5 @@ import { createMemo, createResource, Show } from "solid-js" +import { Portal } from "solid-js/web" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" @@ -57,211 +58,218 @@ export function SessionHeader() { navigate(`/${params.dir}/session/${session.id}`) } + const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) + return ( - <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex"> - <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={layout.mobileSidebar.toggle} - > - <Icon name="menu" size="small" /> - </button> - <div class="px-4 flex items-center justify-between gap-4 w-full"> - <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={worktrees()} - current={sync.project?.worktree ?? projectDirectory()} - label={(x) => getFilename(x)} - onSelect={(x) => (x ? 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> - <Show - when={parentSession()} - fallback={ - <> + <> + <Show when={leftMount()}> + {(mount) => ( + <Portal mount={mount()}> + <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={sessions()} - current={currentSession()} - placeholder="New session" - label={(x) => x.title} - value={(x) => x.id} - onSelect={navigateToSession} - class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" + options={worktrees()} + current={sync.project?.worktree ?? projectDirectory()} + label={(x) => getFilename(x)} + onSelect={(x) => (x ? navigateToProject(x) : undefined)} + class="text-14-regular text-text-base" variant="ghost" - /> - </> - } - > - <div class="flex items-center gap-2 min-w-0"> - <Select - options={sessions()} - current={parentSession()} - placeholder="Back to parent session" - label={(x) => x.title} - value={(x) => x.id} - onSelect={(session) => { - // Only navigate if selecting a different session than current parent - const currentParent = parentSession() - if (session && currentParent && session.id !== currentParent.id) { - navigateToSession(session) - } - }} - class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" - variant="ghost" - /> - <div class="text-text-weaker">/</div> - <div class="flex items-center gap-1.5 min-w-0"> - <Tooltip value="Back to parent session"> - <button - type="button" - class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0" - onClick={() => navigateToSession(parentSession())} - > - <Icon name="arrow-left" size="small" class="text-icon-base" /> - </button> - </Tooltip> + > + {/* @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> + <Show + when={parentSession()} + fallback={ + <> + <Select + options={sessions()} + current={currentSession()} + placeholder="New session" + label={(x) => x.title} + value={(x) => x.id} + onSelect={navigateToSession} + class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" + variant="ghost" + /> + </> + } + > + <div class="flex items-center gap-2 min-w-0"> + <Select + options={sessions()} + current={parentSession()} + placeholder="Back to parent session" + label={(x) => x.title} + value={(x) => x.id} + onSelect={(session) => { + const currentParent = parentSession() + if (session && currentParent && session.id !== currentParent.id) { + navigateToSession(session) + } + }} + class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md" + variant="ghost" + /> + <div class="text-text-weaker">/</div> + <Tooltip value="Back to parent session"> + <button + type="button" + class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0" + onClick={() => navigateToSession(parentSession())} + > + <Icon name="arrow-left" size="small" class="text-icon-base" /> + </button> + </Tooltip> + </div> + </Show> </div> - </Show> - </div> - <Show when={currentSession() && !parentSession()}> - <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}> - <IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" /> - </TooltipKeybind> - </Show> - </div> - <div class="flex items-center gap-3"> - <div class="hidden md:flex items-center gap-1"> - <Button - size="small" - variant="ghost" - onClick={() => { - dialog.show(() => <DialogSelectServer />) - }} - > - <div - classList={{ - "size-1.5 rounded-full": true, - "bg-icon-success-base": server.healthy() === true, - "bg-icon-critical-base": server.healthy() === false, - "bg-border-weak-base": server.healthy() === undefined, - }} - /> - <Icon name="server" size="small" class="text-icon-weak" /> - <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> - </Button> - <SessionLspIndicator /> - <SessionMcpIndicator /> - </div> - <div class="flex items-center gap-1"> - <Show when={currentSession()?.summary?.files}> - <TooltipKeybind - class="hidden md:block shrink-0" - title="Toggle review" - keybind={command.keybind("review.toggle")} - > + <Show when={currentSession() && !parentSession()}> + <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}> + <IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" /> + </TooltipKeybind> + </Show> + </div> + </Portal> + )} + </Show> + <Show when={rightMount()}> + {(mount) => ( + <Portal mount={mount()}> + <div class="flex items-center gap-3"> + <div class="hidden md:flex items-center gap-1"> <Button + size="small" variant="ghost" - class="group/review-toggle size-6 p-0" - onClick={() => view().reviewPanel.toggle()} + onClick={() => { + dialog.show(() => <DialogSelectServer />) + }} > - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - name={view().reviewPanel.opened() ? "layout-right" : "layout-left"} - size="small" - class="group-hover/review-toggle:hidden" - /> - <Icon - name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"} - size="small" - class="hidden group-hover/review-toggle:inline-block" - /> - <Icon - name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"} - size="small" - class="hidden group-active/review-toggle:inline-block" - /> - </div> - </Button> - </TooltipKeybind> - </Show> - <TooltipKeybind - class="hidden md:block shrink-0" - title="Toggle terminal" - keybind={command.keybind("terminal.toggle")} - > - <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().terminal.toggle()}> - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} - class="group-hover/terminal-toggle:hidden" + <div + classList={{ + "size-1.5 rounded-full": true, + "bg-icon-success-base": server.healthy() === true, + "bg-icon-critical-base": server.healthy() === false, + "bg-border-weak-base": server.healthy() === undefined, + }} /> - <Icon - size="small" - name="layout-bottom-partial" - class="hidden group-hover/terminal-toggle:inline-block" - /> - <Icon - size="small" - name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} - class="hidden group-active/terminal-toggle:inline-block" - /> - </div> - </Button> - </TooltipKeybind> - </div> - <Show when={shareEnabled() && currentSession()}> - <Popover - title="Share session" - trigger={ - <Tooltip class="shrink-0" value="Share session"> - <IconButton icon="share" variant="ghost" class="" /> - </Tooltip> - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) - } - return shareURL - }, - { initialValue: "" }, - ) - return ( - <Show when={url.latest}> - {(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />} - </Show> - ) - })} - </Popover> - </Show> - </div> - </div> - </header> + <Icon name="server" size="small" class="text-icon-weak" /> + <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> + </Button> + <SessionLspIndicator /> + <SessionMcpIndicator /> + </div> + <div class="flex items-center gap-1"> + <Show when={currentSession()?.summary?.files}> + <TooltipKeybind + class="hidden md:block shrink-0" + title="Toggle review" + keybind={command.keybind("review.toggle")} + > + <Button + variant="ghost" + class="group/review-toggle size-6 p-0" + onClick={() => view().reviewPanel.toggle()} + > + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + name={view().reviewPanel.opened() ? "layout-right" : "layout-left"} + size="small" + class="group-hover/review-toggle:hidden" + /> + <Icon + name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"} + size="small" + class="hidden group-hover/review-toggle:inline-block" + /> + <Icon + name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"} + size="small" + class="hidden group-active/review-toggle:inline-block" + /> + </div> + </Button> + </TooltipKeybind> + </Show> + <TooltipKeybind + class="hidden md:block shrink-0" + title="Toggle terminal" + keybind={command.keybind("terminal.toggle")} + > + <Button + variant="ghost" + class="group/terminal-toggle size-6 p-0" + onClick={() => view().terminal.toggle()} + > + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} + class="group-hover/terminal-toggle:hidden" + /> + <Icon + size="small" + name="layout-bottom-partial" + class="hidden group-hover/terminal-toggle:inline-block" + /> + <Icon + size="small" + name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} + class="hidden group-active/terminal-toggle:inline-block" + /> + </div> + </Button> + </TooltipKeybind> + </div> + <Show when={shareEnabled() && currentSession()}> + <Popover + title="Share session" + trigger={ + <Tooltip class="shrink-0" value="Share session"> + <IconButton icon="share" variant="ghost" class="" /> + </Tooltip> + } + > + {iife(() => { + const [url] = createResource( + () => currentSession(), + async (session) => { + if (!session) return + let shareURL = session.share?.url + if (!shareURL) { + shareURL = await globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) + } + return shareURL + }, + { initialValue: "" }, + ) + return ( + <Show when={url.latest}> + {(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />} + </Show> + ) + })} + </Popover> + </Show> + </div> + </Portal> + )} + </Show> + </> ) } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ea0b90d5d..a0b257056 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -124,12 +124,19 @@ function createGlobalSync() { return globalSDK.client.session .list({ directory, roots: true }) .then((x) => { - const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) .slice() .sort((a, b) => a.id.localeCompare(b.id)) + + const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory)) + if (sandboxWorkspace) { + setStore("session", reconcile(nonArchived, { key: "id" })) + return + } + + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 // Include up to the limit, plus any updated in the last 4 hours const sessions = nonArchived.filter((s, i) => { if (i < limit) return true diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b0822e707..6d2d3db06 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,6 +5,9 @@ export type Platform = { /** Platform discriminator */ platform: "web" | "desktop" + /** Desktop OS (Tauri only) */ + os?: "macos" | "windows" | "linux" + /** App version */ version?: string diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 39f397ac4..535f4aef2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,6 @@ import { untrack, type JSX, } from "solid-js" -import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" @@ -27,9 +26,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" -import { Mark } from "@opencode-ai/ui/logo" import { getFilename } from "@opencode-ai/util/path" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce, reconcile } from "solid-js/store" @@ -53,7 +50,6 @@ import { retry } from "@opencode-ai/util/retry" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogSelectServer } from "@/components/dialog-select-server" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" @@ -64,16 +60,12 @@ import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ lastSession: {} as { [directory: string]: string }, - activeDraggable: undefined as string | undefined, - mobileProjectsExpanded: {} as Record<string, boolean>, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record<string, string[]>, + workspaceExpanded: {} as Record<string, boolean>, }) - 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) @@ -275,6 +267,31 @@ export default function Layout(props: ParentProps) { return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) + createEffect(() => { + const project = currentProject() + if (!project) return + + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) { + setStore("workspaceOrder", project.worktree, dirs) + return + } + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + const merged = [...keep, ...missing] + + if (merged.length !== existing.length) { + setStore("workspaceOrder", project.worktree, merged) + return + } + + if (merged.some((d, i) => d !== existing[i])) { + setStore("workspaceOrder", project.worktree, merged) + } + }) + function projectSessions(project: LocalProject | undefined) { if (!project) return [] const dirs = [project.worktree, ...(project.sandboxes ?? [])] @@ -325,7 +342,7 @@ export default function Layout(props: ParentProps) { return created } - const prefetchMessages = (directory: string, sessionID: string, token: number) => { + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) @@ -704,18 +721,17 @@ export default function Layout(props: ParentProps) { const id = params.id setStore("lastSession", directory, id) notification.session.markViewed(id) - const project = currentProject() - untrack(() => layout.projects.expand(project?.worktree ?? directory)) + untrack(() => setStore("workspaceExpanded", directory, true)) requestAnimationFrame(() => scrollToSession(id)) }) createEffect(() => { if (isLargeViewport()) { - const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64 document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) - } else { - document.documentElement.style.setProperty("--dialog-left-margin", "0px") + return } + document.documentElement.style.setProperty("--dialog-left-margin", "0px") }) function getDraggableId(event: unknown): string | undefined { @@ -729,7 +745,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setStore("activeDraggable", id) + setStore("activeProject", id) } function handleDragOver(event: DragEvent) { @@ -745,44 +761,73 @@ export default function Layout(props: ParentProps) { } function handleDragEnd() { - setStore("activeDraggable", undefined) + setStore("activeProject", undefined) + } + + function workspaceIds(project: LocalProject | undefined) { + if (!project) return [] + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + const existing = store.workspaceOrder[project.worktree] + if (!existing) return dirs + + const keep = existing.filter((d) => dirs.includes(d)) + const missing = dirs.filter((d) => !existing.includes(d)) + return [...keep, ...missing] + } + + function handleWorkspaceDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeWorkspace", id) + } + + function handleWorkspaceDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const project = currentProject() + if (!project) return + + const ids = workspaceIds(project) + const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString()) + const toIndex = ids.findIndex((dir) => dir === droppable.id.toString()) + if (fromIndex === -1 || toIndex === -1) return + if (fromIndex === toIndex) return + + const result = ids.slice() + const [item] = result.splice(fromIndex, 1) + if (!item) return + result.splice(toIndex, 0, item) + setStore("workspaceOrder", project.worktree, result) + } + + function handleWorkspaceDragEnd() { + setStore("activeWorkspace", undefined) } - const ProjectAvatar = (props: { - project: LocalProject - class?: string - expandable?: boolean - notify?: boolean - }): JSX.Element => { + const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( - <div class="relative size-5 shrink-0 rounded-sm"> + <div class={`relative size-10 shrink-0 ${props.class ?? ""}`}> <Avatar fallback={name()} src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url} {...getAvatarColors(props.project.icon?.color)} - class={`size-full ${props.class ?? ""}`} + class="size-full rounded-lg" style={ notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined } /> - <Show when={props.expandable}> - <Icon - name="chevron-right" - size="normal" - class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50" - /> - </Show> <Show when={notifications().length > 0 && props.notify}> <div classList={{ - "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true, + "absolute -top-0.5 -right-0.5 size-2 rounded-full": true, "bg-icon-critical-base": hasError(), "bg-text-interactive-base": !hasError(), }} @@ -792,47 +837,8 @@ export default function Layout(props: ParentProps) { ) } - const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const current = createMemo(() => base64Decode(params.dir ?? "")) - return ( - <Switch> - <Match when={layout.sidebar.opened()}> - <Button - as={"div"} - variant="ghost" - data-active - class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg" - > - <div class="flex items-center gap-3 p-0 text-left min-w-0 grow"> - <ProjectAvatar project={props.project} /> - <span class="truncate text-14-medium text-text-strong">{name()}</span> - </div> - </Button> - </Match> - <Match when={true}> - <Button - variant="ghost" - size="large" - class="flex items-center justify-center p-0 aspect-square border-none rounded-lg" - data-selected={props.project.worktree === current()} - onClick={() => navigateToProject(props.project.worktree)} - > - <ProjectAvatar project={props.project} notify /> - </Button> - </Match> - </Switch> - ) - } - - const SessionItem = (props: { - session: Session - slug: string - project: LocalProject - mobile?: boolean - }): JSX.Element => { + const SessionItem = (props: { session: Session; slug: string; mobile?: boolean }): JSX.Element => { const notification = useNotification() - const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const [sessionStore] = globalSync.child(props.session.directory) @@ -852,294 +858,243 @@ export default function Layout(props: ParentProps) { const status = sessionStore.session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) + return ( - <> - <div - data-session-id={props.session.id} - class="group/session relative w-full rounded-md cursor-default transition-colors - hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" - > - <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 pl-4 pr-2 py-1" - onMouseEnter={() => prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > - <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> - <span - classList={{ - "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true, - "animate-pulse": isWorking(), - }} - > - {props.session.title} - </span> - <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <Switch> - <Match when={isWorking()}> - <Spinner class="size-2.5 mr-0.5" /> - </Match> - <Match when={hasPermissions()}> - <div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" /> - </Match> - <Match when={hasError()}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={notifications().length > 0}> - <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> - </Match> - <Match when={true}> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </Match> - </Switch> - </div> - </div> - <Show when={props.session.summary?.files}> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span> - <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> - </div> - </Show> - </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"> - <TooltipKeybind - placement={props.mobile ? "bottom" : "right"} - title="Archive session" - keybind={command.keybind("session.archive")} + <div + data-session-id={props.session.id} + class="group/session relative w-full rounded-md cursor-default transition-colors + hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" + > + <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> + <A + href={`${props.slug}/session/${props.session.id}`} + class="flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1 transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7" + onMouseEnter={() => prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > + <span + classList={{ + "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true, + "animate-pulse": isWorking(), + }} > - <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> - </TooltipKeybind> - </div> + {props.session.title} + </span> + <div class="shrink-0 flex items-center gap-2"> + <Switch> + <Match when={isWorking()}> + <Spinner class="size-2.5" /> + </Match> + <Match when={hasPermissions()}> + <div class="size-1.5 rounded-full bg-surface-warning-strong" /> + </Match> + <Match when={hasError()}> + <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={notifications().length > 0}> + <div class="size-1.5 rounded-full bg-text-interactive-base" /> + </Match> + </Switch> + <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show> + </div> + </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"> + <TooltipKeybind + placement={props.mobile ? "bottom" : "right"} + title="Archive session" + keybind={command.keybind("session.archive")} + > + <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> + </TooltipKeybind> </div> - </> + </div> ) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) - const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) - const defaultWorktree = createMemo(() => base64Encode(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const [store, setProjectStore] = globalSync.child(props.project.worktree) - const stores = createMemo(() => - [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]), - ) - const sessions = createMemo(() => - stores() - .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory)) - .toSorted(sortSessions), - ) - const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) - const hasMoreSessions = createMemo(() => store.sessionTotal > store.session.length) - const loadMoreSessions = async () => { - 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 isActive = createMemo(() => { + const selected = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) - const handleOpenChange = (open: boolean) => { - 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={showExpanded()}> - <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}> - <Button - as={"div"} - variant="ghost" - classList={{ - "group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true, - "bg-surface-raised-base-hover": isActive() && !isExpanded(), - }} - > - <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none"> - <ProjectAvatar - project={props.project} - class="group-hover/session:hidden" - expandable - notify={!isExpanded()} - /> - <span class="truncate text-14-medium text-text-strong">{name()}</span> - </Collapsible.Trigger> - <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible"> - <DropdownMenu> - <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" /> - <DropdownMenu.Portal> - <DropdownMenu.Content> - <DropdownMenu.Item - onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)} - > - <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}> - <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - <TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}> - <IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" /> - </TooltipKeybind> - </div> - </Button> - <Collapsible.Content> - <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> - <For each={rootSessions()}> - {(session) => ( - <SessionItem - session={session} - slug={base64Encode(session.directory)} - project={props.project} - mobile={props.mobile} - /> - )} - </For> - <Show when={rootSessions().length === 0}> - <div - class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors - hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover" - > - <div class="flex items-center self-stretch w-full"> - <div class="flex-1 min-w-0"> - <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session"> - <A - href={`${defaultWorktree()}/session`} - class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none" - > - <div class="flex items-center self-stretch gap-6 justify-between"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - New session - </span> - </div> - </A> - </Tooltip> - </div> - </div> - </div> - </Show> - <Show when={hasMoreSessions()}> - <div class="relative w-full py-1"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5" - size="large" - onClick={loadMoreSessions} - > - Load more - </Button> - </div> - </Show> - </nav> - </Collapsible.Content> - </Collapsible> - </Match> - <Match when={true}> - <Tooltip placement="right" value={getFilename(props.project.worktree)}> - <ProjectVisual project={props.project} /> - </Tooltip> - </Match> - </Switch> + <Tooltip placement={props.mobile ? "bottom" : "right"} value={name()}> + <Button + variant="ghost" + size="large" + class="flex items-center justify-center p-0 size-12 rounded-xl" + data-selected={selected()} + onClick={() => navigateToProject(props.project.worktree)} + > + <ProjectIcon project={props.project} notify /> + </Button> + </Tooltip> </div> ) } const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable)) + const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) return ( <Show when={project()}> {(p) => ( - <div class="bg-background-base rounded-md"> - <ProjectVisual project={p()} /> + <div class="bg-background-base rounded-xl p-1"> + <ProjectIcon project={p()} /> </div> )} </Show> ) } - const SidebarContent = (sidebarProps: { mobile?: boolean }) => { - const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + const WorkspaceDragOverlay = (): JSX.Element => { + const label = createMemo(() => { + const project = currentProject() + if (!project) return + const directory = store.activeWorkspace + if (!directory) return + + const [workspaceStore] = globalSync.child(directory) + const kind = directory === project.worktree ? "local" : "sandbox" + const name = workspaceStore.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name}` + }) + return ( - <div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0"> - <div class="flex flex-col items-start self-stretch gap-4 min-h-0"> - <Show when={!sidebarProps.mobile}> - <div - classList={{ - "border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true, - "justify-start": expanded(), - }} - > - <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region> - <Mark class="shrink-0" /> - </A> + <Show when={label()}> + {(value) => ( + <div class="bg-background-base rounded-md px-2 py-1 text-12-medium text-text-strong">{value()}</div> + )} + </Show> + ) + } + + const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions), + ) + const local = createMemo(() => props.directory === props.project.worktree) + const title = createMemo(() => { + const kind = local() ? "local" : "sandbox" + const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) + return `${kind} : ${name}` + }) + const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) + const hasMore = createMemo(() => local() && workspaceStore.session.length >= workspaceStore.limit) + const loadMore = async () => { + if (!local()) return + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + return ( + // @ts-ignore + <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> + <Collapsible + variant="ghost" + open={open()} + class="gap-1.5 shrink-0" + onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)} + > + <Collapsible.Trigger class="group/trigger flex items-center justify-between w-full px-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover"> + <div class="flex items-center gap-2 min-w-0"> + <Icon + name="chevron-right" + size="small" + class="text-text-subtle transition-transform duration-50 group-data-[expanded]/trigger:rotate-90" + /> + <span class="truncate text-12-medium text-text-strong">{title()}</span> </div> - </Show> - <div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0"> - <Show when={!sidebarProps.mobile}> - <TooltipKeybind - class="shrink-0" - placement="right" - title="Toggle sidebar" - keybind={command.keybind("sidebar.toggle")} - inactive={expanded()} + </Collapsible.Trigger> + <Collapsible.Content> + <nav class="flex flex-col gap-1 pl-2"> + <For each={sessions()}> + {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />} + </For> + <Button + as={A} + href={`${slug()}/session`} + variant="ghost" + size="large" + icon="plus-small" + class="flex w-full text-left justify-start text-text-base rounded-md px-3" > - <Button - variant="ghost" - size="large" - class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2" - onClick={layout.sidebar.toggle} - > - <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - name={layout.sidebar.opened() ? "layout-left" : "layout-right"} - size="small" - class="group-hover/sidebar-toggle:hidden" - /> - <Icon - name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"} - size="small" - class="hidden group-hover/sidebar-toggle:inline-block" - /> - <Icon - name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"} - size="small" - class="hidden group-active/sidebar-toggle:inline-block" - /> - </div> - <Show when={layout.sidebar.opened()}> - <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base"> - Toggle sidebar - </div> - </Show> - </Button> - </TooltipKeybind> - </Show> + New session + </Button> + <Show when={hasMore()}> + <div class="relative w-full py-1"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5" + size="large" + onClick={loadMore} + > + Load more + </Button> + </div> + </Show> + </nav> + </Collapsible.Content> + </Collapsible> + </div> + ) + } + + const SidebarContent = (sidebarProps: { mobile?: boolean }) => { + const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + + const project = createMemo(() => currentProject()) + const projectName = createMemo(() => { + const current = project() + if (!current) return "" + return current.name || getFilename(current.worktree) + }) + const workspaces = createMemo(() => workspaceIds(project())) + + const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return "Request failed" + } + + const createWorkspace = async () => { + const current = project() + if (!current) return + + const created = await globalSDK.client.worktree + .create({ directory: current.worktree }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: "Failed to create workspace", + description: errorMessage(err), + }) + return undefined + }) + + if (!created?.directory) return + + globalSync.child(created.directory) + navigate(`/${base64Encode(created.directory)}/session`) + } + + return ( + <div class="flex h-full w-full overflow-hidden"> + <div class="w-16 shrink-0 bg-background-base border-r border-border-weak-base flex flex-col items-center overflow-hidden"> + <div class="flex-1 min-h-0 w-full"> <DragDropProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd} @@ -1148,17 +1103,25 @@ export default function Layout(props: ParentProps) { > <DragDropSensors /> <ConstrainDragXAxis /> - <div - ref={(el) => { - if (!sidebarProps.mobile) scrollContainerRef = el - }} - class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" - > + <div class="h-full w-full flex flex-col items-center gap-2 px-2 py-3 overflow-y-auto no-scrollbar"> <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}> <For each={layout.projects.list()}> {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />} </For> </SortableProvider> + <Tooltip + placement={sidebarProps.mobile ? "bottom" : "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> + } + > + <IconButton icon="plus" variant="ghost" class="size-12 rounded-xl" onClick={chooseProject} /> + </Tooltip> </div> <DragOverlay> <ProjectDragOverlay /> @@ -1166,29 +1129,61 @@ export default function Layout(props: ParentProps) { </DragDropProvider> </div> </div> - <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> - <Switch> - <Match when={providers.all().length > 0 && !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> - <Tooltip placement="right" value="Connect provider" inactive={expanded()}> - <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} - > - Connect provider - </Button> - </Tooltip> - </div> - </Match> - <Match when={providers.all().length > 0}> - <Tooltip placement="right" value="Connect provider" inactive={expanded()}> + + <Show when={expanded()}> + <div + classList={{ + "flex flex-col min-h-0 bg-background-base border-r border-border-weak-base": true, + "flex-1 min-w-0": sidebarProps.mobile, + }} + style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} + > + <Show when={project()}> + {(p) => ( + <> + <div class="shrink-0 h-12 flex items-center justify-between px-3 border-b border-border-weak-base"> + <div class="min-w-0 truncate text-14-medium text-text-strong">{projectName()}</div> + <Button variant="ghost" size="large" icon="plus-small" onClick={createWorkspace}> + New workspace + </Button> + </div> + + <div class="flex-1 min-h-0"> + <DragDropProvider + onDragStart={handleWorkspaceDragStart} + onDragEnd={handleWorkspaceDragEnd} + onDragOver={handleWorkspaceDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragXAxis /> + <div + ref={(el) => { + if (!sidebarProps.mobile) scrollContainerRef = el + }} + class="h-full w-full flex flex-col gap-2 px-2 py-2 overflow-y-auto no-scrollbar" + > + <SortableProvider ids={workspaces()}> + <For each={workspaces()}> + {(directory) => ( + <SortableWorkspace directory={directory} project={p()} mobile={sidebarProps.mobile} /> + )} + </For> + </SortableProvider> + </div> + <DragOverlay> + <WorkspaceDragOverlay /> + </DragOverlay> + </DragDropProvider> + </div> + </> + )} + </Show> + <Show when={!project()}> + <div class="p-3 text-12-regular text-text-weak">Open a project to see workspaces.</div> + </Show> + <Show when={providers.all().length > 0}> + <div class="shrink-0 px-2 py-3 border-t border-border-weak-base flex flex-col gap-1.5"> <Button class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2" variant="ghost" @@ -1196,77 +1191,89 @@ export default function Layout(props: ParentProps) { icon="plus" onClick={connectProvider} > - <Show when={expanded()}>Connect provider</Show> + Connect provider + </Button> + <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" + > + Share feedback </Button> - </Tooltip> - </Match> - </Switch> - <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 - 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} - > - <Show when={expanded()}>Open project</Show> - </Button> - </Tooltip> - <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> + </Show> + </div> + </Show> </div> ) } + const isMac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserveWindowButtons = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + return ( <div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> + <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex items-center"> + <div + classList={{ + "flex items-center w-full min-w-0 pr-2": true, + "pl-2": !isMac(), + }} + > + <Show when={isMac()}> + <div class="w-[72px] h-full shrink-0" data-tauri-drag-region /> + </Show> + <IconButton + icon="menu" + variant="ghost" + class="xl:hidden size-8 rounded-md" + onClick={layout.mobileSidebar.toggle} + /> + <TooltipKeybind + class="hidden xl:flex shrink-0" + placement="bottom" + title="Toggle sidebar" + keybind={command.keybind("sidebar.toggle")} + > + <IconButton + icon={layout.sidebar.opened() ? "layout-left" : "layout-right"} + variant="ghost" + class="size-8 rounded-md" + onClick={layout.sidebar.toggle} + /> + </TooltipKeybind> + <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> + <div class="flex-1 h-full" data-tauri-drag-region /> + <div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" /> + <Show when={reserveWindowButtons()}> + <div class="w-[120px] h-full shrink-0" data-tauri-drag-region /> + </Show> + </div> + </header> <div class="flex-1 min-h-0 flex"> <div classList={{ "hidden xl:block": true, "relative shrink-0": true, }} - style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }} + style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "64px" }} > - <div - classList={{ - "@container w-full h-full pb-5 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, - }} - > + <div class="@container w-full h-full contain-strict"> <SidebarContent /> </div> <Show when={layout.sidebar.opened()}> <ResizeHandle direction="horizontal" size={layout.sidebar.width()} - min={150} - max={window.innerWidth * 0.3} - collapseThreshold={80} + min={214} + max={window.innerWidth * 0.3 + 64} + collapseThreshold={144} onResize={layout.sidebar.resize} onCollapse={layout.sidebar.close} /> @@ -1285,21 +1292,12 @@ export default function Layout(props: ParentProps) { /> <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 pb-5 transition-transform duration-200 ease-out": true, + "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true, "translate-x-0": layout.mobileSidebar.opened(), "-translate-x-full": !layout.mobileSidebar.opened(), }} onClick={(e) => e.stopPropagation()} > - <div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0"> - <A - href="/" - class="shrink-0 h-8 flex items-center justify-start px-2 w-full" - onClick={() => layout.mobileSidebar.hide()} - > - <Mark class="shrink-0" /> - </A> - </div> <SidebarContent mobile /> </div> </div> |
