import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" import { createEffect, startTransition } from "solid-js" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" export type SessionTab = { type: "session" server: ServerConnection.Key dirBase64: string sessionId: string } export type DraftTab = { type: "draft" draftID: string server: ServerConnection.Key directory: string worktree?: string } export type Tab = SessionTab | DraftTab type RecentTab = { key?: string } export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { const dirBase64 = base64Encode(session.directory) return tabs.some( (tab) => tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id, ) } export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ name: "Tabs", gate: false, init: () => { const server = useServer() const platform = usePlatform() const fallback = server.key const [store, setStore, _, ready] = persisted( { ...Persist.global("tabs"), migrate: (value: unknown) => { if (!Array.isArray(value)) return value return value.map((tab) => { if (!tab || typeof tab !== "object" || "server" in tab) return tab return { ...tab, server: fallback } }) }, }, createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore({})) const params = useParams() const navigate = useNavigate() const location = useLocation() const closing = new Set() let recentWrite = 0 let recentValue: string | undefined const recentKey = () => (recentWrite ? recentValue : recent.key) const setRecentKey = (key: string | undefined) => { const write = ++recentWrite recentValue = key if (recentReady()) { setRecent("key", key) return } void recentReady.promise?.then(() => { if (write === recentWrite) setRecent("key", key) }) } const removeDraftPersisted = (draftID: string) => { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } createEffect(() => { if (!ready() || !recentReady()) return const servers = new Set(server.list.map(ServerConnection.key)) const next = store.filter((tab) => servers.has(tab.server)) if (next.length !== store.length) setStore(() => next) if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) }) const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) if (tab.server === server.key) { navigate(href) return } void startTransition(() => { server.setActive(tab.server) navigate(href) }) } const actions = { addSessionTab: (tab: Omit) => { const next = { type: "session" as const, ...tab } if (closing.has(tabKey(next))) return setStore( produce((tabs) => { if (tabs.some((item) => tabKey(item) === tabKey(next))) return tabs.push(next) }), ) }, draft(draftID: string) { const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, newDraft(draft: Omit, prompt?: string) { const draftID = uuid() setStore( produce((tabs) => { tabs.push({ type: "draft", draftID, ...draft }) }), ) navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) }, updateDraft(draftID: string, draft: Partial>) { setStore( (tab) => tab.type === "draft" && tab.draftID === draftID, produce((tab) => Object.assign(tab, draft)), ) }, promoteDraft(draftID: string, session: Omit) { // We're viewing this draft when /new-session?draftId=… points at it. Promoting // replaces the draft tab with a session tab, so the draft route would stop resolving // and fall back home. Navigate to the new session first so we leave /new-session // before the draft is removed from the store. const active = location.pathname === "/new-session" && location.query.draftId === draftID const next = { type: "session" as const, ...session } startTransition(() => { setStore( produce((tabs) => { const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) if (index !== -1) tabs[index] = next }), ) if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) if (active) navigateTab(next) }) removeDraftPersisted(draftID) }, removeTab: (index: number) => { const tab = store[index] if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined const nextTab = store[index + 1] ?? store[index - 1] closing.add(key) void startTransition(() => { setStore( produce((tabs) => { tabs.splice(index, 1) }), ) if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) if (draftID) removeDraftPersisted(draftID) }, removeServer(key: ServerConnection.Key) { const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { const removed = store .filter( (tab) => tab.type === "session" && tab.server === server.key && atob(tab.dirBase64) === input.directory && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { setStore( produce((tabs) => { const sessionIDs = new Set(input.sessionIDs) const currentHref = params.dir && params.id ? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id, }) : undefined const currentIndex = currentHref ? tabs.findIndex( (tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref, ) : -1 const currentTab = tabs[currentIndex] const removedCurrent = currentTab?.type === "session" && currentTab.server === server.key && atob(currentTab.dirBase64) === input.directory && sessionIDs.has(currentTab.sessionId) for (let i = tabs.length - 1; i >= 0; i--) { const tab = tabs[i] if (!tab || tab.type !== "session") continue if (tab.server !== server.key) continue if (atob(tab.dirBase64) !== input.directory) continue if (!sessionIDs.has(tab.sessionId)) continue tabs.splice(i, 1) } if (!removedCurrent) return const nextTab = tabs.slice(currentIndex).find((tab) => tab.type === "session") ?? tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session") if (nextTab) navigateTab(nextTab) else navigate("/") }), ) if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) }) }, select: navigateTab, remember(tab: Tab) { const key = tabKey(tab) if (recentKey() !== key) setRecentKey(key) }, toggleHome(input: { home: boolean; current?: Tab }) { if (input.home) { const tab = store.find((tab) => tabKey(tab) === recentKey()) if (tab) navigateTab(tab) return } if (input.current) { setRecentKey(tabKey(input.current)) navigate("/") return } navigate("/") }, } return { ...actions, store, ready, recentReady } }, })