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 { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" 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 { ContextMenu } from "@opencode-ai/ui/context-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 { Worktree as WorktreeState } from "@/utils/worktree" import { agentColor } from "@/utils/agent" 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 params = useParams() 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 initialDirectory = decode64(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 [state, setState] = createStore({ autoselect: !initialDirectory, busyWorkspaces: new Set(), hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, nav: undefined as HTMLElement | undefined, }) const [editor, setEditor] = createStore({ active: "" as string, value: "", }) const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) setState("busyWorkspaces", (prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) } const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) const editorRef = { current: undefined as HTMLInputElement | undefined } const navLeave = { current: undefined as number | undefined } onCleanup(() => { if (navLeave.current === undefined) return clearTimeout(navLeave.current) }) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const hoverProjectData = createMemo(() => { const id = state.hoverProject if (!id) return return layout.projects.list().find((project) => project.worktree === id) }) createEffect(() => { if (!layout.sidebar.opened()) return setState("hoverProject", undefined) }) createEffect( on( () => ({ dir: params.dir, id: params.id }), () => { if (layout.sidebar.opened()) return if (!state.hoverProject) return setState("hoverSession", undefined) setState("hoverProject", undefined) }, { defer: true }, ), ) const autoselecting = createMemo(() => { if (params.dir) return false if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() if (list.length > 0) return true return !!server.projects.last() }) createEffect(() => { if (!state.autoselect) return const dir = params.dir if (!dir) return const directory = decode64(dir) if (!directory) return setState("autoselect", false) }) 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 let interval: ReturnType | 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", }, ], }) } } createEffect(() => { if (!settings.ready()) return if (!settings.updates.startup()) { if (interval === undefined) return clearInterval(interval) interval = undefined return } if (interval !== undefined) return void pollUpdate() interval = setInterval(pollUpdate, 10 * 60 * 1000) }) onCleanup(() => { if (interval === undefined) return clearInterval(interval) }) }) onMount(() => { const toastBySession = new Map() const alertedAtBySession = new Map() const cooldownMs = 5000 const unsub = globalSDK.event.listen((e) => { if (e.details?.type === "worktree.ready") { setBusy(e.name, false) WorktreeState.ready(e.name) return } if (e.details?.type === "worktree.failed") { setBusy(e.name, false) WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed")) return } 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, { bootstrap: false }) 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 = decode64(params.dir) 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 = decode64(params.dir) 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, { bootstrap: false }) 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(now: number) { const oneMinuteAgo = now - 60 * 1000 return (a: Session, b: Session) => { 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 < b.id ? -1 : a.id > b.id ? 1 : 0 if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated } } function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return if (state.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) { setState("scrollSessionKey", sessionKey) return } setState("scrollSessionKey", sessionKey) element.scrollIntoView({ block: "nearest", behavior: "smooth" }) } const currentProject = createMemo(() => { const directory = decode64(params.dir) 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, { bootstrap: false }) 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 (!state.autoselect) return if (value.dir) return const last = server.projects.last() if (value.list.length === 0) { if (!last) return setState("autoselect", false) openProject(last, false) navigateToProject(last) return } const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return setState("autoselect", false) openProject(next.worktree, false) navigateToProject(next.worktree) }, ), ) 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 workspaceSetting = createMemo(() => { const project = currentProject() if (!project) return false if (project.vcs !== "git") return false return layout.sidebar.workspaces(project.worktree)() }) createEffect(() => { if (!pageReady()) return if (!layoutReady()) return const project = currentProject() if (!project) return const local = project.worktree 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) => d !== local && dirs.includes(d)) const missing = dirs.filter((d) => d !== local && !existing.includes(d)) const merged = [local, ...missing, ...keep] 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 (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue setStore("workspaceExpanded", directory, false) } }) const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] const compare = sortSessions(Date.now()) if (workspaceSetting()) { const dirs = workspaceIds(project) const activeDir = decode64(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(compare) 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(compare) }) type PrefetchQueue = { inflight: Set pending: string[] pendingSet: Set running: number } const prefetchChunk = 200 const prefetchConcurrency = 1 const prefetchPendingLimit = 6 const prefetchToken = { value: 0 } const prefetchQueues = new Map() const PREFETCH_MAX_SESSIONS_PER_DIR = 10 const prefetchedByDir = new Map>() const lruFor = (directory: string) => { const existing = prefetchedByDir.get(directory) if (existing) return existing const created = new Map() prefetchedByDir.set(directory, created) return created } const markPrefetched = (directory: string, sessionID: string) => { const lru = lruFor(directory) if (lru.has(sessionID)) lru.delete(sessionID) lru.set(sessionID, true) while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) { const oldest = lru.keys().next().value as string | undefined if (!oldest) return lru.delete(oldest) } } 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 [store, setStore] = globalSync.child(directory, { bootstrap: false }) 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 < b.id ? -1 : a.id > b.id ? 1 : 0)) const current = store.message[sessionID] ?? [] const merged = (() => { if (current.length === 0) return next const map = new Map() for (const item of current) { if (!item?.id) continue map.set(item.id, item) } for (const item of next) { map.set(item.id, item) } return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) })() batch(() => { setStore("message", sessionID, reconcile(merged, { key: "id" })) for (const message of items) { const currentParts = store.part[message.info.id] ?? [] const mergedParts = (() => { if (currentParts.length === 0) { return message.parts .filter((p) => !!p?.id) .slice() .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) } const map = new Map() for (const item of currentParts) { if (!item?.id) continue map.set(item.id, item) } for (const item of message.parts) { if (!item?.id) continue map.set(item.id, item) } return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) })() setStore("part", message.info.id, reconcile(mergedParts, { 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, { bootstrap: false }) const cached = untrack(() => store.message[session.id] !== undefined) if (cached) return const q = queueFor(directory) if (q.inflight.has(session.id)) return if (q.pendingSet.has(session.id)) return const lru = lruFor(directory) const known = lru.has(session.id) if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return markPrefetched(directory, session.id) 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}`)) } function navigateSessionByUnseen(offset: number) { const sessions = currentSessions() if (sessions.length === 0) return const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) if (!hasUnseen) return const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex for (let i = 1; i <= sessions.length; i++) { const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length const session = sessions[index] if (!session) continue if (notification.session.unseen(session.id).length === 0) continue prefetchSession(session, "high") const next = sessions[(index + 1) % sessions.length] const prev = sessions[(index - 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 ? "shift+alt+arrowdown" : "shift+alt+arrowup", }) } navigateToSession(session) queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) return } } 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 byParent = new Map() for (const item of draft.session) { const parentID = item.parentID if (!parentID) continue const existing = byParent.get(parentID) if (existing) { existing.push(item.id) continue } byParent.set(parentID, [item.id]) } const stack = [session.id] while (stack.length) { const parentID = stack.pop() if (!parentID) continue const children = byParent.get(parentID) if (!children) continue for (const child of children) { if (removed.has(child)) continue removed.add(child) stack.push(child) } } 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.previous.unseen", title: language.t("command.session.previous.unseen"), category: language.t("command.category.session"), keybind: "shift+alt+arrowup", onSelect: () => navigateSessionByUnseen(-1), }, { id: "session.next.unseen", title: language.t("command.session.next.unseen"), category: language.t("command.category.session"), keybind: "shift+alt+arrowdown", onSelect: () => navigateSessionByUnseen(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: "workspace.toggle", title: language.t("command.workspace.toggle"), description: language.t("command.workspace.toggle.description"), category: language.t("command.category.workspace"), slash: "workspace", disabled: !currentProject() || currentProject()?.vcs !== "git", onSelect: () => { const project = currentProject() if (!project) return if (project.vcs !== "git") return const wasEnabled = layout.sidebar.workspaces(project.worktree)() layout.sidebar.toggleWorkspaces(project.worktree) showToast({ title: wasEnabled ? language.t("toast.workspace.disabled.title") : language.t("toast.workspace.enabled.title"), description: wasEnabled ? language.t("toast.workspace.disabled.description") : language.t("toast.workspace.enabled.description"), }) }, }, { 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 if (!layout.sidebar.opened()) { setState("hoverSession", undefined) setState("hoverProject", undefined) } 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 if (!layout.sidebar.opened()) { setState("hoverSession", undefined) setState("hoverProject", undefined) } 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 deepLinkEvent = "opencode:deep-link" const parseDeepLink = (input: string) => { if (!input.startsWith("opencode://")) return const url = new URL(input) if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") if (!directory) return return directory } const handleDeepLinks = (urls: string[]) => { if (!server.isLocal()) return for (const input of urls) { const directory = parseDeepLink(input) if (!directory) continue openProject(directory) } } const drainDeepLinks = () => { const pending = window.__OPENCODE__?.deepLinks ?? [] if (pending.length === 0) return if (window.__OPENCODE__) window.__OPENCODE__.deepLinks = [] handleDeepLinks(pending) } onMount(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ urls: string[] }>).detail const urls = detail?.urls ?? [] if (urls.length === 0) return handleDeepLinks(urls) } drainDeepLinks() window.addEventListener(deepLinkEvent, handler as EventListener) onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener)) }) const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) async function renameProject(project: LocalProject, next: string) { const current = displayName(project) if (next === current) return const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name }) return } globalSync.project.meta(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 (root: string, directory: string) => { if (directory === root) return setBusy(directory, true) const result = await globalSDK.client.worktree .remove({ directory: root, 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(root) if (params.dir && decode64(params.dir) === directory) { navigateToProject(root) } } const resetWorkspace = async (root: string, directory: string) => { if (directory === root) 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: root, 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: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ status: "loading" as "loading" | "ready" | "error", dirty: false, }) onMount(() => { 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 = () => { dialog.close() void deleteWorkspace(props.root, props.directory) } 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: { root: string; 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(() => { 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.root, 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 = decode64(dir) if (!directory) return 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 sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) }) createEffect(() => { const project = currentProject() if (!project) return if (workspaceSetting()) { const activeDir = decode64(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 setState("hoverProject", undefined) 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 local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const existing = store.workspaceOrder[project.worktree] if (!existing) return extra ? [...dirs, extra] : dirs const keep = existing.filter((d) => d !== local && dirs.includes(d)) const missing = dirs.filter((d) => d !== local && !existing.includes(d)) const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep] if (!extra) return merged if (pending) return merged return [...merged, extra] } const sidebarProject = createMemo(() => { if (layout.sidebar.opened()) return currentProject() const hovered = hoverProjectData() if (hovered) return hovered return currentProject() }) 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 = sidebarProject() 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 opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return (
0 && props.notify }} />
0 && props.notify}>
) } const SessionItem = (props: { session: Session slug: string mobile?: boolean dense?: boolean popover?: boolean children?: Map }): 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 childIDs = props.children?.get(props.session.id) if (childIDs) { for (const id of childIDs) { const childPermissions = sessionStore.permission?.[id] ?? [] if (childPermissions.length > 0) return true } return false } 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 agentColor(user.agent, 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 && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) const [menu, setMenu] = createStore({ open: false, pendingRename: false, }) const hoverPrefetch = { current: undefined as ReturnType | undefined } const cancelHoverPrefetch = () => { if (hoverPrefetch.current === undefined) return clearTimeout(hoverPrefetch.current) hoverPrefetch.current = undefined } const scheduleHoverPrefetch = () => { if (hoverPrefetch.current !== undefined) return hoverPrefetch.current = setTimeout(() => { hoverPrefetch.current = undefined prefetchSession(props.session) }, 200) } onCleanup(cancelHoverPrefetch) 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")} onClick={() => { setState("hoverSession", undefined) if (layout.sidebar.opened()) return queueMicrotask(() => setState("hoverProject", 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} } > setState("hoverSession", 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" />
setMenu("open", open)}> { if (!menu.pendingRename) return event.preventDefault() setMenu("pendingRename", false) openEditor(`session:${props.session.id}`, props.session.title) }} > { setMenu("pendingRename", true) setMenu("open", false) }} > {language.t("common.rename")} archiveSession(props.session)}> {language.t("common.archive")} dialog.show(() => )}> {language.t("common.delete")}
) } const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { const label = language.t("command.session.new") const tooltip = () => props.mobile || !sidebarExpanded() const item = ( { setState("hoverSession", undefined) if (layout.sidebar.opened()) return queueMicrotask(() => setState("hoverProject", undefined)) }} >
{label}
) return (
{item} } > {item}
) } 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 = sidebarProject() if (!project) return const directory = store.activeWorkspace if (!directory) return const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) 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 [menu, setMenu] = createStore({ open: false, pendingRename: 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(Date.now())), ) const children = createMemo(() => { const map = new Map() for (const session of workspaceStore.session) { if (!session.parentID) continue const existing = map.get(session.parentID) if (existing) { existing.push(session.id) continue } map.set(session.parentID, [session.id]) } return map }) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => { const current = decode64(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 booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const loading = createMemo(() => open() && !booted() && sessions().length === 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const busy = createMemo(() => isBusy(props.directory)) const loadMore = async () => { 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()}
setMenu("open", open)} > { if (!menu.pendingRename) return event.preventDefault() setMenu("pendingRename", false) openEditor(`workspace:${props.directory}`, workspaceValue()) }} > { setMenu("pendingRename", true) setMenu("open", false) }} > {language.t("common.rename")} dialog.show(() => ( )) } > {language.t("common.reset")} dialog.show(() => ( )) } > {language.t("common.delete")}
) } const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { const current = decode64(params.dir) ?? "" return props.project.worktree === current || props.project.sandboxes?.includes(current) }) const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo( () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), ) const [open, setOpen] = createSignal(false) const [menu, setMenu] = createSignal(false) const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) const active = createMemo( () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), ) createEffect(() => { if (preview()) return if (!open()) return setOpen(false) }) const label = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) 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, { bootstrap: false }) const root = workspaceKey(directory) return data.session .filter((session) => workspaceKey(session.directory) === root) .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions(Date.now())) .slice(0, 2) } const projectSessions = () => { const directory = props.project.worktree const [data] = globalSync.child(directory, { bootstrap: false }) const root = workspaceKey(directory) return data.session .filter((session) => workspaceKey(session.directory) === root) .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions(Date.now())) .slice(0, 2) } const projectName = () => props.project.name || getFilename(props.project.worktree) const Trigger = () => ( { setMenu(value) if (value) setOpen(false) }} > { if (!overlay()) return globalSync.child(props.project.worktree) setState("hoverProject", props.project.worktree) setState("hoverSession", undefined) }} onFocus={() => { if (!overlay()) return globalSync.child(props.project.worktree) setState("hoverProject", props.project.worktree) setState("hoverSession", undefined) }} onClick={() => navigateToProject(props.project.worktree)} onBlur={() => setOpen(false)} > dialog.show(() => )}> {language.t("common.edit")} { const enabled = layout.sidebar.workspaces(props.project.worktree)() if (enabled) { layout.sidebar.toggleWorkspaces(props.project.worktree) return } if (props.project.vcs !== "git") return layout.sidebar.toggleWorkspaces(props.project.worktree) }} > {layout.sidebar.workspaces(props.project.worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} closeProject(props.project.worktree)} > {language.t("common.close")} ) return ( // @ts-ignore
}> } onOpenChange={(value) => { if (menu()) return setOpen(value) if (value) setState("hoverSession", 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(Date.now())), ) const children = createMemo(() => { const map = new Map() for (const session of workspaceStore.session) { if (!session.parentID) continue const existing = map.get(session.parentID) if (existing) { existing.push(session.id) continue } map.set(session.parentID, [session.id]) } return map }) const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const loading = createMemo(() => !booted() && sessions().length === 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().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 [overflow-anchor:none]" >
) } const createWorkspace = async (project: LocalProject) => { if (!layout.sidebar.opened()) { setState("hoverSession", undefined) setState("hoverProject", undefined) } const created = await globalSDK.client.worktree .create({ directory: project.worktree }) .then((x) => x.data) .catch((err) => { showToast({ title: language.t("workspace.create.failed.title"), description: errorMessage(err), }) return undefined }) if (!created?.directory) return const local = project.worktree const key = workspaceKey(created.directory) const root = workspaceKey(local) setBusy(created.directory, true) WorktreeState.pending(created.directory) setStore("workspaceExpanded", key, true) if (key !== created.directory) { setStore("workspaceExpanded", created.directory, true) } setStore("workspaceOrder", project.worktree, (prev) => { const existing = prev ?? [] const next = existing.filter((item) => { const id = workspaceKey(item) if (id === root) return false return id !== key }) return [local, created.directory, ...next] }) globalSync.child(created.directory) navigate(`/${base64Encode(created.directory)}/session`) layout.mobileSidebar.hide() } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" return project.name || getFilename(project.worktree) }) const projectId = createMemo(() => panelProps.project?.id ?? "") const workspaces = createMemo(() => workspaceIds(panelProps.project)) const workspacesEnabled = createMemo(() => { const project = panelProps.project if (!project) return false if (project.vcs !== "git") return false return layout.sidebar.workspaces(project.worktree)() }) const homedir = createMemo(() => globalSync.data.path.home) return (
{(p) => ( <>
renameProject(p(), next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation /> {p().worktree.replace(homedir(), "~")}
dialog.show(() => )}> {language.t("common.edit")} { const enabled = layout.sidebar.workspaces(p().worktree)() if (enabled) { layout.sidebar.toggleWorkspaces(p().worktree) return } if (p().vcs !== "git") return 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 (!panelProps.mobile) scrollContainerRef = el }} class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" > {(directory) => ( )}
)}
0 && providers.paid().length === 0), }} >
{language.t("sidebar.gettingStarted.title")}
{language.t("sidebar.gettingStarted.line1")}
{language.t("sidebar.gettingStarted.line2")}
) } const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() command.register(() => [ { id: "workspace.new", title: language.t("workspace.new"), category: language.t("command.category.workspace"), keybind: "mod+shift+w", disabled: !workspaceSetting(), onSelect: () => { const project = currentProject() if (!project) return return createWorkspace(project) }, }, ]) 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")} />
) } return (
{ if (e.target === e.currentTarget) layout.mobileSidebar.hide() }} />
}> {props.children}
) }