diff options
| author | Adam <[email protected]> | 2026-01-12 10:11:29 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-15 07:29:13 -0600 |
| commit | 679270d9e0731c2b3e2c059d83907cb4086d90e2 (patch) | |
| tree | 4e8d70a281b92f86a4b916ed45a5410ecbba0289 /packages/app/src | |
| parent | 9f66a45970d1edf12ae9b3e7a22d77711b5e51c3 (diff) | |
| download | opencode-679270d9e0731c2b3e2c059d83907cb4086d90e2.tar.gz opencode-679270d9e0731c2b3e2c059d83907cb4086d90e2.zip | |
feat(app): new layout
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 101 | ||||
| -rw-r--r-- | packages/app/src/components/titlebar.tsx | 115 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/context/sync.tsx | 2 | ||||
| -rw-r--r-- | packages/app/src/index.css | 4 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 523 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 19 |
7 files changed, 522 insertions, 250 deletions
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b2e7fafeb..5ed721740 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -34,6 +34,17 @@ export function SessionHeader() { const sync = useSync() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const name = createMemo(() => { + const current = project() + if (current) return current.name || getFilename(current.worktree) + return getFilename(projectDirectory()) + }) + const hotkey = createMemo(() => command.keybind("file.open")) const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID)) const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id)) @@ -58,87 +69,29 @@ export function SessionHeader() { navigate(`/${params.dir}/session/${session.id}`) } - const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left")) + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) return ( <> - <Show when={leftMount()}> + <Show when={centerMount()}> {(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={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={ - <> - <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 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> + <button + type="button" + class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active" + onClick={() => command.trigger("file.open")} + > + <Icon name="magnifying-glass" size="small" class="text-text-weak" /> + <span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span> + <Show when={hotkey()}> + {(keybind) => ( + <span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak"> + {keybind()} + </span> + )} </Show> - </div> + </button> </Portal> )} </Show> diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx new file mode 100644 index 000000000..5cf9f74bc --- /dev/null +++ b/packages/app/src/components/titlebar.tsx @@ -0,0 +1,115 @@ +import { createEffect, createMemo, Show } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useTheme } from "@opencode-ai/ui/theme" + +import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useCommand } from "@/context/command" + +export function Titlebar() { + const layout = useLayout() + const platform = usePlatform() + const command = useCommand() + const theme = useTheme() + + const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") + const reserve = createMemo( + () => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"), + ) + + const getWin = () => { + if (platform.platform !== "desktop") return + + const tauri = ( + window as unknown as { + __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } } + } + ).__TAURI__ + if (!tauri?.window?.getCurrentWindow) return + + return tauri.window.getCurrentWindow() + } + + createEffect(() => { + if (platform.platform !== "desktop") return + + const scheme = theme.colorScheme() + const value = scheme === "system" ? null : scheme + + const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } }) + .__TAURI__ + const get = tauri?.webviewWindow?.getCurrentWebviewWindow + if (!get) return + + const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> } + if (!win.setTheme) return + + void win.setTheme(value).catch(() => undefined) + }) + + const interactive = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + + const selector = + "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" + + return !!target.closest(selector) + } + + const drag = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (e.buttons !== 1) return + if (interactive(e.target)) return + + const win = getWin() + if (!win?.startDragging) return + + e.preventDefault() + void win.startDragging().catch(() => undefined) + } + + return ( + <header class="h-10 shrink-0 bg-background-base flex items-center relative"> + <div + classList={{ + "flex items-center w-full min-w-0 pr-2": true, + "pl-2": !mac(), + }} + onMouseDown={drag} + > + <Show when={mac()}> + <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={reserve()}> + <div class="w-[120px] h-full shrink-0" data-tauri-drag-region /> + </Show> + </div> + <div class="absolute inset-0 flex items-center justify-center pointer-events-none"> + <div id="opencode-titlebar-center" class="pointer-events-auto" /> + </div> + </header> + ) +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 385f564fa..ba332be7b 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -53,6 +53,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( sidebar: { opened: false, width: 280, + workspaces: false, }, terminal: { height: 280, @@ -304,6 +305,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(width: number) { setStore("sidebar", "width", width) }, + workspaces: createMemo(() => store.sidebar.workspaces ?? false), + setWorkspaces(value: boolean) { + setStore("sidebar", "workspaces", value) + }, + toggleWorkspaces() { + setStore("sidebar", "workspaces", (x) => !x) + }, }, terminal: { height: createMemo(() => store.terminal.height), diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index e5f2c076e..33129e1b4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const [store, setStore] = globalSync.child(sdk.directory) const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - const chunk = 200 + const chunk = 400 const inflight = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>() diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b..d9d51aa8f 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,7 @@ cursor: default; } } + +*[data-tauri-drag-region] { + app-region: drag; +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 535f4aef2..cc5396656 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -23,6 +23,8 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" @@ -55,6 +57,8 @@ import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogEditProject } from "@/components/dialog-edit-project" +import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" export default function Layout(props: ParentProps) { @@ -814,20 +818,24 @@ export default function Layout(props: ParentProps) { const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( - <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 rounded-lg" - style={ - notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined - } - /> + <div class={`relative size-8 shrink-0 rounded-sm ${props.class ?? ""}`}> + <div class="size-full rounded-sm overflow-clip"> + <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 rounded-sm" + style={ + notifications().length > 0 && props.notify + ? { "-webkit-mask-image": mask, "mask-image": mask } + : undefined + } + /> + </div> <Show when={notifications().length > 0 && props.notify}> <div classList={{ - "absolute -top-0.5 -right-0.5 size-2 rounded-full": true, + "absolute -top-px -right-px size-2 rounded-full z-10": true, "bg-icon-critical-base": hasError(), "bg-text-interactive-base": !hasError(), }} @@ -837,7 +845,7 @@ export default function Layout(props: ParentProps) { ) } - const SessionItem = (props: { session: Session; slug: string; mobile?: boolean }): JSX.Element => { + const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) @@ -859,47 +867,62 @@ export default function Layout(props: ParentProps) { return status?.type === "busy" || status?.type === "retry" }) + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + const user = messages + .slice() + .reverse() + .find((m) => m.role === "user") + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agent?.color + }) + return ( <div 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 px-3 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" + class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} 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(), - }} - > - {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> + <div class="flex items-center gap-1 w-full"> + <div + class="shrink-0 size-6 flex items-center justify-center" + style={{ color: tint() ?? "var(--icon-interactive-base)" }} + > + <Switch> + <Match when={isWorking()}> + <Spinner class="size-[15px]" /> + </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> + </div> + <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> + {props.session.title} + </span> <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"> + <div + class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`} + > <TooltipKeybind placement={props.mobile ? "bottom" : "right"} title="Archive session" @@ -914,26 +937,81 @@ export default function Layout(props: ParentProps) { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const selected = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) + const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) + const label = (directory: string) => { + const [data] = globalSync.child(directory) + const kind = directory === props.project.worktree ? "local" : "sandbox" + const name = data.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name}` + } + + const sessions = (directory: string) => { + const [data] = globalSync.child(directory) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + .slice(0, 2) + } + + const trigger = ( + <button + type="button" + classList={{ + "flex items-center justify-center size-10 p-1 rounded-md border transition-colors cursor-default": true, + "bg-surface-base-hover border-icon-strong-base": selected(), + "bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(), + }} + onClick={() => navigateToProject(props.project.worktree)} + > + <ProjectIcon project={props.project} notify /> + </button> + ) + return ( // @ts-ignore <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> - <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> + <HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={10} trigger={trigger}> + <div class="-m-3 flex flex-col w-72"> + <div class="px-3 py-2 text-12-medium text-text-strong">Recent sessions</div> + <div class="px-2 pb-2 flex flex-col gap-2"> + <For each={workspaces()}> + {(directory) => ( + <div class="flex flex-col gap-1"> + <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> + <div class="shrink-0 size-6 flex items-center justify-center"> + <Icon name="branch" size="small" class="text-icon-base" /> + </div> + <span class="truncate text-14-medium text-text-strong">{label(directory)}</span> + </div> + <For each={sessions(directory)}> + {(session) => ( + <SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} /> + )} + </For> + </div> + )} + </For> + </div> + <div class="px-2 py-2 border-t border-border-weak-base"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-text-base px-2" + onClick={() => { + layout.sidebar.open() + navigateToProject(props.project.worktree) + }} + > + View all sessions + </Button> + </div> + </div> + </HoverCard> </div> ) } @@ -967,7 +1045,7 @@ export default function Layout(props: ParentProps) { return ( <Show when={label()}> {(value) => ( - <div class="bg-background-base rounded-md px-2 py-1 text-12-medium text-text-strong">{value()}</div> + <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div> )} </Show> ) @@ -1003,39 +1081,59 @@ export default function Layout(props: ParentProps) { <Collapsible variant="ghost" open={open()} - class="gap-1.5 shrink-0" + class="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 class="px-2 py-1"> + <div class="group/trigger relative"> + <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"> + <div class="flex items-center gap-1 min-w-0"> + <div class="flex items-center justify-center shrink-0 size-6"> + <Icon name="branch" size="small" /> + </div> + <span class="truncate text-14-medium text-text-strong">{title()}</span> + <Icon + name={open() ? "chevron-down" : "chevron-right"} + size="small" + class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100" + /> + </div> + </Collapsible.Trigger> + <div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex"> + <Tooltip class="pointer-events-auto" value="More options" placement="top"> + <IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md" /> + </Tooltip> + <Tooltip class="pointer-events-auto" value="New session" placement="top"> + <IconButton + icon="plus-small" + variant="ghost" + class="size-6 rounded-md" + onClick={() => navigate(`/${slug()}/session`)} + /> + </Tooltip> + </div> </div> - </Collapsible.Trigger> + </div> <Collapsible.Content> - <nav class="flex flex-col gap-1 pl-2"> - <For each={sessions()}> - {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />} - </For> + <nav class="flex flex-col gap-1 px-2"> <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" + icon="edit" + class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3" > New session </Button> + <For each={sessions()}> + {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />} + </For> <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" + class="flex w-full text-left justify-start text-12-medium text-text-weak px-10" size="large" onClick={loadMore} > @@ -1050,9 +1148,53 @@ export default function Layout(props: ParentProps) { ) } + const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions), + ) + const hasMore = createMemo(() => workspaceStore.session.length >= workspaceStore.limit) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + + return ( + <div + ref={(el) => { + if (!props.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar" + > + <nav class="flex flex-col gap-1 px-2"> + <For each={sessions()}> + {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />} + </For> + <Show when={hasMore()}> + <div class="relative w-full py-1"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-12-medium text-text-weak px-10" + size="large" + onClick={loadMore} + > + Load more + </Button> + </div> + </Show> + </nav> + </div> + ) + } + const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + const sync = useGlobalSync() const project = createMemo(() => currentProject()) const projectName = createMemo(() => { const current = project() @@ -1091,9 +1233,11 @@ export default function Layout(props: ParentProps) { navigate(`/${base64Encode(created.directory)}/session`) } + const homedir = createMemo(() => sync.data.path.home) + 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="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"> <div class="flex-1 min-h-0 w-full"> <DragDropProvider onDragStart={handleDragStart} @@ -1103,7 +1247,7 @@ export default function Layout(props: ParentProps) { > <DragDropSensors /> <ConstrainDragXAxis /> - <div class="h-full w-full flex flex-col items-center gap-2 px-2 py-3 overflow-y-auto no-scrollbar"> + <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 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} />} @@ -1120,7 +1264,7 @@ export default function Layout(props: ParentProps) { </div> } > - <IconButton icon="plus" variant="ghost" class="size-12 rounded-xl" onClick={chooseProject} /> + <IconButton icon="plus" variant="ghost" size="large" onClick={chooseProject} /> </Tooltip> </div> <DragOverlay> @@ -1128,12 +1272,25 @@ export default function Layout(props: ParentProps) { </DragOverlay> </DragDropProvider> </div> + <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> + <Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings"> + <IconButton icon="settings-gear" variant="ghost" size="large" onClick={command.show} /> + </Tooltip> + <Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help"> + <IconButton + icon="help" + variant="ghost" + size="large" + onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")} + /> + </Tooltip> + </div> </div> <Show when={expanded()}> <div classList={{ - "flex flex-col min-h-0 bg-background-base border-r border-border-weak-base": true, + "flex flex-col min-h-0 bg-background-stronger border border-border-weak-base rounded-tl-sm": true, "flex-1 min-w-0": sidebarProps.mobile, }} style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} @@ -1141,69 +1298,125 @@ export default function Layout(props: ParentProps) { <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 class="shrink-0 px-2 py-1"> + <div class="flex items-start justify-between gap-2 p-2"> + <div class="flex flex-col min-w-0"> + <span class="text-16-medium text-text-strong truncate">{projectName()}</span> + <Tooltip placement="right" value={project()?.worktree} class="shrink-0"> + <span class="text-12-regular text-text-base truncate"> + {project()?.worktree.replace(homedir(), "~")} + </span> + </Tooltip> + </div> + <DropdownMenu> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="shrink-0 size-6 rounded-md" + /> + <DropdownMenu.Portal> + <DropdownMenu.Content> + <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}> + <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item onSelect={() => closeProject(p().worktree)}> + <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Separator /> + <DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces()}> + <DropdownMenu.ItemLabel> + {layout.sidebar.workspaces() ? "Disable workspaces" : "Enable workspaces"} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> </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> + <Show + when={layout.sidebar.workspaces()} + fallback={ + <> + <div class="py-4 px-3"> + <Button + size="large" + icon="plus-small" + class="w-full" + onClick={() => { + navigate(`/${base64Encode(p().worktree)}/session`) + layout.mobileSidebar.hide() + }} + > + New session + </Button> + </div> + <div class="flex-1 min-h-0"> + <LocalWorkspace project={p()} mobile={sidebarProps.mobile} /> + </div> + </> + } + > + <> + <div class="py-4 px-3"> + <Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}> + New workspace + </Button> </div> - <DragOverlay> - <WorkspaceDragOverlay /> - </DragOverlay> - </DragDropProvider> - </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="size-full flex flex-col py-2 gap-4 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> <Show when={!project()}> - <div class="p-3 text-12-regular text-text-weak">Open a project to see workspaces.</div> + <div class="p-3 text-12-regular text-text-weak">Open a project to see sessions.</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" - size="large" - icon="plus" - onClick={connectProvider} - > - 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> + <Show when={providers.all().length > 0 && providers.paid().length === 0}> + <div class="shrink-0 px-2 py-3 border-t border-border-weak-base"> + <div class="rounded-md bg-background-base 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> + <Button + class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3" + size="large" + icon="plus" + onClick={connectProvider} + > + Connect provider + </Button> + </div> </div> </Show> </div> @@ -1212,50 +1425,9 @@ export default function Layout(props: ParentProps) { ) } - 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="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> + <Titlebar /> <div class="flex-1 min-h-0 flex"> <div classList={{ @@ -1282,7 +1454,7 @@ export default function Layout(props: ParentProps) { <div class="xl:hidden"> <div classList={{ - "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true, + "fixed inset-0 z-40 transition-opacity duration-200": true, "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(), "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(), }} @@ -1302,7 +1474,14 @@ export default function Layout(props: ParentProps) { </div> </div> - <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main> + <main + classList={{ + "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true, + "border-l rounded-tl-sm": !layout.sidebar.opened(), + }} + > + {props.children} + </main> </div> <Toast.Region /> </div> diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 69065a8fa..143150b92 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -885,6 +885,19 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = scroller + if (!root) { + el.scrollIntoView({ behavior, block: "start" }) + return + } + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) + } + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { setActiveMessage(message) @@ -896,7 +909,7 @@ export default function Page() { requestAnimationFrame(() => { const el = document.getElementById(anchor(message.id)) - if (el) el.scrollIntoView({ behavior, block: "start" }) + if (el) scrollToElement(el, behavior) }) updateHash(message.id) @@ -904,7 +917,7 @@ export default function Page() { } const el = document.getElementById(anchor(message.id)) - if (el) el.scrollIntoView({ behavior, block: "start" }) + if (el) scrollToElement(el, behavior) updateHash(message.id) } @@ -956,7 +969,7 @@ export default function Page() { const hashTarget = document.getElementById(hash) if (hashTarget) { - hashTarget.scrollIntoView({ behavior: "auto", block: "start" }) + scrollToElement(hashTarget, "auto") return } |
