feat(app): new layout

This commit is contained in:
adamelmore
2026-01-12 08:38:29 -06:00
committed by Adam
parent 779610d668
commit 9f66a45970
5 changed files with 659 additions and 632 deletions

View File

@@ -1,4 +1,5 @@
import { createMemo, createResource, Show } from "solid-js" import { createMemo, createResource, Show } from "solid-js"
import { Portal } from "solid-js/web"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
@@ -57,16 +58,14 @@ export function SessionHeader() {
navigate(`/${params.dir}/session/${session.id}`) navigate(`/${params.dir}/session/${session.id}`)
} }
const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return ( return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex"> <>
<button <Show when={leftMount()}>
type="button" {(mount) => (
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" <Portal mount={mount()}>
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-3 min-w-0">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2"> <div class="hidden xl:flex items-center gap-2">
@@ -113,7 +112,6 @@ export function SessionHeader() {
label={(x) => x.title} label={(x) => x.title}
value={(x) => x.id} value={(x) => x.id}
onSelect={(session) => { onSelect={(session) => {
// Only navigate if selecting a different session than current parent
const currentParent = parentSession() const currentParent = parentSession()
if (session && currentParent && session.id !== currentParent.id) { if (session && currentParent && session.id !== currentParent.id) {
navigateToSession(session) navigateToSession(session)
@@ -123,7 +121,6 @@ export function SessionHeader() {
variant="ghost" variant="ghost"
/> />
<div class="text-text-weaker">/</div> <div class="text-text-weaker">/</div>
<div class="flex items-center gap-1.5 min-w-0">
<Tooltip value="Back to parent session"> <Tooltip value="Back to parent session">
<button <button
type="button" type="button"
@@ -134,7 +131,6 @@ export function SessionHeader() {
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
</div>
</Show> </Show>
</div> </div>
<Show when={currentSession() && !parentSession()}> <Show when={currentSession() && !parentSession()}>
@@ -143,6 +139,12 @@ export function SessionHeader() {
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
</Portal>
)}
</Show>
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1"> <div class="hidden md:flex items-center gap-1">
<Button <Button
@@ -203,7 +205,11 @@ export function SessionHeader() {
title="Toggle terminal" title="Toggle terminal"
keybind={command.keybind("terminal.toggle")} keybind={command.keybind("terminal.toggle")}
> >
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={() => view().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"> <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon <Icon
size="small" size="small"
@@ -261,7 +267,9 @@ export function SessionHeader() {
</Popover> </Popover>
</Show> </Show>
</div> </div>
</div> </Portal>
</header> )}
</Show>
</>
) )
} }

View File

@@ -124,12 +124,19 @@ function createGlobalSync() {
return globalSDK.client.session return globalSDK.client.session
.list({ directory, roots: true }) .list({ directory, roots: true })
.then((x) => { .then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id) .filter((s) => !!s?.id)
.filter((s) => !s.time?.archived) .filter((s) => !s.time?.archived)
.slice() .slice()
.sort((a, b) => a.id.localeCompare(b.id)) .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 // Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => { const sessions = nonArchived.filter((s, i) => {
if (i < limit) return true if (i < limit) return true

View File

@@ -5,6 +5,9 @@ export type Platform = {
/** Platform discriminator */ /** Platform discriminator */
platform: "web" | "desktop" platform: "web" | "desktop"
/** Desktop OS (Tauri only) */
os?: "macos" | "windows" | "linux"
/** App version */ /** App version */
version?: string version?: string

View File

@@ -13,7 +13,6 @@ import {
untrack, untrack,
type JSX, type JSX,
} from "solid-js" } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router" import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" 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 { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client" import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store" 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider" import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
@@ -64,16 +60,12 @@ import { useServer } from "@/context/server"
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string }, lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined, activeProject: undefined as string | undefined,
mobileProjectsExpanded: {} as Record<string, boolean>, 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 let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)") const xlQuery = window.matchMedia("(min-width: 1280px)")
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) 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)) 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) { function projectSessions(project: LocalProject | undefined) {
if (!project) return [] if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])] const dirs = [project.worktree, ...(project.sandboxes ?? [])]
@@ -325,7 +342,7 @@ export default function Layout(props: ParentProps) {
return created return created
} }
const prefetchMessages = (directory: string, sessionID: string, token: number) => { async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [, setStore] = globalSync.child(directory) const [, setStore] = globalSync.child(directory)
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
@@ -704,18 +721,17 @@ export default function Layout(props: ParentProps) {
const id = params.id const id = params.id
setStore("lastSession", directory, id) setStore("lastSession", directory, id)
notification.session.markViewed(id) notification.session.markViewed(id)
const project = currentProject() untrack(() => setStore("workspaceExpanded", directory, true))
untrack(() => layout.projects.expand(project?.worktree ?? directory))
requestAnimationFrame(() => scrollToSession(id)) requestAnimationFrame(() => scrollToSession(id))
}) })
createEffect(() => { createEffect(() => {
if (isLargeViewport()) { 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`) document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
} else { return
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
} }
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
}) })
function getDraggableId(event: unknown): string | undefined { function getDraggableId(event: unknown): string | undefined {
@@ -729,7 +745,7 @@ export default function Layout(props: ParentProps) {
function handleDragStart(event: unknown) { function handleDragStart(event: unknown) {
const id = getDraggableId(event) const id = getDraggableId(event)
if (!id) return if (!id) return
setStore("activeDraggable", id) setStore("activeProject", id)
} }
function handleDragOver(event: DragEvent) { function handleDragOver(event: DragEvent) {
@@ -745,44 +761,73 @@ export default function Layout(props: ParentProps) {
} }
function handleDragEnd() { function handleDragEnd() {
setStore("activeDraggable", undefined) setStore("activeProject", undefined)
} }
const ProjectAvatar = (props: { function workspaceIds(project: LocalProject | undefined) {
project: LocalProject if (!project) return []
class?: string const dirs = [project.worktree, ...(project.sandboxes ?? [])]
expandable?: boolean const existing = store.workspaceOrder[project.worktree]
notify?: boolean if (!existing) return dirs
}): JSX.Element => {
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 notification = useNotification()
const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) 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" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return ( return (
<div class="relative size-5 shrink-0 rounded-sm"> <div class={`relative size-10 shrink-0 ${props.class ?? ""}`}>
<Avatar <Avatar
fallback={name()} fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url} src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)} {...getAvatarColors(props.project.icon?.color)}
class={`size-full ${props.class ?? ""}`} class="size-full rounded-lg"
style={ style={
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined 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}> <Show when={notifications().length > 0 && props.notify}>
<div <div
classList={{ 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-icon-critical-base": hasError(),
"bg-text-interactive-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 SessionItem = (props: { session: Session; slug: string; mobile?: boolean }): 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 notification = useNotification() const notification = useNotification()
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id)) const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory) const [sessionStore] = globalSync.child(props.session.directory)
@@ -852,8 +858,8 @@ export default function Layout(props: ParentProps) {
const status = sessionStore.session_status[props.session.id] const status = sessionStore.session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry" return status?.type === "busy" || status?.type === "retry"
}) })
return ( return (
<>
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors class="group/session relative w-full rounded-md cursor-default transition-colors
@@ -862,11 +868,10 @@ export default function Layout(props: ParentProps) {
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A <A
href={`${props.slug}/session/${props.session.id}`} 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" 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")} onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => 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 <span
classList={{ classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true, "text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
@@ -875,44 +880,23 @@ export default function Layout(props: ParentProps) {
> >
{props.session.title} {props.session.title}
</span> </span>
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> <div class="shrink-0 flex items-center gap-2">
<Switch> <Switch>
<Match when={isWorking()}> <Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" /> <Spinner class="size-2.5" />
</Match> </Match>
<Match when={hasPermissions()}> <Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" /> <div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match> </Match>
<Match when={hasError()}> <Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match> </Match>
<Match when={notifications().length > 0}> <Match when={notifications().length > 0}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" /> <div class="size-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> </Match>
</Switch> </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> <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div> </div>
</Show>
</A> </A>
</Tooltip> </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"> <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">
@@ -925,132 +909,135 @@ export default function Layout(props: ParentProps) {
</TooltipKeybind> </TooltipKeybind>
</div> </div>
</div> </div>
</>
) )
} }
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree) 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 name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree) const selected = createMemo(() => {
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 current = params.dir ? base64Decode(params.dir) : "" const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current) 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 ( return (
// @ts-ignore // @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch> <Tooltip placement={props.mobile ? "bottom" : "right"} value={name()}>
<Match when={showExpanded()}>
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
<Button <Button
as={"div"}
variant="ghost" variant="ghost"
classList={{ size="large"
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true, class="flex items-center justify-center p-0 size-12 rounded-xl"
"bg-surface-raised-base-hover": isActive() && !isExpanded(), data-selected={selected()}
}} onClick={() => navigateToProject(props.project.worktree)}
> >
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none"> <ProjectIcon project={props.project} notify />
<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> </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> </Tooltip>
</div> </div>
)
}
const ProjectDragOverlay = (): JSX.Element => {
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
return (
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-xl p-1">
<ProjectIcon project={p()} />
</div> </div>
</div> )}
</Show> </Show>
<Show when={hasMoreSessions()}> )
}
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 (
<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>
</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"
>
New session
</Button>
<Show when={hasMore()}>
<div class="relative w-full py-1"> <div class="relative w-full py-1">
<Button <Button
variant="ghost" variant="ghost"
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5" class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
size="large" size="large"
onClick={loadMoreSessions} onClick={loadMore}
> >
Load more Load more
</Button> </Button>
@@ -1059,87 +1046,55 @@ export default function Layout(props: ParentProps) {
</nav> </nav>
</Collapsible.Content> </Collapsible.Content>
</Collapsible> </Collapsible>
</Match>
<Match when={true}>
<Tooltip placement="right" value={getFilename(props.project.worktree)}>
<ProjectVisual project={props.project} />
</Tooltip>
</Match>
</Switch>
</div> </div>
) )
} }
const ProjectDragOverlay = (): JSX.Element => {
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
return (
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-md">
<ProjectVisual project={p()} />
</div>
)}
</Show>
)
}
const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
const expanded = () => sidebarProps.mobile || layout.sidebar.opened() 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 ( return (
<div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0"> <div class="flex h-full w-full overflow-hidden">
<div class="flex flex-col items-start self-stretch gap-4 min-h-0"> <div class="w-16 shrink-0 bg-background-base border-r border-border-weak-base flex flex-col items-center overflow-hidden">
<Show when={!sidebarProps.mobile}> <div class="flex-1 min-h-0 w-full">
<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>
</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()}
>
<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>
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -1148,61 +1103,14 @@ export default function Layout(props: ParentProps) {
> >
<DragDropSensors /> <DragDropSensors />
<ConstrainDragXAxis /> <ConstrainDragXAxis />
<div <div class="h-full w-full flex flex-col items-center gap-2 px-2 py-3 overflow-y-auto no-scrollbar">
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"
>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}> <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}> <For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />} {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
</For> </For>
</SortableProvider> </SortableProvider>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</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()}>
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="plus"
onClick={connectProvider}
>
<Show when={expanded()}>Connect provider</Show>
</Button>
</Tooltip>
</Match>
</Switch>
<Tooltip <Tooltip
placement="right" placement={sidebarProps.mobile ? "bottom" : "right"}
value={ value={
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span>Open project</span> <span>Open project</span>
@@ -1211,19 +1119,80 @@ export default function Layout(props: ParentProps) {
</Show> </Show>
</div> </div>
} }
inactive={expanded()}
> >
<IconButton icon="plus" variant="ghost" class="size-12 rounded-xl" onClick={chooseProject} />
</Tooltip>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</div>
<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 <Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2" class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost" variant="ghost"
size="large" size="large"
icon="folder-add-left" icon="plus"
onClick={chooseProject} onClick={connectProvider}
> >
<Show when={expanded()}>Open project</Show> Connect provider
</Button> </Button>
</Tooltip>
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
<Button <Button
as={"a"} as={"a"}
href="https://opencode.ai/desktop-feedback" href="https://opencode.ai/desktop-feedback"
@@ -1233,40 +1202,78 @@ export default function Layout(props: ParentProps) {
size="large" size="large"
icon="bubble-5" icon="bubble-5"
> >
<Show when={expanded()}>Share feedback</Show> Share feedback
</Button> </Button>
</Tooltip>
</div> </div>
</Show>
</div>
</Show>
</div> </div>
) )
} }
const isMac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const reserveWindowButtons = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
return ( return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <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 class="flex-1 min-h-0 flex">
<div <div
classList={{ classList={{
"hidden xl:block": true, "hidden xl:block": true,
"relative shrink-0": 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 /> <SidebarContent />
</div> </div>
<Show when={layout.sidebar.opened()}> <Show when={layout.sidebar.opened()}>
<ResizeHandle <ResizeHandle
direction="horizontal" direction="horizontal"
size={layout.sidebar.width()} size={layout.sidebar.width()}
min={150} min={214}
max={window.innerWidth * 0.3} max={window.innerWidth * 0.3 + 64}
collapseThreshold={80} collapseThreshold={144}
onResize={layout.sidebar.resize} onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close} onCollapse={layout.sidebar.close}
/> />
@@ -1285,21 +1292,12 @@ export default function Layout(props: ParentProps) {
/> />
<div <div
classList={{ 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-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(), "-translate-x-full": !layout.mobileSidebar.opened(),
}} }}
onClick={(e) => e.stopPropagation()} 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 /> <SidebarContent mobile />
</div> </div>
</div> </div>

View File

@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store" import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo" 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 { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu" import { createMenu } from "./menu"
@@ -30,6 +30,11 @@ let update: Update | null = null
const createPlatform = (password: Accessor<string | null>): Platform => ({ const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop", platform: "desktop",
os: (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
return undefined
})(),
version: pkg.version, version: pkg.version,
async openDirectoryPickerDialog(opts) { async openDirectoryPickerDialog(opts) {
@@ -292,18 +297,24 @@ root?.addEventListener("mousewheel", (e) => {
e.stopPropagation() e.stopPropagation()
}) })
// Handle external links - open in system browser instead of webview render(() => {
document.addEventListener("click", (e) => { const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) { if (link?.href) {
e.preventDefault() e.preventDefault()
platform.openLink(link.href) platform.openLink(link.href)
} }
}) }
render(() => { onMount(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null) document.addEventListener("click", handleClick)
const platform = createPlatform(() => serverPassword()) onCleanup(() => {
document.removeEventListener("click", handleClick)
})
})
return ( return (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>