From 9f66a45970d1edf12ae9b3e7a22d77711b5e51c3 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 12 Jan 2026 08:38:29 -0600 Subject: [PATCH] feat(app): new layout --- .../src/components/session/session-header.tsx | 406 ++++----- packages/app/src/context/global-sync.tsx | 9 +- packages/app/src/context/platform.tsx | 3 + packages/app/src/pages/layout.tsx | 842 +++++++++--------- packages/desktop/src/index.tsx | 31 +- 5 files changed, 659 insertions(+), 632 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 ( -
- -
-
-
- - - 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" - /> -
/
-
- - - + -
-
-
- - - -
-
- -
- -
+ +
+ + + +
+ + )} + + + {(mount) => ( + +
+ - - - - - } - > - {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 ( - - {(shareUrl) => } - - ) - })} - - -
- -
+ + + +
+ + + + +
+ + + + + } + > + {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 ( + + {(shareUrl) => } + + ) + })} + + + + + )} + + ) } 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, + activeProject: undefined as string | undefined, + activeWorkspace: undefined as string | undefined, + workspaceOrder: {} as Record, + workspaceExpanded: {} as Record, }) - 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) } - const ProjectAvatar = (props: { - project: LocalProject - class?: string - expandable?: boolean - notify?: boolean - }): JSX.Element => { + 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 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 ( -
+
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined } /> - - 0 && props.notify}>
{ - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const current = createMemo(() => base64Decode(params.dir ?? "")) - return ( - - - - - - - - - ) - } - - 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 ( - <> -
- - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} +
+ + prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > + -
- - {props.session.title} - -
- - - - - -
- - -
- - 0}> -
- - - - {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")} - - - -
-
- -
- {`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`} - {(summary) => } -
-
-
- - + {props.session.title} + +
+ + + + + +
+ + +
+ + 0}> +
+ + + {(summary) => } +
+ + + - +
) } 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
- - - - - - - - - - - - - - - + + +
) } 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 ( {(p) => ( -
- +
+
)} ) } + 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 ( + + {(value) => ( +
{value()}
+ )} +
+ ) + } + + 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 +
+ setStore("workspaceExpanded", props.directory, value)} + > + +
+ + {title()} +
+
+ + + +
+
+ ) + } + 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 ( -
-
- -
- - - -
-
-
- - - - - +
+
+
-
{ - if (!sidebarProps.mobile) scrollContainerRef = el - }} - class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar" - > +
p.worktree)}> {(project) => } + + Open project + + {command.keybind("project.open")} + +
+ } + > + +
@@ -1166,29 +1129,61 @@ export default function Layout(props: ParentProps) {
-
- - 0 && !providers.paid().length && expanded()}> -
-
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
-
- - - -
-
- 0}> - + + +
+ + {(p) => ( + <> +
+
{projectName()}
+ +
+ +
+ + + +
{ + if (!sidebarProps.mobile) scrollContainerRef = el + }} + class="h-full w-full flex flex-col gap-2 px-2 py-2 overflow-y-auto no-scrollbar" + > + + + {(directory) => ( + + )} + + +
+ + + +
+
+ + )} +
+ +
Open a project to see workspaces.
+
+ 0}> +
+ - - - - - Open project - - {command.keybind("project.open")} -
- } - inactive={expanded()} - > - - - - - -
+
+
+
) } + const isMac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserveWindowButtons = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + return (
+
+
+ +
+ + + +
+
+
+ +
+ +
+
-
+
@@ -1285,21 +1292,12 @@ export default function Layout(props: ParentProps) { />
e.stopPropagation()} > -
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index f05a28e14..e554f8da0 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Logo } from "@opencode-ai/ui/logo" -import { createSignal, Show, Accessor, JSX, createResource } from "solid-js" +import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" import { createMenu } from "./menu" @@ -30,6 +30,11 @@ let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ platform: "desktop", + os: (() => { + const type = ostype() + if (type === "macos" || type === "windows" || type === "linux") return type + return undefined + })(), version: pkg.version, async openDirectoryPickerDialog(opts) { @@ -292,19 +297,25 @@ root?.addEventListener("mousewheel", (e) => { e.stopPropagation() }) -// Handle external links - open in system browser instead of webview -document.addEventListener("click", (e) => { - const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null - if (link?.href) { - e.preventDefault() - platform.openLink(link.href) - } -}) - render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) + function handleClick(e: MouseEvent) { + const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null + if (link?.href) { + e.preventDefault() + platform.openLink(link.href) + } + } + + onMount(() => { + document.addEventListener("click", handleClick) + onCleanup(() => { + document.removeEventListener("click", handleClick) + }) + }) + return (