refactor(workspace): migrate from per-folder windows to single-window workspace
Replace the legacy folder + welcome routes with a unified /workspace route that hosts all folders, conversations, tabs, and terminals in one window. - Persist opened tabs to the database (opened_tabs entity + migration) so tab layout survives restarts and deep-link bootstrap restores state - Replace FolderContext shim with AppWorkspaceProvider, ActiveFolderProvider, and TabProvider; expose both opened (folders) and full DB (allFolders) listings via list_all_folder_details - Return conversations across all non-deleted folders from list_all when no folder filter is given, so the sidebar can show every folder's history - Add ConversationContextBar above the chat input with folder picker (auto-opens unopened folders on select), branch picker, and commit / push / merge / stash entries to restore BranchDropdown functionality - Rework sidebar with stats header, search, flat / folder-grouped view modes (localStorage-persisted), reveal-in-sidebar event subscriber, and per-folder context menu (focus, close tabs, remove from workspace); indent conversations under folder headers in grouped mode - Gate terminal creation on active folder and show folder context - Remove deprecated BranchDropdown, FolderNameDropdown, welcome route, and per-folder window commands - Localize all new strings across 10 locales
This commit is contained in:
@@ -11,19 +11,16 @@ import {
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { saveFolderOpenedConversations } from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
OpenedConversation,
|
||||
} from "@/lib/types"
|
||||
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
|
||||
@@ -41,21 +38,28 @@ 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: (conversationId: number, agentType: AgentType) => 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: (workingDir: string) => void
|
||||
openNewConversationTab: (folderId: number, workingDir: string) => void
|
||||
bindConversationTab: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
@@ -67,6 +71,7 @@ interface TabContextValue {
|
||||
tabId: string,
|
||||
runtimeConversationId: number
|
||||
) => void
|
||||
setTabFolder: (tabId: string, folderId: number, workingDir: string) => void
|
||||
reorderTabs: (reorderedTabs: TabItem[]) => void
|
||||
onPreviewTabReplaced: (callback: (tabId: string) => void) => () => void
|
||||
}
|
||||
@@ -82,25 +87,31 @@ export function useTabContext() {
|
||||
}
|
||||
|
||||
function makeConversationTabId(
|
||||
folderId: number,
|
||||
agentType: AgentType,
|
||||
conversationId: number
|
||||
): string {
|
||||
return `conv-${agentType}-${conversationId}`
|
||||
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(agentType, conversationId)
|
||||
const canonicalId = makeConversationTabId(folderId, 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
|
||||
(t) =>
|
||||
t.folderId === folderId &&
|
||||
t.conversationId === conversationId &&
|
||||
t.agentType === agentType
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,51 +119,18 @@ 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 {
|
||||
folder,
|
||||
folderId,
|
||||
selectedConversation,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
conversations,
|
||||
} = useFolderContext()
|
||||
const { conversations, folders, setActiveFolderId } = useAppWorkspace()
|
||||
|
||||
const [rawTabs, setTabs] = useState<TabItemInternal[]>(() => {
|
||||
if (selectedConversation) {
|
||||
const tabId = makeConversationTabId(
|
||||
selectedConversation.agentType,
|
||||
selectedConversation.id
|
||||
)
|
||||
return [
|
||||
{
|
||||
id: tabId,
|
||||
kind: "conversation",
|
||||
conversationId: selectedConversation.id,
|
||||
agentType: selectedConversation.agentType,
|
||||
title: t("loadingConversation"),
|
||||
isPinned: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
const [rawTabs, setTabs] = useState<TabItemInternal[]>([])
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null)
|
||||
const [tabsHydrated, setTabsHydrated] = useState(false)
|
||||
|
||||
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
|
||||
// Refs for volatile state
|
||||
const activeTabIdRef = useRef(activeTabId)
|
||||
useEffect(() => {
|
||||
activeTabIdRef.current = activeTabId
|
||||
@@ -163,11 +141,24 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
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(
|
||||
@@ -180,92 +171,75 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[]
|
||||
)
|
||||
|
||||
// Restore tabs from folder.opened_conversations when folder first loads
|
||||
const [restoredFolderId, setRestoredFolderId] = useState<number | null>(() =>
|
||||
selectedConversation ? folderId : null
|
||||
)
|
||||
|
||||
// Hydrate from persisted opened_tabs on mount
|
||||
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",
|
||||
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)
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}, [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])
|
||||
}, [t])
|
||||
|
||||
// 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 (!tabsHydrated) 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,
|
||||
}))
|
||||
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,
|
||||
}))
|
||||
|
||||
saveFolderOpenedConversations(folderId, items).catch(() => {
|
||||
saveOpenedTabs(items).catch(() => {
|
||||
// Silently ignore save errors
|
||||
})
|
||||
}, 500)
|
||||
@@ -275,13 +249,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [rawTabs, activeTabId, folderId, restoredFolderId])
|
||||
}, [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.agent_type}-${c.id}`, c)
|
||||
m.set(`${c.folder_id}-${c.agent_type}-${c.id}`, c)
|
||||
}
|
||||
return m
|
||||
}, [conversations])
|
||||
@@ -292,7 +266,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return rawTabs.map((tab) => {
|
||||
if (tab.conversationId != null) {
|
||||
const conv = conversationMap.get(
|
||||
`${tab.agentType}-${tab.conversationId}`
|
||||
`${tab.folderId}-${tab.agentType}-${tab.conversationId}`
|
||||
)
|
||||
if (conv) {
|
||||
const newTitle = conv.title || t("untitledConversation")
|
||||
@@ -306,36 +280,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
})
|
||||
}, [rawTabs, conversationMap, t])
|
||||
|
||||
const syncFolderContext = useCallback(
|
||||
(tab: TabItem | null) => {
|
||||
if (!tab) {
|
||||
clearSelection()
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
if (tab.conversationId != null) {
|
||||
selectConversation(tab.conversationId, tab.agentType)
|
||||
} else {
|
||||
const workingDir = tab.workingDir ?? folder?.path
|
||||
if (!workingDir) {
|
||||
clearSelection()
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
startNewConversation(workingDir)
|
||||
}
|
||||
},
|
||||
[
|
||||
folder?.path,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
]
|
||||
)
|
||||
|
||||
const openTab = useCallback(
|
||||
(
|
||||
folderId: number,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
pin = false,
|
||||
@@ -347,6 +294,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
setTabs((prev) => {
|
||||
const existingIndex = findTabIndexForConversation(
|
||||
prev,
|
||||
folderId,
|
||||
agentType,
|
||||
conversationId
|
||||
)
|
||||
@@ -364,19 +312,22 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return prev
|
||||
}
|
||||
|
||||
// Resolve title from conversations list (via ref)
|
||||
const resolvedTitle =
|
||||
title ??
|
||||
conversationsRef.current.find(
|
||||
(c) => c.id === conversationId && c.agent_type === agentType
|
||||
(c) =>
|
||||
c.id === conversationId &&
|
||||
c.agent_type === agentType &&
|
||||
c.folder_id === folderId
|
||||
)?.title ??
|
||||
t("untitledConversation")
|
||||
|
||||
const tabId = makeConversationTabId(agentType, conversationId)
|
||||
const tabId = makeConversationTabId(folderId, agentType, conversationId)
|
||||
activateTabId = tabId
|
||||
const newTab: TabItemInternal = {
|
||||
id: tabId,
|
||||
kind: "conversation",
|
||||
folderId,
|
||||
conversationId,
|
||||
agentType,
|
||||
title: resolvedTitle,
|
||||
@@ -387,7 +338,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return [...prev, newTab]
|
||||
}
|
||||
|
||||
// Preview (not pinned): replace existing preview tab
|
||||
const previewIndex = prev.findIndex((t) => !t.isPinned)
|
||||
if (previewIndex >= 0) {
|
||||
replacedPreviewTabId = prev[previewIndex].id
|
||||
@@ -399,7 +349,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return [...prev, newTab]
|
||||
})
|
||||
|
||||
// Notify listeners about the replaced preview tab
|
||||
if (replacedPreviewTabId) {
|
||||
for (const cb of previewReplacedCallbacksRef.current) {
|
||||
cb(replacedPreviewTabId)
|
||||
@@ -409,30 +358,36 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
if (activateTabId) {
|
||||
setActiveTabId(activateTabId)
|
||||
}
|
||||
selectConversation(conversationId, agentType)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, selectConversation, t]
|
||||
[activateConversationPane, t]
|
||||
)
|
||||
|
||||
const makeReplacementDraftTab = useCallback(
|
||||
(preferred?: TabItemInternal): TabItemInternal => ({
|
||||
id: makeNewConversationTabId(),
|
||||
kind: "conversation",
|
||||
conversationId: null,
|
||||
agentType: AGENT_DISPLAY_ORDER[0],
|
||||
title: t("newConversation"),
|
||||
isPinned: true,
|
||||
workingDir: preferred?.workingDir ?? folder?.path,
|
||||
}),
|
||||
[folder?.path, t]
|
||||
(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 tileModeKey = `folder:${folderId}:tile-mode`
|
||||
const [isTileMode, setIsTileMode] = useState(() => {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
return localStorage.getItem(tileModeKey) === "true"
|
||||
return localStorage.getItem(TILE_MODE_STORAGE_KEY) === "true"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -440,15 +395,16 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(tileModeKey, String(isTileMode))
|
||||
localStorage.setItem(TILE_MODE_STORAGE_KEY, String(isTileMode))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [isTileMode, tileModeKey])
|
||||
}, [isTileMode])
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
let neighborToSync: TabItemInternal | undefined
|
||||
let shouldReplaceWithEmpty = false
|
||||
|
||||
setTabs((prev) => {
|
||||
const index = prev.findIndex((t) => t.id === tabId)
|
||||
@@ -458,14 +414,16 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
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 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]
|
||||
}
|
||||
@@ -473,22 +431,26 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return next
|
||||
})
|
||||
|
||||
// Sync folder context outside the updater to avoid
|
||||
// updating FolderProvider state during TabProvider render
|
||||
if (shouldReplaceWithEmpty) {
|
||||
setActiveTabId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (neighborToSync) {
|
||||
setActiveTabId(neighborToSync.id)
|
||||
syncFolderContext(neighborToSync)
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[activateConversationPane, makeReplacementDraftTab, syncFolderContext]
|
||||
[activateConversationPane, makeReplacementDraftTab]
|
||||
)
|
||||
|
||||
const closeConversationTab = useCallback(
|
||||
(conversationId: number, agentType: AgentType) => {
|
||||
(folderId: number, conversationId: number, agentType: AgentType) => {
|
||||
const target = rawTabsRef.current.find(
|
||||
(tab) =>
|
||||
tab.conversationId === conversationId && tab.agentType === agentType
|
||||
tab.folderId === folderId &&
|
||||
tab.conversationId === conversationId &&
|
||||
tab.agentType === agentType
|
||||
)
|
||||
if (!target) return
|
||||
closeTab(target.id)
|
||||
@@ -496,20 +458,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[closeTab]
|
||||
)
|
||||
|
||||
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 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 =
|
||||
@@ -519,12 +474,33 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
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)
|
||||
syncFolderContext(replacementTab)
|
||||
activateConversationPane()
|
||||
}, [activateConversationPane, makeReplacementDraftTab, syncFolderContext])
|
||||
}, [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) => {
|
||||
@@ -532,10 +508,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
if (!tab) return
|
||||
|
||||
setActiveTabId(tabId)
|
||||
syncFolderContext(tab)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, syncFolderContext]
|
||||
[activateConversationPane]
|
||||
)
|
||||
|
||||
const pinTab = useCallback((tabId: string) => {
|
||||
@@ -554,13 +529,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
)
|
||||
|
||||
const openNewConversationTab = useCallback(
|
||||
(workingDir: string) => {
|
||||
(folderId: number, workingDir: string) => {
|
||||
// Reuse existing draft tab for the same folder if present
|
||||
const existingTab = rawTabsRef.current.find(
|
||||
(t) => t.conversationId == null
|
||||
(t) => t.conversationId == null && t.folderId === folderId
|
||||
)
|
||||
|
||||
if (existingTab) {
|
||||
// Update workingDir if it differs from the request
|
||||
if (existingTab.workingDir !== workingDir) {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
@@ -569,7 +544,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
)
|
||||
}
|
||||
setActiveTabId(existingTab.id)
|
||||
syncFolderContext(existingTab)
|
||||
activateConversationPane()
|
||||
return
|
||||
}
|
||||
@@ -579,6 +553,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const newTab: TabItemInternal = {
|
||||
id: tabId,
|
||||
kind: "conversation",
|
||||
folderId,
|
||||
conversationId: null,
|
||||
agentType,
|
||||
title: t("newConversation"),
|
||||
@@ -588,10 +563,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
|
||||
setTabs((prev) => [...prev, newTab])
|
||||
setActiveTabId(tabId)
|
||||
startNewConversation(workingDir)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, startNewConversation, syncFolderContext, t]
|
||||
[activateConversationPane, t]
|
||||
)
|
||||
|
||||
const bindConversationTab = useCallback(
|
||||
@@ -606,7 +580,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
setTabs((prev) =>
|
||||
prev.flatMap((tab) => {
|
||||
if (tab.id === tabId) {
|
||||
const nextTab = {
|
||||
const nextTab: TabItemInternal = {
|
||||
...tab,
|
||||
conversationId,
|
||||
agentType,
|
||||
@@ -617,6 +591,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
}
|
||||
|
||||
if (
|
||||
tab.folderId === tab.folderId &&
|
||||
tab.conversationId === conversationId &&
|
||||
tab.agentType === agentType
|
||||
) {
|
||||
@@ -631,15 +606,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
)
|
||||
if (nextActiveTabId) {
|
||||
setActiveTabId(nextActiveTabId)
|
||||
const target = rawTabsRef.current.find(
|
||||
(tab) => tab.id === nextActiveTabId
|
||||
)
|
||||
if (target) {
|
||||
syncFolderContext(target)
|
||||
}
|
||||
}
|
||||
},
|
||||
[syncFolderContext]
|
||||
[]
|
||||
)
|
||||
|
||||
const setTabRuntimeConversationId = useCallback(
|
||||
@@ -657,40 +626,57 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
[]
|
||||
)
|
||||
|
||||
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,
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user