refactor(sidebar): highlight expanded folder and add per-folder new-conversation button

- 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
This commit is contained in:
xintaofei
2026-04-22 01:01:18 +08:00
parent f3bdf94723
commit 72b8817bb2
3 changed files with 107 additions and 46 deletions

View File

@@ -145,7 +145,7 @@ export const SidebarConversationCard = memo(function SidebarConversationCard({
"transition-colors duration-[120ms]", "transition-colors duration-[120ms]",
"pr-[0.5rem] pl-7", "pr-[0.5rem] pl-7",
isSelected isSelected
? "bg-sidebar-primary/15" ? "bg-sidebar-primary/8"
: "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]" : "hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
)} )}
> >

View File

@@ -31,7 +31,6 @@ import {
saveFolderExpanded, saveFolderExpanded,
} from "@/lib/sidebar-view-mode-storage" } from "@/lib/sidebar-view-mode-storage"
import { SidebarConversationCard } from "./sidebar-conversation-card" import { SidebarConversationCard } from "./sidebar-conversation-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -96,7 +95,6 @@ type FlatItem =
type: "folder_header" type: "folder_header"
folderId: number folderId: number
folderName: string folderName: string
branch: string | null
count: number count: number
expanded: boolean expanded: boolean
} }
@@ -107,70 +105,102 @@ const CARD_HEIGHT_REM = 2
const FolderHeader = memo(function FolderHeader({ const FolderHeader = memo(function FolderHeader({
folderId, folderId,
folderName, folderName,
branch,
count, count,
expanded, expanded,
onToggle, onToggle,
onFocus, onFocus,
onCloseFolderTabs, onCloseFolderTabs,
onRemoveFromWorkspace, onRemoveFromWorkspace,
onNewConversation,
t, t,
}: { }: {
folderId: number folderId: number
folderName: string folderName: string
branch: string | null
count: number count: number
expanded: boolean expanded: boolean
onToggle: (folderId: number) => void onToggle: (folderId: number) => void
onFocus: (folderId: number) => void onFocus: (folderId: number) => void
onCloseFolderTabs: (folderId: number) => void onCloseFolderTabs: (folderId: number) => void
onRemoveFromWorkspace: (folderId: number) => void onRemoveFromWorkspace: (folderId: number) => void
onNewConversation: (folderId: number) => void
t: ReturnType<typeof useTranslations> t: ReturnType<typeof useTranslations>
}) { }) {
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="relative h-[2rem]"> <div className="relative h-[2rem]">
<button <div
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn( className={cn(
"flex h-[1.9375rem] w-full items-center gap-[0.5rem] cursor-pointer outline-none", "flex h-[1.9375rem] w-full items-center",
"rounded-[0.4375rem] px-2", "rounded-[0.4375rem] border",
"text-sidebar-foreground hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]", "transition-[background-color,color,border-color] duration-150",
"transition-[background-color,color] duration-150" expanded
? "bg-sidebar-primary/15 border-sidebar-primary/25"
: "border-transparent hover:bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_2%)]"
)} )}
> >
<span <button
data-folder-id={folderId}
onClick={() => onToggle(folderId)}
className={cn( className={cn(
"flex h-[0.75rem] w-[0.75rem] shrink-0 items-center justify-center text-muted-foreground/75", "flex h-full min-w-0 flex-1 items-center gap-[0.5rem] px-2 cursor-pointer outline-none",
"transition-transform duration-[180ms] [transition-timing-function:cubic-bezier(.3,.7,.3,1)]", "text-sidebar-foreground"
expanded ? "rotate-90" : "rotate-0"
)} )}
> >
<ChevronRight className="h-[0.625rem] w-[0.625rem]" /> <span
</span> className={cn(
<div className="flex min-w-0 flex-1 items-center gap-[0.375rem]"> "flex h-[0.75rem] w-[0.75rem] shrink-0 items-center justify-center text-muted-foreground/75",
<span className="min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]"> "transition-transform duration-[180ms] [transition-timing-function:cubic-bezier(.3,.7,.3,1)]",
{folderName} expanded ? "rotate-90" : "rotate-0"
)}
>
<ChevronRight className="h-[0.625rem] w-[0.625rem]" />
</span> </span>
{branch && ( <div className="flex min-w-0 flex-1 items-center gap-[0.375rem]">
<Badge <span
variant="outline"
className={cn( className={cn(
"h-[1rem] max-w-[6.875rem] gap-0 px-[0.375rem] py-0", "min-w-0 flex-shrink truncate text-left text-[0.875rem] font-semibold tracking-[-0.00625rem]",
"text-[0.6875rem] font-medium leading-none tracking-[0.0125rem]", "transition-colors duration-150",
"border-sidebar-border text-muted-foreground/80" expanded && "text-sidebar-primary"
)} )}
> >
<span className="truncate">{branch}</span> {folderName}
</Badge> </span>
<span
className={cn(
"inline-flex shrink-0 items-center justify-center",
"h-[0.9375rem] min-w-[1rem] rounded-[0.3125rem] px-[0.25rem]",
"text-[0.625rem] font-semibold leading-none tabular-nums",
"transition-colors duration-150",
expanded
? "bg-sidebar-primary/20 text-sidebar-primary"
: "bg-[color-mix(in_oklab,var(--sidebar-accent),var(--sidebar-foreground)_6%)] text-muted-foreground/80"
)}
>
{count}
</span>
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onNewConversation(folderId)
}}
title={t("newConversation")}
aria-label={t("newConversation")}
className={cn(
"mr-[0.25rem] flex h-[1.25rem] w-[1.25rem] shrink-0 items-center justify-center",
"rounded-[0.25rem] cursor-pointer outline-none text-muted-foreground/80",
"transition-colors duration-150",
expanded
? "hover:text-sidebar-primary"
: "hover:text-sidebar-foreground"
)} )}
</div> >
<span className="shrink-0 text-[0.75rem] font-medium tabular-nums text-muted-foreground/70"> <Plus className="h-[0.75rem] w-[0.75rem]" />
{count} </button>
</span> </div>
</button>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
@@ -227,7 +257,6 @@ export function SidebarConversationList({
conversationsError: error, conversationsError: error,
refreshConversations, refreshConversations,
updateConversationLocal, updateConversationLocal,
branches,
removeFolderFromWorkspace, removeFolderFromWorkspace,
} = useAppWorkspace() } = useAppWorkspace()
const refreshing = loading const refreshing = loading
@@ -319,13 +348,11 @@ export function SidebarConversationList({
for (const folderId of orderedFolderIds) { for (const folderId of orderedFolderIds) {
const list = byFolder.get(folderId) ?? [] const list = byFolder.get(folderId) ?? []
const folderName = folderIndex.get(folderId)?.name ?? String(folderId) const folderName = folderIndex.get(folderId)?.name ?? String(folderId)
const branch = branches.get(folderId) ?? null
const expanded = folderExpanded[folderId] ?? true const expanded = folderExpanded[folderId] ?? true
items.push({ items.push({
type: "folder_header", type: "folder_header",
folderId, folderId,
folderName, folderName,
branch,
count: list.length, count: list.length,
expanded, expanded,
}) })
@@ -335,7 +362,7 @@ export function SidebarConversationList({
} }
} }
return items return items
}, [orderedFolderIds, byFolder, folderIndex, branches, folderExpanded]) }, [orderedFolderIds, byFolder, folderIndex, folderExpanded])
const stickyState = useMemo<{ const stickyState = useMemo<{
folder: Extract<FlatItem, { type: "folder_header" }> | null folder: Extract<FlatItem, { type: "folder_header" }> | null
@@ -559,6 +586,15 @@ export function SidebarConversationList({
openNewConversationTab(activeFolder.id, activeFolder.path) openNewConversationTab(activeFolder.id, activeFolder.path)
}, [activeFolder, openNewConversationTab]) }, [activeFolder, openNewConversationTab])
const handleNewConversationForFolder = useCallback(
(folderId: number) => {
const folder = folderIndex.get(folderId)
if (!folder) return
openNewConversationTab(folderId, folder.path)
},
[folderIndex, openNewConversationTab]
)
const handleImport = useCallback(async () => { const handleImport = useCallback(async () => {
if (importing) return if (importing) return
if (!activeFolder) return if (!activeFolder) return
@@ -678,13 +714,13 @@ export function SidebarConversationList({
key={`sticky-${stickyFolderItem.folderId}`} key={`sticky-${stickyFolderItem.folderId}`}
folderId={stickyFolderItem.folderId} folderId={stickyFolderItem.folderId}
folderName={stickyFolderItem.folderName} folderName={stickyFolderItem.folderName}
branch={stickyFolderItem.branch}
count={stickyFolderItem.count} count={stickyFolderItem.count}
expanded={stickyFolderItem.expanded} expanded={stickyFolderItem.expanded}
onToggle={toggleFolder} onToggle={toggleFolder}
onFocus={focusFolder} onFocus={focusFolder}
onCloseFolderTabs={handleCloseFolderTabs} onCloseFolderTabs={handleCloseFolderTabs}
onRemoveFromWorkspace={handleRemoveFolder} onRemoveFromWorkspace={handleRemoveFolder}
onNewConversation={handleNewConversationForFolder}
t={t} t={t}
/> />
</div> </div>
@@ -708,13 +744,13 @@ export function SidebarConversationList({
key={`folder-${item.folderId}`} key={`folder-${item.folderId}`}
folderId={item.folderId} folderId={item.folderId}
folderName={item.folderName} folderName={item.folderName}
branch={item.branch}
count={item.count} count={item.count}
expanded={item.expanded} expanded={item.expanded}
onToggle={toggleFolder} onToggle={toggleFolder}
onFocus={focusFolder} onFocus={focusFolder}
onCloseFolderTabs={handleCloseFolderTabs} onCloseFolderTabs={handleCloseFolderTabs}
onRemoveFromWorkspace={handleRemoveFolder} onRemoveFromWorkspace={handleRemoveFolder}
onNewConversation={handleNewConversationForFolder}
t={t} t={t}
/> />
) )

View File

@@ -12,6 +12,7 @@ import {
} from "react" } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useAcpActions } from "@/contexts/acp-connections-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { listOpenedTabs, saveOpenedTabs } from "@/lib/api" import { listOpenedTabs, saveOpenedTabs } from "@/lib/api"
import type { AgentType, ConversationStatus, OpenedTab } from "@/lib/types" import type { AgentType, ConversationStatus, OpenedTab } from "@/lib/types"
@@ -125,6 +126,7 @@ export function TabProvider({ children }: TabProviderProps) {
const t = useTranslations("Folder.tabContext") const t = useTranslations("Folder.tabContext")
const { activateConversationPane } = useWorkspaceContext() const { activateConversationPane } = useWorkspaceContext()
const { conversations, folders, setActiveFolderId } = useAppWorkspace() const { conversations, folders, setActiveFolderId } = useAppWorkspace()
const { disconnect: acpDisconnect } = useAcpActions()
const [rawTabs, setTabs] = useState<TabItemInternal[]>([]) const [rawTabs, setTabs] = useState<TabItemInternal[]>([])
const [activeTabId, setActiveTabId] = useState<string | null>(null) const [activeTabId, setActiveTabId] = useState<string | null>(null)
@@ -530,21 +532,44 @@ export function TabProvider({ children }: TabProviderProps) {
const openNewConversationTab = useCallback( const openNewConversationTab = useCallback(
(folderId: number, workingDir: string) => { (folderId: number, workingDir: string) => {
// Reuse existing draft tab for the same folder if present // 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( const existingTab = rawTabsRef.current.find(
(t) => t.conversationId == null && t.folderId === folderId (t) => t.conversationId == null
) )
if (existingTab) { if (existingTab) {
if (existingTab.workingDir !== workingDir) { 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) => setTabs((prev) =>
prev.map((t) => prev.map((t) =>
t.id === existingTab.id ? { ...t, workingDir } : t t.id === existingTab.id ? { ...t, workingDir } : t
) )
) )
} }
setActiveTabId(existingTab.id)
activateConversationPane()
return return
} }
@@ -565,7 +590,7 @@ export function TabProvider({ children }: TabProviderProps) {
setActiveTabId(tabId) setActiveTabId(tabId)
activateConversationPane() activateConversationPane()
}, },
[activateConversationPane, t] [acpDisconnect, activateConversationPane, t]
) )
const bindConversationTab = useCallback( const bindConversationTab = useCallback(