Initial commit
This commit is contained in:
632
src/contexts/tab-context.tsx
Normal file
632
src/contexts/tab-context.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
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<TabContextValue | null>(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 { activateConversationPane } = useWorkspaceContext()
|
||||
const {
|
||||
folder,
|
||||
folderId,
|
||||
selectedConversation,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
conversations,
|
||||
} = useFolderContext()
|
||||
|
||||
const [rawTabs, setTabs] = useState<TabItemInternal[]>(() => {
|
||||
if (selectedConversation) {
|
||||
const tabId = makeConversationTabId(
|
||||
selectedConversation.agentType,
|
||||
selectedConversation.id
|
||||
)
|
||||
return [
|
||||
{
|
||||
id: tabId,
|
||||
kind: "conversation" as const,
|
||||
conversationId: selectedConversation.id,
|
||||
agentType: selectedConversation.agentType,
|
||||
title: "Loading...",
|
||||
isPinned: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(() => {
|
||||
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<number | null>(() =>
|
||||
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: "Loading...",
|
||||
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])
|
||||
|
||||
// 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<ReturnType<typeof setTimeout> | 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<string, (typeof conversations)[number]>()
|
||||
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 || "Untitled conversation"
|
||||
const newStatus = conv.status as ConversationStatus | undefined
|
||||
if (tab.title !== newTitle || tab.status !== newStatus) {
|
||||
return { ...tab, title: newTitle, status: newStatus }
|
||||
}
|
||||
}
|
||||
}
|
||||
return tab
|
||||
})
|
||||
}, [rawTabs, conversationMap])
|
||||
|
||||
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 ??
|
||||
"Untitled conversation"
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
const makeReplacementNewConversationTab = useCallback(
|
||||
(preferred?: TabItemInternal): TabItemInternal => ({
|
||||
id: makeNewConversationTabId(),
|
||||
kind: "new_conversation",
|
||||
agentType: preferred?.agentType ?? "codex",
|
||||
title: "New Conversation",
|
||||
isPinned: true,
|
||||
workingDir: preferred?.workingDir ?? folder?.path,
|
||||
}),
|
||||
[folder?.path]
|
||||
)
|
||||
|
||||
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: "New Conversation",
|
||||
isPinned: true,
|
||||
workingDir,
|
||||
}
|
||||
|
||||
setTabs((prev) => [...prev, newTab])
|
||||
setActiveTabId(tabId)
|
||||
startNewConversation(agentType, workingDir)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, startNewConversation, syncFolderContext]
|
||||
)
|
||||
|
||||
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 <TabContext.Provider value={value}>{children}</TabContext.Provider>
|
||||
}
|
||||
Reference in New Issue
Block a user