import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" import { createEffect, getOwner, onCleanup, 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" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" export type SessionTab = { type: "session" server: ServerConnection.Key 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) : sessionHref(tab.server, 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) { return tabs.some((tab) => tab.type === "session" && tab.server === server && 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.window("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.window("tabs.recent"), createStore({})) const params = useParams() const navigate = useNavigate() const location = useLocation() const memory = createTabMemory(getOwner()) 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) } onCleanup(memory.dispose) 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) { for (const tab of store) { if (!servers.has(tab.server)) memory.remove(tabKey(tab)) } 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)) navigate(href) } const 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)) memory.remove(key) if (draftID) removeDraftPersisted(draftID) } const actions = { addSessionTab: (tab: Omit) => { const next = { type: "session" as const, ...tab } const existing = store.find((item) => tabKey(item) === tabKey(next)) if (existing) return existing void startTransition(() => { setStore( produce((tabs) => { if (tabs.some((item) => tabKey(item) === tabKey(next))) return tabs.push(next) }), ) }) return next }, reorder(keys: string[]) { setStore( produce((tabs) => { const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab])) const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab) if (next.length !== tabs.length) return tabs.splice(0, tabs.length, ...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() void startTransition(() => { setStore( produce((tabs) => { tabs.push({ type: "draft", draftID, ...draft }) }), ) navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) }) }, updateDraft(draftID: string, draft: Partial>) { void startTransition(() => { setStore( (tab) => tab.type === "draft" && tab.draftID === draftID, produce((tab) => Object.assign(tab, draft)), ) }) }, promoteDraft(draftID: string, session: Omit) { // Keep the replacement and navigation atomic so /new-session never renders // after its backing draft tab has been removed from the store. const active = location.pathname === "/new-session" && location.query.draftId === draftID const next = { type: "session" as const, ...session } void 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) }) memory.remove(`draft:${draftID}`) removeDraftPersisted(draftID) }, removeTab, removeSessionTab(input: Omit) { const index = store.findIndex( (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, ) if (index !== -1) removeTab(index) }, 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)) for (const key of removed) memory.remove(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 targetServer = input.server ?? server.key const removed = store .filter( (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { setStore( produce((tabs) => { const sessionIDs = new Set(input.sessionIDs) const currentHref = targetServer === server.key && params.dir && params.id ? tabHref({ type: "session", server: targetServer, sessionId: params.id, }) : undefined const currentIndex = currentHref ? tabs.findIndex( (tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref, ) : -1 const currentTab = tabs[currentIndex] const removedCurrent = currentTab?.type === "session" && currentTab.server === targetServer && 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 !== targetServer) 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) }) for (const key of removed) memory.remove(key) }, 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("/") }, state(tab: Tab, name: string, init: () => T) { return memory.ensure(tabKey(tab), name, init) }, } return { ...actions, store, ready, recentReady } }, })