import { createStore, produce } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { useServer } from "./server" import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export function getAvatarColors(key?: string) { if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { return { background: `var(--avatar-background-${key})`, foreground: `var(--avatar-text-${key})`, } } return { background: "var(--surface-info-base)", foreground: "var(--text-base)", } } type SessionTabs = { active?: string all: string[] } type SessionView = { scroll: Record reviewOpen?: string[] pendingMessage?: string pendingMessageAt?: number } type TabHandoff = { dir: string id: string at: number } export type LocalProject = Partial & { worktree: string; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) { touch(key) seed(key) return key } export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey return () => { const value = key() ensure(value) return value } } export function pruneSessionKeys(input: { keep?: string max: number used: Map view: string[] tabs: string[] }) { if (!input.keep) return [] const keys = new Set([...input.view, ...input.tabs]) if (keys.size <= input.max) return [] const score = (key: string) => { if (key === input.keep) return Number.MAX_SAFE_INTEGER return input.used.get(key) ?? 0 } return Array.from(keys) .sort((a, b) => score(b) - score(a)) .slice(input.max) } export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { const globalSdk = useGlobalSDK() const globalSync = useGlobalSync() const server = useServer() const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) const migrate = (value: unknown) => { if (!isRecord(value)) return value const sidebar = value.sidebar const migratedSidebar = (() => { if (!isRecord(sidebar)) return sidebar if (typeof sidebar.workspaces !== "boolean") return sidebar return { ...sidebar, workspaces: {}, workspacesDefault: sidebar.workspaces, } })() const review = value.review const fileTree = value.fileTree const migratedFileTree = (() => { if (!isRecord(fileTree)) return fileTree if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree const width = typeof fileTree.width === "number" ? fileTree.width : 344 return { ...fileTree, opened: true, width: width === 260 ? 344 : width, tab: "changes", } })() const migratedReview = (() => { if (!isRecord(review)) return review if (typeof review.panelOpened === "boolean") return review const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true return { ...review, panelOpened: opened, } })() if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value return { ...value, sidebar: migratedSidebar, review: migratedReview, fileTree: migratedFileTree, } } const target = Persist.global("layout", ["layout.v6"]) const [store, setStore, _, ready] = persisted( { ...target, migrate }, createStore({ sidebar: { opened: false, width: 344, workspaces: {} as Record, workspacesDefault: false, }, terminal: { height: 280, opened: false, }, review: { diffStyle: "split" as ReviewDiffStyle, panelOpened: true, }, fileTree: { opened: true, width: 344, tab: "changes" as "changes" | "all", }, session: { width: 600, }, mobileSidebar: { opened: false, }, sessionTabs: {} as Record, sessionView: {} as Record, handoff: { tabs: undefined as TabHandoff | undefined, }, }), ) const MAX_SESSION_KEYS = 50 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() const SESSION_STATE_KEYS = [ { key: "prompt", legacy: "prompt", version: "v2" }, { key: "terminal", legacy: "terminal", version: "v1" }, { key: "file-view", legacy: "file", version: "v1" }, ] as const const dropSessionState = (keys: string[]) => { for (const key of keys) { const parts = key.split("/") const dir = parts[0] const session = parts[1] if (!dir) continue for (const entry of SESSION_STATE_KEYS) { const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) void removePersisted(target) const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` void removePersisted({ key: legacyKey }) } } } function prune(keep?: string) { const drop = pruneSessionKeys({ keep, max: MAX_SESSION_KEYS, used, view: Object.keys(store.sessionView), tabs: Object.keys(store.sessionTabs), }) if (drop.length === 0) return setStore( produce((draft) => { for (const key of drop) { delete draft.sessionView[key] delete draft.sessionTabs[key] } }), ) scroll.drop(drop) dropSessionState(drop) for (const key of drop) { used.delete(key) } } function touch(sessionKey: string) { meta.active = sessionKey used.set(sessionKey, Date.now()) if (!ready()) return if (meta.pruned) return meta.pruned = true prune(sessionKey) } const scroll = createScrollPersistence({ debounceMs: 250, getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, onFlush: (sessionKey, next) => { const current = store.sessionView[sessionKey] const keep = meta.active ?? sessionKey if (!current) { setStore("sessionView", sessionKey, { scroll: next }) prune(keep) return } setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) prune(keep) }, }) const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey)) createEffect(() => { if (!ready()) return if (meta.pruned) return const active = meta.active if (!active) return meta.pruned = true prune(active) }) onMount(() => { const flush = () => batch(() => scroll.flushAll()) const handleVisibility = () => { if (document.visibilityState !== "hidden") return flush() } window.addEventListener("pagehide", flush) document.addEventListener("visibilitychange", handleVisibility) onCleanup(() => { window.removeEventListener("pagehide", flush) document.removeEventListener("visibilitychange", handleVisibility) scroll.dispose() }) }) const [colors, setColors] = createStore>({}) const colorRequested = new Map() function pickAvailableColor(used: Set): AvatarColorKey { const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c)) if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] return available[Math.floor(Math.random() * available.length)] } function enrich(project: { worktree: string; expanded: boolean }) { const [childStore] = globalSync.child(project.worktree, { bootstrap: false }) const projectID = childStore.project const metadata = projectID ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) const local = childStore.projectMeta const localOverride = local?.name !== undefined || local?.commands?.start !== undefined || local?.icon?.override !== undefined || local?.icon?.color !== undefined const base = { ...(metadata ?? {}), ...project, icon: { url: metadata?.icon?.url, override: metadata?.icon?.override ?? childStore.icon, color: metadata?.icon?.color, }, } const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride) if (!isGlobal) return base return { ...base, id: base.id ?? "global", name: local?.name, commands: local?.commands, icon: { url: base.icon?.url, override: local?.icon?.override, color: local?.icon?.color, }, } } const roots = createMemo(() => { const map = new Map() for (const project of globalSync.data.project) { const sandboxes = project.sandboxes ?? [] for (const sandbox of sandboxes) { map.set(sandbox, project.worktree) } } return map }) const rootFor = (directory: string) => { const map = roots() if (map.size === 0) return directory const visited = new Set() const chain = [directory] while (chain.length) { const current = chain[chain.length - 1] if (!current) return directory const next = map.get(current) if (!next) return current if (visited.has(next)) return directory visited.add(next) chain.push(next) } return directory } createEffect(() => { const projects = server.projects.list() const seen = new Set(projects.map((project) => project.worktree)) batch(() => { for (const project of projects) { const root = rootFor(project.worktree) if (root === project.worktree) continue server.projects.close(project.worktree) if (!seen.has(root)) { server.projects.open(root) seen.add(root) } if (project.expanded) server.projects.expand(root) } }) }) const enriched = createMemo(() => server.projects.list().map(enrich)) const list = createMemo(() => { const projects = enriched() return projects.map((project) => { const color = project.icon?.color ?? colors[project.worktree] if (!color) return project const icon = project.icon ? { ...project.icon, color } : { color } return { ...project, icon } }) }) createEffect(() => { const projects = enriched() if (projects.length === 0) return if (!globalSync.ready) return for (const project of projects) { if (!project.id) continue if (project.id === "global") continue globalSync.project.icon(project.worktree, project.icon?.override) } }) createEffect(() => { const projects = enriched() if (projects.length === 0) return for (const project of projects) { if (project.icon?.color) colorRequested.delete(project.worktree) } const used = new Set() for (const project of projects) { const color = project.icon?.color ?? colors[project.worktree] if (color) used.add(color) } for (const project of projects) { if (project.icon?.color) continue const worktree = project.worktree const existing = colors[worktree] const color = existing ?? pickAvailableColor(used) if (!existing) { used.add(color) setColors(worktree, color) } if (!project.id) continue const requested = colorRequested.get(worktree) if (requested === color) continue colorRequested.set(worktree, color) if (project.id === "global") { globalSync.project.meta(worktree, { icon: { color } }) continue } void globalSdk.client.project .update({ projectID: project.id, directory: worktree, icon: { color } }) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) }) } }) onMount(() => { Promise.all( server.projects.list().map((project) => { return globalSync.project.loadSessions(project.worktree) }), ) }) return { ready, handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { setStore("handoff", "tabs", { dir, id, at: Date.now() }) }, clearTabs() { if (!store.handoff?.tabs) return setStore("handoff", "tabs", undefined) }, }, projects: { list, open(directory: string) { const root = rootFor(directory) if (server.projects.list().find((x) => x.worktree === root)) return globalSync.project.loadSessions(root) server.projects.open(root) }, close(directory: string) { server.projects.close(directory) }, expand(directory: string) { server.projects.expand(directory) }, collapse(directory: string) { server.projects.collapse(directory) }, move(directory: string, toIndex: number) { server.projects.move(directory, toIndex) }, }, sidebar: { opened: createMemo(() => store.sidebar.opened), open() { setStore("sidebar", "opened", true) }, close() { setStore("sidebar", "opened", false) }, toggle() { setStore("sidebar", "opened", (x) => !x) }, width: createMemo(() => store.sidebar.width), resize(width: number) { setStore("sidebar", "width", width) }, workspaces(directory: string) { return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false }, setWorkspaces(directory: string, value: boolean) { setStore("sidebar", "workspaces", directory, value) }, toggleWorkspaces(directory: string) { const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false setStore("sidebar", "workspaces", directory, !current) }, }, terminal: { height: createMemo(() => store.terminal.height), resize(height: number) { setStore("terminal", "height", height) }, }, review: { diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), setDiffStyle(diffStyle: ReviewDiffStyle) { if (!store.review) { setStore("review", { diffStyle, panelOpened: true }) return } setStore("review", "diffStyle", diffStyle) }, }, fileTree: { opened: createMemo(() => store.fileTree?.opened ?? true), width: createMemo(() => store.fileTree?.width ?? 344), tab: createMemo(() => store.fileTree?.tab ?? "changes"), setTab(tab: "changes" | "all") { if (!store.fileTree) { setStore("fileTree", { opened: true, width: 344, tab }) return } setStore("fileTree", "tab", tab) }, open() { if (!store.fileTree) { setStore("fileTree", { opened: true, width: 344, tab: "changes" }) return } setStore("fileTree", "opened", true) }, close() { if (!store.fileTree) { setStore("fileTree", { opened: false, width: 344, tab: "changes" }) return } setStore("fileTree", "opened", false) }, toggle() { if (!store.fileTree) { setStore("fileTree", { opened: true, width: 344, tab: "changes" }) return } setStore("fileTree", "opened", (x) => !x) }, resize(width: number) { if (!store.fileTree) { setStore("fileTree", { opened: true, width, tab: "changes" }) return } setStore("fileTree", "width", width) }, }, session: { width: createMemo(() => store.session?.width ?? 600), resize(width: number) { if (!store.session) { setStore("session", { width }) return } setStore("session", "width", width) }, }, mobileSidebar: { opened: createMemo(() => store.mobileSidebar?.opened ?? false), show() { setStore("mobileSidebar", "opened", true) }, hide() { setStore("mobileSidebar", "opened", false) }, toggle() { setStore("mobileSidebar", "opened", (x) => !x) }, }, pendingMessage: { set(sessionKey: string, messageID: string) { const at = Date.now() touch(sessionKey) const current = store.sessionView[sessionKey] if (!current) { setStore("sessionView", sessionKey, { scroll: {}, pendingMessage: messageID, pendingMessageAt: at, }) prune(meta.active ?? sessionKey) return } setStore( "sessionView", sessionKey, produce((draft) => { draft.pendingMessage = messageID draft.pendingMessageAt = at }), ) }, consume(sessionKey: string) { const current = store.sessionView[sessionKey] const message = current?.pendingMessage const at = current?.pendingMessageAt if (!message || !at) return setStore( "sessionView", sessionKey, produce((draft) => { delete draft.pendingMessage delete draft.pendingMessageAt }), ) if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return return message }, }, view(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) function setTerminalOpened(next: boolean) { const current = store.terminal if (!current) { setStore("terminal", { height: 280, opened: next }) return } const value = current.opened ?? false if (value === next) return setStore("terminal", "opened", next) } function setReviewPanelOpened(next: boolean) { const current = store.review if (!current) { setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) return } const value = current.panelOpened ?? true if (value === next) return setStore("review", "panelOpened", next) } return { scroll(tab: string) { return scroll.scroll(key(), tab) }, setScroll(tab: string, pos: SessionScroll) { scroll.setScroll(key(), tab, pos) }, terminal: { opened: terminalOpened, open() { setTerminalOpened(true) }, close() { setTerminalOpened(false) }, toggle() { setTerminalOpened(!terminalOpened()) }, }, reviewPanel: { opened: reviewPanelOpened, open() { setReviewPanelOpened(true) }, close() { setReviewPanelOpened(false) }, toggle() { setReviewPanelOpened(!reviewPanelOpened()) }, }, review: { open: createMemo(() => s().reviewOpen), setOpen(open: string[]) { const session = key() const current = store.sessionView[session] if (!current) { setStore("sessionView", session, { scroll: {}, reviewOpen: open, }) return } if (same(current.reviewOpen, open)) return setStore("sessionView", session, "reviewOpen", open) }, }, } }, tabs(sessionKey: string | Accessor) { const key = createSessionKeyReader(sessionKey, ensureKey) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) return { tabs, active: createMemo(() => tabs().active), all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), setActive(tab: string | undefined) { const session = key() if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: [], active: tab }) } else { setStore("sessionTabs", session, "active", tab) } }, setAll(all: string[]) { const session = key() const next = all.filter((tab) => tab !== "review") if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: next, active: undefined }) } else { setStore("sessionTabs", session, "all", next) } }, async open(tab: string) { const session = key() const current = store.sessionTabs[session] ?? { all: [] } if (tab === "review") { if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab }) return } setStore("sessionTabs", session, "active", tab) return } if (tab === "context") { const all = [tab, ...current.all.filter((x) => x !== tab)] if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all, active: tab }) return } setStore("sessionTabs", session, "all", all) setStore("sessionTabs", session, "active", tab) return } if (!current.all.includes(tab)) { if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: [tab], active: tab }) return } setStore("sessionTabs", session, "all", [...current.all, tab]) setStore("sessionTabs", session, "active", tab) return } if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: current.all, active: tab }) return } setStore("sessionTabs", session, "active", tab) }, close(tab: string) { const session = key() const current = store.sessionTabs[session] if (!current) return if (tab === "review") { if (current.active !== tab) return setStore("sessionTabs", session, "active", current.all[0]) return } const all = current.all.filter((x) => x !== tab) if (current.active !== tab) { setStore("sessionTabs", session, "all", all) return } const index = current.all.findIndex((f) => f === tab) const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0] batch(() => { setStore("sessionTabs", session, "all", all) setStore("sessionTabs", session, "active", next) }) }, move(tab: string, to: number) { const session = key() const current = store.sessionTabs[session] if (!current) return const index = current.all.findIndex((f) => f === tab) if (index === -1) return setStore( "sessionTabs", session, "all", produce((opened) => { opened.splice(to, 0, opened.splice(index, 1)[0]) }), ) }, } }, } }, })