- Replace the branch badge with a compact count badge; recolor the folder name and badge to sidebar-primary on a tinted row background when expanded - Lighten the selected conversation item background so the expanded folder row stays the strongest signal - Add a "+" button on each folder header that reuses a single new- conversation tab across folders, disconnecting the old ACP session so the connection lifecycle reconnects against the target folder's working directory
712 lines
19 KiB
TypeScript
712 lines
19 KiB
TypeScript
"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<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(
|
|
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<TabItemInternal[]>([])
|
|
const [activeTabId, setActiveTabId] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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<string, (typeof conversations)[number]>()
|
|
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 <TabContext.Provider value={value}>{children}</TabContext.Provider>
|
|
}
|