"use client" import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo, type ReactNode, } from "react" import { useTranslations } from "next-intl" import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useAcpActions } from "@/contexts/acp-connections-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { listOpenedTabs, saveOpenedTabs } from "@/lib/api" import type { AgentType, ConversationStatus, OpenedTab } from "@/lib/types" import { AGENT_DISPLAY_ORDER } from "@/lib/types" interface TabItemInternal { id: string kind: "conversation" folderId: number conversationId: number | null /** The runtime session key used by ConversationRuntimeContext. * For new conversations this is a virtual (negative) ID that differs * from the persisted `conversationId`. */ runtimeConversationId?: number agentType: AgentType title: string isPinned: boolean workingDir?: string status?: ConversationStatus } export type TabItem = TabItemInternal interface TabContextValue { tabs: TabItem[] activeTabId: string | null tabsHydrated: boolean isTileMode: boolean openTab: ( folderId: number, conversationId: number, agentType: AgentType, pin?: boolean, title?: string ) => void closeTab: (tabId: string) => void closeConversationTab: ( folderId: number, conversationId: number, agentType: AgentType ) => void closeOtherTabs: (tabId: string) => void closeAllTabs: () => void closeTabsByFolder: (folderId: number) => void switchTab: (tabId: string) => void pinTab: (tabId: string) => void toggleTileMode: () => void openNewConversationTab: (folderId: number, workingDir: string) => void bindConversationTab: ( tabId: string, conversationId: number, agentType: AgentType, title: string, runtimeConversationId?: number ) => void setTabRuntimeConversationId: ( tabId: string, runtimeConversationId: number ) => void setTabFolder: (tabId: string, folderId: number, workingDir: string) => void reorderTabs: (reorderedTabs: TabItem[]) => void onPreviewTabReplaced: (callback: (tabId: string) => void) => () => void } const TabContext = createContext(null) export function useTabContext() { const ctx = useContext(TabContext) if (!ctx) { throw new Error("useTabContext must be used within TabProvider") } return ctx } function makeConversationTabId( folderId: number, agentType: AgentType, conversationId: number ): string { return `conv-${folderId}-${agentType}-${conversationId}` } function makeNewConversationTabId(): string { return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } function findTabIndexForConversation( tabs: TabItemInternal[], folderId: number, agentType: AgentType, conversationId: number ): number { const canonicalId = makeConversationTabId(folderId, agentType, conversationId) const idx = tabs.findIndex((t) => t.id === canonicalId) if (idx >= 0) return idx return tabs.findIndex( (t) => t.folderId === folderId && t.conversationId === conversationId && t.agentType === agentType ) } interface TabProviderProps { children: ReactNode } const TILE_MODE_STORAGE_KEY = "workspace:tile-mode" export function TabProvider({ children }: TabProviderProps) { const t = useTranslations("Folder.tabContext") const { activateConversationPane } = useWorkspaceContext() const { conversations, folders, setActiveFolderId } = useAppWorkspace() const { disconnect: acpDisconnect } = useAcpActions() const [rawTabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) const [tabsHydrated, setTabsHydrated] = useState(false) // Refs for volatile state const activeTabIdRef = useRef(activeTabId) useEffect(() => { activeTabIdRef.current = activeTabId }, [activeTabId]) const rawTabsRef = useRef(rawTabs) useEffect(() => { rawTabsRef.current = rawTabs }, [rawTabs]) // Sync active tab's folderId up to AppWorkspaceProvider so derived // consumers (ActiveFolderProvider, branch polling, etc.) reflect the // currently-focused folder. useEffect(() => { const activeTab = rawTabs.find((t) => t.id === activeTabId) ?? null setActiveFolderId(activeTab?.folderId ?? null) }, [rawTabs, activeTabId, setActiveFolderId]) const conversationsRef = useRef(conversations) useEffect(() => { conversationsRef.current = conversations }, [conversations]) const foldersRef = useRef(folders) useEffect(() => { foldersRef.current = folders }, [folders]) // Callback set for preview tab replacement notifications const previewReplacedCallbacksRef = useRef(new Set<(tabId: string) => void>()) const onPreviewTabReplaced = useCallback( (callback: (tabId: string) => void) => { previewReplacedCallbacksRef.current.add(callback) return () => { previewReplacedCallbacksRef.current.delete(callback) } }, [] ) // Hydrate from persisted opened_tabs on mount useEffect(() => { let cancelled = false void (async () => { try { const items = await listOpenedTabs() if (cancelled) return const restored: TabItemInternal[] = items.map((it) => ({ id: it.conversation_id != null ? makeConversationTabId( it.folder_id, it.agent_type, it.conversation_id ) : makeNewConversationTabId(), kind: "conversation", folderId: it.folder_id, conversationId: it.conversation_id, agentType: it.agent_type, title: t("loadingConversation"), isPinned: it.is_pinned, })) setTabs(restored) const active = items.find((it) => it.is_active) if (active) { const activeRestored = restored.find( (r) => r.folderId === active.folder_id && r.agentType === active.agent_type && r.conversationId === active.conversation_id ) if (activeRestored) setActiveTabId(activeRestored.id) } else if (restored.length > 0) { setActiveTabId(restored[0].id) } } catch (err) { console.error("[TabProvider] listOpenedTabs failed:", err) } finally { if (!cancelled) setTabsHydrated(true) } })() return () => { cancelled = true } }, [t]) // Debounced save to DB const saveTimerRef = useRef | null>(null) useEffect(() => { if (!tabsHydrated) return if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } saveTimerRef.current = setTimeout(() => { const items: OpenedTab[] = rawTabs.map((tab, i) => ({ id: 0, folder_id: tab.folderId, conversation_id: tab.conversationId, agent_type: tab.agentType, position: i, is_active: tab.id === activeTabId, is_pinned: tab.isPinned, })) saveOpenedTabs(items).catch(() => { // Silently ignore save errors }) }, 500) return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } } }, [rawTabs, activeTabId, tabsHydrated]) // Pre-index conversations for O(1) lookup in tabs derivation const conversationMap = useMemo(() => { const m = new Map() for (const c of conversations) { m.set(`${c.folder_id}-${c.agent_type}-${c.id}`, c) } return m }, [conversations]) // Derive tabs with up-to-date titles and status from conversations const tabs = useMemo(() => { if (conversationMap.size === 0) return rawTabs return rawTabs.map((tab) => { if (tab.conversationId != null) { const conv = conversationMap.get( `${tab.folderId}-${tab.agentType}-${tab.conversationId}` ) if (conv) { const newTitle = conv.title || t("untitledConversation") const newStatus = conv.status as ConversationStatus | undefined if (tab.title !== newTitle || tab.status !== newStatus) { return { ...tab, title: newTitle, status: newStatus } } } } return tab }) }, [rawTabs, conversationMap, t]) const openTab = useCallback( ( folderId: number, conversationId: number, agentType: AgentType, pin = false, title?: string ) => { let activateTabId: string | undefined let replacedPreviewTabId: string | undefined setTabs((prev) => { const existingIndex = findTabIndexForConversation( prev, folderId, agentType, conversationId ) if (existingIndex >= 0) { activateTabId = prev[existingIndex].id if (pin && !prev[existingIndex].isPinned) { const updated = [...prev] updated[existingIndex] = { ...updated[existingIndex], isPinned: true, } return updated } return prev } const resolvedTitle = title ?? conversationsRef.current.find( (c) => c.id === conversationId && c.agent_type === agentType && c.folder_id === folderId )?.title ?? t("untitledConversation") const tabId = makeConversationTabId(folderId, agentType, conversationId) activateTabId = tabId const newTab: TabItemInternal = { id: tabId, kind: "conversation", folderId, conversationId, agentType, title: resolvedTitle, isPinned: pin, } if (pin) { return [...prev, newTab] } const previewIndex = prev.findIndex((t) => !t.isPinned) if (previewIndex >= 0) { replacedPreviewTabId = prev[previewIndex].id const updated = [...prev] updated[previewIndex] = newTab return updated } return [...prev, newTab] }) if (replacedPreviewTabId) { for (const cb of previewReplacedCallbacksRef.current) { cb(replacedPreviewTabId) } } if (activateTabId) { setActiveTabId(activateTabId) } activateConversationPane() }, [activateConversationPane, t] ) const makeReplacementDraftTab = useCallback( (preferred?: TabItemInternal): TabItemInternal => { const folderId = preferred?.folderId ?? foldersRef.current[0]?.id ?? 0 const workingDir = preferred?.workingDir ?? foldersRef.current.find((f) => f.id === folderId)?.path ?? "" return { id: makeNewConversationTabId(), kind: "conversation", folderId, conversationId: null, agentType: AGENT_DISPLAY_ORDER[0], title: t("newConversation"), isPinned: true, workingDir, } }, [t] ) const [isTileMode, setIsTileMode] = useState(() => { if (typeof window === "undefined") return false try { return localStorage.getItem(TILE_MODE_STORAGE_KEY) === "true" } catch { return false } }) useEffect(() => { try { localStorage.setItem(TILE_MODE_STORAGE_KEY, String(isTileMode)) } catch { /* ignore */ } }, [isTileMode]) const closeTab = useCallback( (tabId: string) => { let neighborToSync: TabItemInternal | undefined let shouldReplaceWithEmpty = false setTabs((prev) => { const index = prev.findIndex((t) => t.id === tabId) if (index < 0) return prev const closingTab = prev[index] const next = prev.filter((t) => t.id !== tabId) if (next.length === 0) { if (foldersRef.current.length === 0) { shouldReplaceWithEmpty = true return [] } const replacementTab = makeReplacementDraftTab(closingTab) neighborToSync = replacementTab return [replacementTab] } if (tabId === activeTabIdRef.current) { const newIndex = Math.min(index, next.length - 1) neighborToSync = next[newIndex] } return next }) if (shouldReplaceWithEmpty) { setActiveTabId(null) return } if (neighborToSync) { setActiveTabId(neighborToSync.id) activateConversationPane() } }, [activateConversationPane, makeReplacementDraftTab] ) const closeConversationTab = useCallback( (folderId: number, conversationId: number, agentType: AgentType) => { const target = rawTabsRef.current.find( (tab) => tab.folderId === folderId && tab.conversationId === conversationId && tab.agentType === agentType ) if (!target) return closeTab(target.id) }, [closeTab] ) const closeOtherTabs = useCallback((tabId: string) => { setTabs((prev) => { const kept = prev.filter((t) => t.id === tabId) return kept.length === prev.length ? prev : kept }) setActiveTabId(tabId) }, []) const closeAllTabs = useCallback(() => { const seedTab = rawTabsRef.current.find( (t) => t.conversationId == null && t.workingDir ) ?? rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ?? rawTabsRef.current[0] if (foldersRef.current.length === 0) { setTabs([]) setActiveTabId(null) return } const replacementTab = makeReplacementDraftTab(seedTab) setTabs([replacementTab]) setActiveTabId(replacementTab.id) activateConversationPane() }, [activateConversationPane, makeReplacementDraftTab]) const closeTabsByFolder = useCallback((folderId: number) => { setTabs((prev) => { const remaining = prev.filter((t) => t.folderId !== folderId) if (remaining.length === prev.length) return prev // If active tab is being closed, move to first remaining tab const currentActive = activeTabIdRef.current const stillActive = currentActive != null && remaining.some((t) => t.id === currentActive) if (!stillActive) { setActiveTabId(remaining.length > 0 ? remaining[0].id : null) } return remaining }) }, []) const switchTab = useCallback( (tabId: string) => { const tab = rawTabsRef.current.find((t) => t.id === tabId) if (!tab) return setActiveTabId(tabId) activateConversationPane() }, [activateConversationPane] ) const pinTab = useCallback((tabId: string) => { setTabs((prev) => prev.map((t) => (t.id === tabId ? { ...t, isPinned: true } : t)) ) }, []) const toggleTileMode = useCallback(() => { setIsTileMode((prev) => !prev) }, []) const reorderTabs = useCallback( (reorderedTabs: TabItem[]) => setTabs(reorderedTabs), [] ) const openNewConversationTab = useCallback( (folderId: number, workingDir: string) => { // Singleton: reuse any existing draft tab regardless of folder, // so only one new-conversation tab can exist at a time. const existingTab = rawTabsRef.current.find( (t) => t.conversationId == null ) if (existingTab) { const folderChanged = existingTab.folderId !== folderId const workingDirChanged = existingTab.workingDir !== workingDir setActiveTabId(existingTab.id) activateConversationPane() if (folderChanged) { // Tear down the old ACP connection (bound to the old // workingDir) before patching the tab's folderId/workingDir. // The connection-lifecycle effect watches workingDir; once // status has settled to disconnected and workingDir flips, // it auto-reconnects against the new folder. void (async () => { try { await acpDisconnect(existingTab.id) } catch (err) { console.error("[TabProvider] disconnect draft tab:", err) } setTabs((prev) => prev.map((t) => t.id === existingTab.id ? { ...t, folderId, workingDir } : t ) ) })() } else if (workingDirChanged) { setTabs((prev) => prev.map((t) => t.id === existingTab.id ? { ...t, workingDir } : t ) ) } return } const agentType = AGENT_DISPLAY_ORDER[0] const tabId = makeNewConversationTabId() const newTab: TabItemInternal = { id: tabId, kind: "conversation", folderId, conversationId: null, agentType, title: t("newConversation"), isPinned: true, workingDir, } setTabs((prev) => [...prev, newTab]) setActiveTabId(tabId) activateConversationPane() }, [acpDisconnect, activateConversationPane, t] ) const bindConversationTab = useCallback( ( tabId: string, conversationId: number, agentType: AgentType, title: string, runtimeConversationId?: number ) => { let nextActiveTabId: string | null = null setTabs((prev) => prev.flatMap((tab) => { if (tab.id === tabId) { const nextTab: TabItemInternal = { ...tab, conversationId, agentType, title, runtimeConversationId, } return [nextTab] } if ( tab.folderId === tab.folderId && tab.conversationId === conversationId && tab.agentType === agentType ) { if (activeTabIdRef.current === tabId) { nextActiveTabId = tab.id } return [] } return [tab] }) ) if (nextActiveTabId) { setActiveTabId(nextActiveTabId) } }, [] ) const setTabRuntimeConversationId = useCallback( (tabId: string, runtimeConversationId: number) => { setTabs((prev) => { const target = prev.find((tab) => tab.id === tabId) if (!target || target.runtimeConversationId === runtimeConversationId) { return prev } return prev.map((tab) => tab.id === tabId ? { ...tab, runtimeConversationId } : tab ) }) }, [] ) const setTabFolder = useCallback( (tabId: string, folderId: number, workingDir: string) => { setTabs((prev) => prev.map((tab) => tab.id === tabId ? { ...tab, folderId, workingDir } : tab ) ) }, [] ) const value = useMemo( () => ({ tabs, activeTabId, tabsHydrated, isTileMode, openTab, closeTab, closeConversationTab, closeOtherTabs, closeAllTabs, closeTabsByFolder, switchTab, pinTab, toggleTileMode, openNewConversationTab, bindConversationTab, setTabRuntimeConversationId, setTabFolder, reorderTabs, onPreviewTabReplaced, }), [ tabs, activeTabId, tabsHydrated, isTileMode, openTab, closeTab, closeConversationTab, closeOtherTabs, closeAllTabs, closeTabsByFolder, switchTab, pinTab, toggleTileMode, openNewConversationTab, bindConversationTab, setTabRuntimeConversationId, setTabFolder, reorderTabs, onPreviewTabReplaced, ] ) return {children} }