import { batch, createEffect, createMemo, createSignal, For, Match, on, onCleanup, onMount, ParentProps, Show, Switch, untrack, type Accessor, type JSX, } from "solid-js" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" 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" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter, createSortable, } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" 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 { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSettings } from "@/components/dialog-settings" 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" import { useLanguage, type Locale } from "@/context/language" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), createStore({ lastSession: {} as { [directory: string]: string }, activeProject: undefined as string | undefined, activeWorkspace: undefined as string | undefined, workspaceOrder: {} as Record, workspaceName: {} as Record, workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, }), ) const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined const xlQuery = window.matchMedia("(min-width: 1280px)") const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches) xlQuery.addEventListener("change", handleViewportChange) onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange)) const params = useParams() const [autoselect, setAutoselect] = createSignal(!params.dir) const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() const layoutReady = createMemo(() => layout.ready()) const platform = usePlatform() const settings = useSettings() const server = useServer() const notification = useNotification() const permission = usePermission() const navigate = useNavigate() const providers = useProviders() const dialog = useDialog() const command = useCommand() const theme = useTheme() const language = useLanguage() const initialDir = params.dir const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", light: "theme.scheme.light", dark: "theme.scheme.dark", } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const [editor, setEditor] = createStore({ active: "" as string, value: "", }) const [busyWorkspaces, setBusyWorkspaces] = createSignal>(new Set()) const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) setBusyWorkspaces((prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) } const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory)) const editorRef = { current: undefined as HTMLInputElement | undefined } const [hoverSession, setHoverSession] = createSignal() const autoselecting = createMemo(() => { if (params.dir) return false if (initialDir) return false if (!autoselect()) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() if (list.length === 0) return false return true }) const editorOpen = (id: string) => editor.active === id const editorValue = () => editor.value const openEditor = (id: string, value: string) => { if (!id) return setEditor({ active: id, value }) } const closeEditor = () => setEditor({ active: "", value: "" }) const saveEditor = (callback: (next: string) => void) => { const next = editor.value.trim() if (!next) { closeEditor() return } closeEditor() callback(next) } const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { if (event.key === "Enter") { event.preventDefault() saveEditor(callback) return } if (event.key === "Escape") { event.preventDefault() closeEditor() } } const InlineEditor = (props: { id: string value: Accessor onSave: (next: string) => void class?: string displayClass?: string editing?: boolean stopPropagation?: boolean openOnDblClick?: boolean }) => { const isEditing = () => props.editing ?? editorOpen(props.id) const stopEvents = () => props.stopPropagation ?? false const allowDblClick = () => props.openOnDblClick ?? true const stopPropagation = (event: Event) => { if (!stopEvents()) return event.stopPropagation() } const handleDblClick = (event: MouseEvent) => { if (!allowDblClick()) return stopPropagation(event) openEditor(props.id, props.value()) } return ( {props.value()} } > { editorRef.current = el requestAnimationFrame(() => el.focus()) }} value={editorValue()} class={props.class} onInput={(event) => setEditor("value", event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation() editorKeyDown(event, props.onSave) }} onBlur={() => closeEditor()} onPointerDown={stopPropagation} onClick={stopPropagation} onDblClick={stopPropagation} onMouseDown={stopPropagation} onMouseUp={stopPropagation} onTouchStart={stopPropagation} /> ) } function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) if (ids.length === 0) return const currentIndex = ids.indexOf(theme.themeId()) const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), description: nextTheme?.name ?? nextThemeId, }) } function cycleColorScheme(direction = 1) { const current = theme.colorScheme() const currentIndex = colorSchemeOrder.indexOf(current) const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length const next = colorSchemeOrder[nextIndex] theme.setColorScheme(next) showToast({ title: language.t("toast.scheme.title"), description: colorSchemeLabel(next), }) } function setLocale(next: Locale) { if (next === language.locale()) return language.setLocale(next) showToast({ title: language.t("toast.language.title"), description: language.t("toast.language.description", { language: language.label(next) }), }) } function cycleLanguage(direction = 1) { const locales = language.locales const currentIndex = locales.indexOf(language.locale()) const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length const next = locales[nextIndex] if (!next) return setLocale(next) } onMount(() => { if (!platform.checkUpdate || !platform.update || !platform.restart) return let toastId: number | undefined async function pollUpdate() { const { updateAvailable, version } = await platform.checkUpdate!() if (updateAvailable && toastId === undefined) { toastId = showToast({ persistent: true, icon: "download", title: language.t("toast.update.title"), description: language.t("toast.update.description", { version: version ?? "" }), actions: [ { label: language.t("toast.update.action.installRestart"), onClick: async () => { await platform.update!() await platform.restart!() }, }, { label: language.t("toast.update.action.notYet"), onClick: "dismiss", }, ], }) } } pollUpdate() const interval = setInterval(pollUpdate, 10 * 60 * 1000) onCleanup(() => clearInterval(interval)) }) onMount(() => { const toastBySession = new Map() const alertedAtBySession = new Map() const cooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return const title = e.details.type === "permission.asked" ? language.t("notification.permission.title") : language.t("notification.question.title") const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const) const directory = e.name const props = e.details.properties if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return const [store] = globalSync.child(directory) const session = store.session.find((s) => s.id === props.sessionID) const sessionKey = `${directory}:${props.sessionID}` const sessionTitle = session?.title ?? language.t("command.session.new") const projectName = getFilename(directory) const description = e.details.type === "permission.asked" ? language.t("notification.permission.description", { sessionTitle, projectName }) : language.t("notification.question.description", { sessionTitle, projectName }) const href = `/${base64Encode(directory)}/session/${props.sessionID}` const now = Date.now() const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0 if (now - lastAlerted < cooldownMs) return alertedAtBySession.set(sessionKey, now) if (e.details.type === "permission.asked") { playSound(soundSrc(settings.sounds.permissions())) if (settings.notifications.permissions()) { void platform.notify(title, description, href) } } if (e.details.type === "question.asked") { if (settings.notifications.agent()) { void platform.notify(title, description, href) } } const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id if (directory === currentDir && props.sessionID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) if (existingToastId !== undefined) toaster.dismiss(existingToastId) const toastId = showToast({ persistent: true, icon, title, description, actions: [ { label: language.t("notification.action.goToSession"), onClick: () => navigate(href), }, { label: language.t("common.dismiss"), onClick: "dismiss", }, ], }) toastBySession.set(sessionKey, toastId) }) onCleanup(unsub) createEffect(() => { const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id if (!currentDir || !currentSession) return const sessionKey = `${currentDir}:${currentSession}` const toastId = toastBySession.get(sessionKey) if (toastId !== undefined) { toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } const [store] = globalSync.child(currentDir) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { const childKey = `${currentDir}:${child.id}` const childToastId = toastBySession.get(childKey) if (childToastId !== undefined) { toaster.dismiss(childToastId) toastBySession.delete(childKey) alertedAtBySession.delete(childKey) } } }) }) function sortSessions(a: Session, b: Session) { const now = Date.now() const oneMinuteAgo = now - 60 * 1000 const aUpdated = a.time.updated ?? a.time.created const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo if (aRecent && bRecent) return a.id.localeCompare(b.id) if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated } const [scrollSessionKey, setScrollSessionKey] = createSignal(undefined) function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return if (scrollSessionKey() === sessionKey) return const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) if (!element) return const containerRect = scrollContainerRef.getBoundingClientRect() const elementRect = element.getBoundingClientRect() if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) { setScrollSessionKey(sessionKey) return } setScrollSessionKey(sessionKey) element.scrollIntoView({ block: "nearest", behavior: "smooth" }) } const currentProject = createMemo(() => { const directory = params.dir ? base64Decode(params.dir) : undefined if (!directory) return const projects = layout.projects.list() const sandbox = projects.find((p) => p.sandboxes?.includes(directory)) if (sandbox) return sandbox const direct = projects.find((p) => p.worktree === directory) if (direct) return direct const [child] = globalSync.child(directory) const id = child.project if (!id) return const meta = globalSync.data.project.find((p) => p.id === id) const root = meta?.worktree if (!root) return return projects.find((p) => p.worktree === root) }) createEffect( on( () => ({ ready: pageReady(), project: currentProject() }), (value) => { if (!value.ready) return const project = value.project if (!project) return const last = server.projects.last() if (last === project.worktree) return server.projects.touch(project.worktree) }, { defer: true }, ), ) createEffect( on( () => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }), (value) => { if (!value.ready) return if (!value.layoutReady) return if (!autoselect()) return if (initialDir) return if (value.dir) return if (value.list.length === 0) return const last = server.projects.last() const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return setAutoselect(false) openProject(next.worktree, false) navigateToProject(next.worktree) }, { defer: true }, ), ) const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] if (direct) return direct if (!projectId) return if (!branch) return return store.workspaceBranchName[projectId]?.[branch] } const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next })) if (!projectId) return if (!branch) return setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next })) } const workspaceLabel = (directory: string, branch?: string, projectId?: string) => workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) const isWorkspaceEditing = () => editor.active.startsWith("workspace:") const workspaceSetting = createMemo(() => { const project = currentProject() if (!project) return false return layout.sidebar.workspaces(project.worktree)() }) createEffect(() => { if (!pageReady()) return if (!layoutReady()) return 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) } }) createEffect(() => { if (!pageReady()) return if (!layoutReady()) return const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory)) if (!project) continue if (layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) } }) const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] if (workspaceSetting()) { const dirs = workspaceIds(project) const activeDir = params.dir ? base64Decode(params.dir) : "" const result: Session[] = [] for (const dir of dirs) { const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree const active = dir === activeDir if (!expanded && !active) continue const [dirStore] = globalSync.child(dir, { bootstrap: true }) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) result.push(...dirSessions) } return result } const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) }) type PrefetchQueue = { inflight: Set pending: string[] pendingSet: Set running: number } const prefetchChunk = 600 const prefetchConcurrency = 1 const prefetchPendingLimit = 6 const prefetchToken = { value: 0 } const prefetchQueues = new Map() createEffect(() => { params.dir globalSDK.url prefetchToken.value += 1 for (const q of prefetchQueues.values()) { q.pending.length = 0 q.pendingSet.clear() } }) const queueFor = (directory: string) => { const existing = prefetchQueues.get(directory) if (existing) return existing const created: PrefetchQueue = { inflight: new Set(), pending: [], pendingSet: new Set(), running: 0, } prefetchQueues.set(directory, created) return created } async function prefetchMessages(directory: string, sessionID: string, token: number) { const [, setStore] = globalSync.child(directory) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { if (prefetchToken.value !== token) return const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const next = items .map((x) => x.info) .filter((m) => !!m?.id) .slice() .sort((a, b) => a.id.localeCompare(b.id)) batch(() => { setStore("message", sessionID, reconcile(next, { key: "id" })) for (const message of items) { setStore( "part", message.info.id, reconcile( message.parts .filter((p) => !!p?.id) .slice() .sort((a, b) => a.id.localeCompare(b.id)), { key: "id" }, ), ) } }) }) .catch(() => undefined) } const pumpPrefetch = (directory: string) => { const q = queueFor(directory) if (q.running >= prefetchConcurrency) return const sessionID = q.pending.shift() if (!sessionID) return q.pendingSet.delete(sessionID) q.inflight.add(sessionID) q.running += 1 const token = prefetchToken.value void prefetchMessages(directory, sessionID, token).finally(() => { q.running -= 1 q.inflight.delete(sessionID) pumpPrefetch(directory) }) } const prefetchSession = (session: Session, priority: "high" | "low" = "low") => { const directory = session.directory if (!directory) return const [store] = globalSync.child(directory) if (store.message[session.id] !== undefined) return const q = queueFor(directory) if (q.inflight.has(session.id)) return if (q.pendingSet.has(session.id)) return if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) q.pendingSet.add(session.id) while (q.pending.length > prefetchPendingLimit) { const dropped = q.pending.pop() if (!dropped) continue q.pendingSet.delete(dropped) } pumpPrefetch(directory) } createEffect(() => { const sessions = currentSessions() const id = params.id if (!id) { const first = sessions[0] if (first) prefetchSession(first) const second = sessions[1] if (second) prefetchSession(second) return } const index = sessions.findIndex((s) => s.id === id) if (index === -1) return const next = sessions[index + 1] if (next) prefetchSession(next) const prev = sessions[index - 1] if (prev) prefetchSession(prev) }) function navigateSessionByOffset(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 let targetIndex: number if (sessionIndex === -1) { targetIndex = offset > 0 ? 0 : sessions.length - 1 } else { targetIndex = (sessionIndex + offset + sessions.length) % sessions.length } const session = sessions[targetIndex] if (!session) return const next = sessions[(targetIndex + 1) % sessions.length] const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] if (offset > 0) { if (next) prefetchSession(next, "high") if (prev) prefetchSession(prev) } if (offset < 0) { if (prev) prefetchSession(prev, "high") if (next) prefetchSession(next) } if (import.meta.env.DEV) { navStart({ dir: base64Encode(session.directory), from: params.id, to: session.id, trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup", }) } navigateToSession(session) queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) } async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] await globalSDK.client.session.update({ directory: session.directory, sessionID: session.id, time: { archived: Date.now() }, }) setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) if (match.found) draft.session.splice(match.index, 1) }), ) if (session.id === params.id) { if (nextSession) { navigate(`/${params.dir}/session/${nextSession.id}`) } else { navigate(`/${params.dir}/session`) } } } async function deleteSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived) const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] const result = await globalSDK.client.session .delete({ directory: session.directory, sessionID: session.id }) .then((x) => x.data) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(err), }) return false }) if (!result) return setStore( produce((draft) => { const removed = new Set([session.id]) const collect = (parentID: string) => { for (const item of draft.session) { if (item.parentID !== parentID) continue removed.add(item.id) collect(item.id) } } collect(session.id) draft.session = draft.session.filter((s) => !removed.has(s.id)) }), ) if (session.id === params.id) { if (nextSession) { navigate(`/${params.dir}/session/${nextSession.id}`) } else { navigate(`/${params.dir}/session`) } } } command.register(() => { const commands: CommandOption[] = [ { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, { id: "project.open", title: language.t("command.project.open"), category: language.t("command.category.project"), keybind: "mod+o", onSelect: () => chooseProject(), }, { id: "provider.connect", title: language.t("command.provider.connect"), category: language.t("command.category.provider"), onSelect: () => connectProvider(), }, { id: "server.switch", title: language.t("command.server.switch"), category: language.t("command.category.server"), onSelect: () => openServer(), }, { id: "settings.open", title: language.t("command.settings.open"), category: language.t("command.category.settings"), keybind: "mod+comma", onSelect: () => openSettings(), }, { id: "session.previous", title: language.t("command.session.previous"), category: language.t("command.category.session"), keybind: "alt+arrowup", onSelect: () => navigateSessionByOffset(-1), }, { id: "session.next", title: language.t("command.session.next"), category: language.t("command.category.session"), keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, { id: "session.archive", title: language.t("command.session.archive"), category: language.t("command.category.session"), keybind: "mod+shift+backspace", disabled: !params.dir || !params.id, onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) if (session) archiveSession(session) }, }, { id: "theme.cycle", title: language.t("command.theme.cycle"), category: language.t("command.category.theme"), keybind: "mod+shift+t", onSelect: () => cycleTheme(1), }, ] for (const [id, definition] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, title: language.t("command.theme.set", { theme: definition.name ?? id }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { theme.previewTheme(id) return () => theme.cancelPreview() }, }) } commands.push({ id: "theme.scheme.cycle", title: language.t("command.theme.scheme.cycle"), category: language.t("command.category.theme"), keybind: "mod+shift+s", onSelect: () => cycleColorScheme(1), }) for (const scheme of colorSchemeOrder) { commands.push({ id: `theme.scheme.${scheme}`, title: language.t("command.theme.scheme.set", { scheme: colorSchemeLabel(scheme) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { theme.previewColorScheme(scheme) return () => theme.cancelPreview() }, }) } commands.push({ id: "language.cycle", title: language.t("command.language.cycle"), category: language.t("command.category.language"), onSelect: () => cycleLanguage(1), }) for (const locale of language.locales) { commands.push({ id: `language.set.${locale}`, title: language.t("command.language.set", { language: language.label(locale) }), category: language.t("command.category.language"), onSelect: () => setLocale(locale), }) } return commands }) function connectProvider() { dialog.show(() => ) } function openServer() { dialog.show(() => ) } function openSettings() { dialog.show(() => ) } function navigateToProject(directory: string | undefined) { if (!directory) return server.projects.touch(directory) const lastSession = store.lastSession[directory] navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) layout.mobileSidebar.hide() } function navigateToSession(session: Session | undefined) { if (!session) return navigate(`/${base64Encode(session.directory)}/session/${session.id}`) layout.mobileSidebar.hide() } function openProject(directory: string, navigate = true) { layout.projects.open(directory) if (navigate) navigateToProject(directory) } const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) async function renameProject(project: LocalProject, next: string) { if (!project.id) return const current = displayName(project) if (next === current) return const name = next === getFilename(project.worktree) ? "" : next await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name }) } async function renameSession(session: Session, next: string) { if (next === session.title) return await globalSDK.client.session.update({ directory: session.directory, sessionID: session.id, title: next, }) } const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) if (current === next) return setWorkspaceName(directory, next, projectId, branch) } function closeProject(directory: string) { const index = layout.projects.list().findIndex((x) => x.worktree === directory) const next = layout.projects.list()[index + 1] layout.projects.close(directory) if (next) navigateToProject(next.worktree) else navigate("/") } async function chooseProject() { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { for (const directory of result) { openProject(directory, false) } navigateToProject(result[0]) } else if (result) { openProject(result) } } if (platform.openDirectoryPickerDialog && server.isLocal()) { const result = await platform.openDirectoryPickerDialog?.({ title: language.t("command.project.open"), multiple: true, }) resolve(result) } else { dialog.show( () => , () => resolve(null), ) } } 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 language.t("common.requestFailed") } const deleteWorkspace = async (directory: string) => { const current = currentProject() if (!current) return if (directory === current.worktree) return setBusy(directory, true) const result = await globalSDK.client.worktree .remove({ directory: current.worktree, worktreeRemoveInput: { directory } }) .then((x) => x.data) .catch((err) => { showToast({ title: language.t("workspace.delete.failed.title"), description: errorMessage(err), }) return false }) setBusy(directory, false) if (!result) return layout.projects.close(directory) layout.projects.open(current.worktree) if (params.dir && base64Decode(params.dir) === directory) { navigateToProject(current.worktree) } } const resetWorkspace = async (directory: string) => { const current = currentProject() if (!current) return if (directory === current.worktree) return setBusy(directory, true) const progress = showToast({ persistent: true, title: language.t("workspace.resetting.title"), description: language.t("workspace.resetting.description"), }) const dismiss = () => toaster.dismiss(progress) const sessions = await globalSDK.client.session .list({ directory }) .then((x) => x.data ?? []) .catch(() => []) const result = await globalSDK.client.worktree .reset({ directory: current.worktree, worktreeResetInput: { directory } }) .then((x) => x.data) .catch((err) => { showToast({ title: language.t("workspace.reset.failed.title"), description: errorMessage(err), }) return false }) if (!result) { setBusy(directory, false) dismiss() return } const archivedAt = Date.now() await Promise.all( sessions .filter((session) => session.time.archived === undefined) .map((session) => globalSDK.client.session .update({ sessionID: session.id, directory: session.directory, time: { archived: archivedAt }, }) .catch(() => undefined), ), ) await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) setBusy(directory, false) dismiss() showToast({ title: language.t("workspace.reset.success.title"), description: language.t("workspace.reset.success.description"), actions: [ { label: language.t("command.session.new"), onClick: () => { const href = `/${base64Encode(directory)}/session` navigate(href) layout.mobileSidebar.hide() }, }, { label: language.t("common.dismiss"), onClick: "dismiss", }, ], }) } function DialogDeleteSession(props: { session: Session }) { const handleDelete = async () => { await deleteSession(props.session) dialog.close() } return (
{language.t("session.delete.confirm", { name: props.session.title })}
) } function DialogDeleteWorkspace(props: { directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ status: "loading" as "loading" | "ready" | "error", dirty: false, }) onMount(() => { const current = currentProject() if (!current) { setData({ status: "error", dirty: false }) return } globalSDK.client.file .status({ directory: props.directory }) .then((x) => { const files = x.data ?? [] const dirty = files.length > 0 setData({ status: "ready", dirty }) }) .catch(() => { setData({ status: "error", dirty: false }) }) }) const handleDelete = async () => { await deleteWorkspace(props.directory) dialog.close() } const description = () => { if (data.status === "loading") return language.t("workspace.status.checking") if (data.status === "error") return language.t("workspace.status.error") if (!data.dirty) return language.t("workspace.status.clean") return language.t("workspace.status.dirty") } return (
{language.t("workspace.delete.confirm", { name: name() })} {description()}
) } function DialogResetWorkspace(props: { directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [state, setState] = createStore({ status: "loading" as "loading" | "ready" | "error", dirty: false, sessions: [] as Session[], }) const refresh = async () => { const sessions = await globalSDK.client.session .list({ directory: props.directory }) .then((x) => x.data ?? []) .catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) } onMount(() => { const current = currentProject() if (!current) { setState({ status: "error", dirty: false }) return } globalSDK.client.file .status({ directory: props.directory }) .then((x) => { const files = x.data ?? [] const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() }) .catch(() => { setState({ status: "error", dirty: false }) }) }) const handleReset = () => { dialog.close() void resetWorkspace(props.directory) } const archivedCount = () => state.sessions.length const description = () => { if (state.status === "loading") return language.t("workspace.status.checking") if (state.status === "error") return language.t("workspace.status.error") if (!state.dirty) return language.t("workspace.status.clean") return language.t("workspace.status.dirty") } const archivedLabel = () => { const count = archivedCount() if (count === 0) return language.t("workspace.reset.archived.none") if (count === 1) return language.t("workspace.reset.archived.one") return language.t("workspace.reset.archived.many", { count }) } return (
{language.t("workspace.reset.confirm", { name: name() })} {description()} {archivedLabel()} {language.t("workspace.reset.note")}
) } createEffect( on( () => ({ ready: pageReady(), dir: params.dir, id: params.id }), (value) => { if (!value.ready) return const dir = value.dir const id = value.id if (!dir || !id) return const directory = base64Decode(dir) setStore("lastSession", directory, id) notification.session.markViewed(id) const expanded = untrack(() => store.workspaceExpanded[directory]) if (expanded === false) { setStore("workspaceExpanded", directory, true) } requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`)) }, { defer: true }, ), ) createEffect(() => { const project = currentProject() if (!project) return if (workspaceSetting()) { const activeDir = params.dir ? base64Decode(params.dir) : "" const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const active = directory === activeDir if (!expanded && !active) continue globalSync.project.loadSessions(directory) } return } globalSync.project.loadSessions(project.worktree) }) function getDraggableId(event: unknown): string | undefined { if (typeof event !== "object" || event === null) return undefined if (!("draggable" in event)) return undefined const draggable = (event as { draggable?: { id?: unknown } }).draggable if (!draggable) return undefined return typeof draggable.id === "string" ? draggable.id : undefined } function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return setStore("activeProject", id) } function handleDragOver(event: DragEvent) { const { draggable, droppable } = event if (draggable && droppable) { const projects = layout.projects.list() const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== -1) { layout.projects.move(draggable.id.toString(), toIndex) } } } function handleDragEnd() { setStore("activeProject", undefined) } function workspaceIds(project: LocalProject | undefined) { if (!project) return [] const dirs = [project.worktree, ...(project.sandboxes ?? [])] const active = currentProject() const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs const existing = store.workspaceOrder[project.worktree] if (!existing) return next const keep = existing.filter((d) => next.includes(d)) const missing = next.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% - 4px) 4px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return (
0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined } />
0 && props.notify}>
) } const SessionItem = (props: { session: Session slug: string mobile?: boolean dense?: boolean popover?: boolean }): JSX.Element => { const notification = useNotification() 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) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) for (const child of childSessions) { const childPermissions = sessionStore.permission?.[child.id] ?? [] if (childPermissions.length > 0) return true } return false }) const isWorking = createMemo(() => { if (hasPermissions()) return false const status = sessionStore.session_status[props.session.id] 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 }) const hoverMessages = createMemo(() => sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), ) const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) const [menuOpen, setMenuOpen] = createSignal(false) const [pendingRename, setPendingRename] = createSignal(false) const messageLabel = (message: Message) => { const parts = sessionStore.part[message.id] ?? [] const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) return text?.text } const item = ( prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} onClick={() => setHoverSession(undefined)} >
}>
0}>
props.session.title} onSave={(next) => renameSession(props.session, next)} class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" stopPropagation /> {(summary) => (
)}
) return (
{item} } > setHoverSession(open ? props.session.id : undefined)} > {language.t("session.messages.loading")}
} >
{ if (!isActive()) { sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) navigate(`${props.slug}/session/${props.session.id}`) return } window.history.replaceState(null, "", `#message-${message.id}`) window.dispatchEvent(new HashChangeEvent("hashchange")) }} size="normal" class="w-60" />
{ if (!pendingRename()) return event.preventDefault() setPendingRename(false) openEditor(`session:${props.session.id}`, props.session.title) }} > { setPendingRename(true) setMenuOpen(false) }} > {language.t("common.rename")} archiveSession(props.session)}> {language.t("common.archive")} dialog.show(() => )}> {language.t("common.delete")}
) } const SessionSkeleton = (props: { count?: number }): JSX.Element => { const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) return (
{() =>
}
) } const ProjectDragOverlay = (): JSX.Element => { 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 ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) 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, { bootstrap: false }) const [menuOpen, setMenuOpen] = createSignal(false) const [pendingRename, setPendingRename] = createSignal(false) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" return current === props.directory }) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) return workspaceName(props.directory, props.project.id, branch) ?? name }) const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local()) const boot = createMemo(() => open() || active()) const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) const busy = createMemo(() => isBusy(props.directory)) const loadMore = async () => { if (!local()) return setWorkspaceStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.directory) } const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`)) const openWrapper = (value: boolean) => { setStore("workspaceExpanded", props.directory, value) if (value) return if (editorOpen(`workspace:${props.directory}`)) closeEditor() } createEffect(() => { if (!boot()) return globalSync.child(props.directory, { bootstrap: true }) }) const header = () => (
}>
{local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : {workspaceStore.vcs?.branch ?? getFilename(props.directory)} } > { const trimmed = next.trim() if (!trimmed) return renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) setEditor("value", workspaceValue()) }} class="text-14-medium text-text-base min-w-0 truncate" displayClass="text-14-medium text-text-base min-w-0 truncate" editing={workspaceEditActive()} stopPropagation={false} openOnDblClick={false} />
) return (
{header()} } >
{header()}
{ if (!pendingRename()) return event.preventDefault() setPendingRename(false) openEditor(`workspace:${props.directory}`, workspaceValue()) }} > navigate(`/${slug()}/session`)}> {language.t("command.session.new")} { setPendingRename(true) setMenuOpen(false) }} > {language.t("common.rename")} dialog.show(() => )} > {language.t("common.reset")} dialog.show(() => )} > {language.t("common.delete")} navigate(`/${slug()}/session`)} aria-label={language.t("command.session.new")} />
) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(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 workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) const [open, setOpen] = createSignal(false) const label = (directory: string) => { const [data] = globalSync.child(directory) const kind = directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") const name = workspaceLabel(directory, data.vcs?.branch, props.project.id) 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 && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } const projectSessions = () => { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } const projectName = () => props.project.name || getFilename(props.project.worktree) const trigger = ( ) return ( // @ts-ignore
{ setOpen(value) if (value) setHoverSession(undefined) }} >
{displayName(props.project)}
{ event.stopPropagation() setOpen(false) closeProject(props.project.worktree) }} />
{language.t("sidebar.project.recentSessions")}
{(session) => ( )} } > {(directory) => (
{label(directory)}
{(session) => ( )}
)}
) } 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 && !session.time?.archived) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length) const loadMore = async () => { setWorkspaceStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.project.worktree) } return (
{ if (!props.mobile) scrollContainerRef = el }} class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar" style={{ "overflow-anchor": "none" }} >
) } 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() if (!current) return "" return current.name || getFilename(current.worktree) }) const projectId = createMemo(() => project()?.id ?? "") const workspaces = createMemo(() => workspaceIds(project())) 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: language.t("workspace.create.failed.title"), description: errorMessage(err), }) return undefined }) if (!created?.directory) return globalSync.child(created.directory) navigate(`/${base64Encode(created.directory)}/session`) } command.register(() => [ { id: "workspace.new", title: language.t("workspace.new"), category: language.t("command.category.workspace"), keybind: "mod+shift+w", disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(), onSelect: createWorkspace, }, ]) const homedir = createMemo(() => sync.data.path.home) return (
p.worktree)}> {(project) => } {language.t("command.project.open")} {command.keybind("project.open")}
} >
platform.openLink("https://opencode.ai/desktop-feedback")} aria-label={language.t("sidebar.help")} />
{(p) => ( <>
project() && renameProject(project()!, next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation /> {project()?.worktree.replace(homedir(), "~")}
dialog.show(() => )}> {language.t("common.edit")} layout.sidebar.toggleWorkspaces(p.worktree)}> {layout.sidebar.workspaces(p.worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} closeProject(p.worktree)}> {language.t("common.close")}
} > <>
{ if (!sidebarProps.mobile) scrollContainerRef = el }} class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar" style={{ "overflow-anchor": "none" }} > {(directory) => ( )}
)}
0 && providers.paid().length === 0}>
{language.t("sidebar.gettingStarted.title")}
{language.t("sidebar.gettingStarted.line1")}
{language.t("sidebar.gettingStarted.line2")}
) } return (
{ if (e.target === e.currentTarget) layout.mobileSidebar.hide() }} />
}> {props.children}
) }