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:
@@ -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%)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user