"use client" import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo, type ReactNode, } from "react" import { useTranslations } from "next-intl" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { saveFolderOpenedConversations } from "@/lib/tauri" import type { AgentType, ConversationStatus, OpenedConversation, } from "@/lib/types" interface TabItemInternal { id: string kind: "conversation" | "new_conversation" conversationId?: number agentType: AgentType title: string isPinned: boolean workingDir?: string status?: ConversationStatus } export type TabItem = TabItemInternal interface TabContextValue { tabs: TabItem[] activeTabId: string | null openTab: ( conversationId: number, agentType: AgentType, pin?: boolean, title?: string ) => void closeTab: (tabId: string) => void closeOtherTabs: (tabId: string) => void closeAllTabs: () => void switchTab: (tabId: string) => void pinTab: (tabId: string) => void openNewConversationTab: (agentType: AgentType, workingDir: string) => void promoteNewConversationTab: ( tabId: string, conversationId: number, agentType: AgentType, title: string ) => void linkTabConversation: ( tabId: string, conversationId: number, agentType: AgentType, title: string ) => void reorderTabs: (reorderedTabs: TabItem[]) => 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( agentType: AgentType, conversationId: number ): string { return `conv-${agentType}-${conversationId}` } function makeNewConversationTabId(): string { return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } /** * Find a tab that represents the given conversation, regardless of whether * it has been promoted to a canonical id yet. Checks canonical id first, * then falls back to matching by conversationId + agentType (covers the * linked-but-not-yet-promoted new_conversation tabs). */ function findTabIndexForConversation( tabs: TabItemInternal[], agentType: AgentType, conversationId: number ): number { const canonicalId = makeConversationTabId(agentType, conversationId) const idx = tabs.findIndex((t) => t.id === canonicalId) if (idx >= 0) return idx return tabs.findIndex( (t) => t.conversationId === conversationId && t.agentType === agentType ) } interface TabProviderProps { children: ReactNode } export function TabProvider({ children }: TabProviderProps) { const t = useTranslations("Folder.tabContext") const { activateConversationPane } = useWorkspaceContext() const { folder, folderId, selectedConversation, selectConversation, clearSelection, startNewConversation, cancelNewConversation, conversations, } = useFolderContext() const [rawTabs, setTabs] = useState(() => { if (selectedConversation) { const tabId = makeConversationTabId( selectedConversation.agentType, selectedConversation.id ) return [ { id: tabId, kind: "conversation" as const, conversationId: selectedConversation.id, agentType: selectedConversation.agentType, title: t("loadingConversation"), isPinned: true, }, ] } return [] }) const [activeTabId, setActiveTabId] = useState(() => { if (selectedConversation) { return makeConversationTabId( selectedConversation.agentType, selectedConversation.id ) } return null }) // Refs for volatile state — used in callbacks to avoid re-creation const activeTabIdRef = useRef(activeTabId) useEffect(() => { activeTabIdRef.current = activeTabId }, [activeTabId]) const rawTabsRef = useRef(rawTabs) useEffect(() => { rawTabsRef.current = rawTabs }, [rawTabs]) const conversationsRef = useRef(conversations) useEffect(() => { conversationsRef.current = conversations }, [conversations]) // Restore tabs from folder.opened_conversations when folder first loads const [restoredFolderId, setRestoredFolderId] = useState(() => selectedConversation ? folderId : null ) useEffect(() => { if (!folder) return if (restoredFolderId === folder.id) return let cancelled = false queueMicrotask(() => { if (cancelled) return setRestoredFolderId(folder.id) const opened = folder.opened_conversations if (opened.length === 0) return const restoredTabs: TabItemInternal[] = opened.map((oc) => ({ id: makeConversationTabId(oc.agent_type, oc.conversation_id), kind: "conversation" as const, conversationId: oc.conversation_id, agentType: oc.agent_type, title: t("loadingConversation"), isPinned: oc.is_pinned, })) setTabs(restoredTabs) const activeItem = opened.find((oc) => oc.is_active) const target = activeItem ?? opened[0] setActiveTabId( makeConversationTabId(target.agent_type, target.conversation_id) ) }) return () => { cancelled = true } }, [folder, restoredFolderId, t]) // Sync restored active tab to FolderProvider (deferred to avoid // updating parent during child render) const prevRestoredIdRef = useRef(restoredFolderId) useEffect(() => { if (restoredFolderId === prevRestoredIdRef.current) return prevRestoredIdRef.current = restoredFolderId if (!folder || folder.opened_conversations.length === 0) return const opened = folder.opened_conversations const target = opened.find((oc) => oc.is_active) ?? opened[0] selectConversation(target.conversation_id, target.agent_type) }, [restoredFolderId, folder, selectConversation]) // Debounced save to DB const saveTimerRef = useRef | null>(null) const skipSaveRef = useRef(true) // skip saving until first restore completes useEffect(() => { // Skip the initial render and restoration phase if (skipSaveRef.current) { if (restoredFolderId != null) { skipSaveRef.current = false } return } if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } saveTimerRef.current = setTimeout(() => { const items: OpenedConversation[] = rawTabs .filter( (t): t is TabItemInternal & { conversationId: number } => t.conversationId != null ) .map((t, i) => ({ conversation_id: t.conversationId, agent_type: t.agentType, position: i, is_active: t.id === activeTabId, is_pinned: t.isPinned, })) saveFolderOpenedConversations(folderId, items).catch(() => { // Silently ignore save errors }) }, 500) return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current) } } }, [rawTabs, activeTabId, folderId, restoredFolderId]) // 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.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.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 syncFolderContext = useCallback( (tab: TabItem | null) => { if (!tab) { clearSelection() cancelNewConversation() return } if (tab.kind === "conversation" && tab.conversationId != null) { selectConversation(tab.conversationId, tab.agentType) } else if (tab.kind === "new_conversation" && tab.workingDir) { startNewConversation(tab.agentType, tab.workingDir) } }, [ selectConversation, clearSelection, startNewConversation, cancelNewConversation, ] ) const openTab = useCallback( ( conversationId: number, agentType: AgentType, pin = false, title?: string ) => { let activateTabId: string | undefined setTabs((prev) => { const existingIndex = findTabIndexForConversation( prev, 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 } // Resolve title from conversations list (via ref) const resolvedTitle = title ?? conversationsRef.current.find( (c) => c.id === conversationId && c.agent_type === agentType )?.title ?? t("untitledConversation") const tabId = makeConversationTabId(agentType, conversationId) activateTabId = tabId const newTab: TabItemInternal = { id: tabId, kind: "conversation", conversationId, agentType, title: resolvedTitle, isPinned: pin, } if (pin) { return [...prev, newTab] } // Preview (not pinned): replace existing preview tab const previewIndex = prev.findIndex((t) => !t.isPinned) if (previewIndex >= 0) { const updated = [...prev] updated[previewIndex] = newTab return updated } return [...prev, newTab] }) if (activateTabId) { setActiveTabId(activateTabId) } selectConversation(conversationId, agentType) activateConversationPane() }, [activateConversationPane, selectConversation, t] ) const makeReplacementNewConversationTab = useCallback( (preferred?: TabItemInternal): TabItemInternal => ({ id: makeNewConversationTabId(), kind: "new_conversation", agentType: preferred?.agentType ?? "codex", title: t("newConversation"), isPinned: true, workingDir: preferred?.workingDir ?? folder?.path, }), [folder?.path, t] ) const closeTab = useCallback( (tabId: string) => { let neighborToSync: TabItemInternal | undefined 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) { const replacementTab = makeReplacementNewConversationTab(closingTab) neighborToSync = replacementTab return [replacementTab] } // If closing the active tab, pick a neighbor to activate if (tabId === activeTabIdRef.current) { // Prefer right neighbor, then left const newIndex = Math.min(index, next.length - 1) neighborToSync = next[newIndex] } return next }) // Sync folder context outside the updater to avoid // updating FolderProvider state during TabProvider render if (neighborToSync) { setActiveTabId(neighborToSync.id) syncFolderContext(neighborToSync) activateConversationPane() } }, [ activateConversationPane, makeReplacementNewConversationTab, syncFolderContext, ] ) const closeOtherTabs = useCallback( (tabId: string) => { setTabs((prev) => { const kept = prev.filter((t) => t.id === tabId) return kept.length === prev.length ? prev : kept }) const tab = rawTabsRef.current.find((t) => t.id === tabId) if (tab) { setActiveTabId(tabId) syncFolderContext(tab) } }, [syncFolderContext] ) const closeAllTabs = useCallback(() => { const seedTab = rawTabsRef.current.find( (t) => t.kind === "new_conversation" && t.workingDir ) ?? rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ?? rawTabsRef.current[0] const replacementTab = makeReplacementNewConversationTab(seedTab) setTabs([replacementTab]) setActiveTabId(replacementTab.id) syncFolderContext(replacementTab) activateConversationPane() }, [ activateConversationPane, makeReplacementNewConversationTab, syncFolderContext, ]) const switchTab = useCallback( (tabId: string) => { const tab = rawTabsRef.current.find((t) => t.id === tabId) if (!tab) return setActiveTabId(tabId) syncFolderContext(tab) activateConversationPane() }, [activateConversationPane, syncFolderContext] ) const pinTab = useCallback((tabId: string) => { setTabs((prev) => prev.map((t) => (t.id === tabId ? { ...t, isPinned: true } : t)) ) }, []) const reorderTabs = useCallback( (reorderedTabs: TabItem[]) => setTabs(reorderedTabs), [] ) const openNewConversationTab = useCallback( (agentType: AgentType, workingDir: string) => { const existingTab = rawTabsRef.current.find( (t) => t.kind === "new_conversation" && t.agentType === agentType && !t.conversationId ) if (existingTab) { setActiveTabId(existingTab.id) syncFolderContext(existingTab) activateConversationPane() return } const tabId = makeNewConversationTabId() const newTab: TabItemInternal = { id: tabId, kind: "new_conversation", agentType, title: t("newConversation"), isPinned: true, workingDir, } setTabs((prev) => [...prev, newTab]) setActiveTabId(tabId) startNewConversation(agentType, workingDir) activateConversationPane() }, [activateConversationPane, startNewConversation, syncFolderContext, t] ) const linkTabConversation = useCallback( ( tabId: string, conversationId: number, agentType: AgentType, title: string ) => { setTabs((prev) => prev.map((t) => t.id === tabId ? { ...t, conversationId, agentType, title } : t ) ) }, [] ) const promoteNewConversationTab = useCallback( ( tabId: string, conversationId: number, agentType: AgentType, title: string ) => { let activateId: string | undefined setTabs((prev) => { const index = prev.findIndex((t) => t.id === tabId) if (index < 0) return prev const newId = makeConversationTabId(agentType, conversationId) // Check if a *different* tab already represents this conversation const dupeIndex = findTabIndexForConversation( prev, agentType, conversationId ) if (dupeIndex >= 0 && dupeIndex !== index) { activateId = prev[dupeIndex].id return prev.filter((t) => t.id !== tabId) } const promoted: TabItemInternal = { ...prev[index], id: newId, kind: "conversation", conversationId, agentType, title, isPinned: true, } activateId = newId const updated = [...prev] updated[index] = promoted return updated }) if (activateId) { setActiveTabId(activateId) selectConversation(conversationId, agentType) activateConversationPane() } }, [activateConversationPane, selectConversation] ) const value = useMemo( () => ({ tabs, activeTabId, openTab, closeTab, closeOtherTabs, closeAllTabs, switchTab, pinTab, openNewConversationTab, promoteNewConversationTab, linkTabConversation, reorderTabs, }), [ tabs, activeTabId, openTab, closeTab, closeOtherTabs, closeAllTabs, switchTab, pinTab, openNewConversationTab, promoteNewConversationTab, linkTabConversation, reorderTabs, ] ) return {children} }