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:
xintaofei
2026-04-20 21:22:36 +08:00
parent 10801bf393
commit d9323d7399
89 changed files with 3701 additions and 2743 deletions

View File

@@ -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,
]