feat(conversation-context-bar): unify selectors and attachments into a horizontal scrolling row

- Pin folder/branch selectors to the left with a subtle divider so they stay visible while scrolling
- Redirect vertical wheel input to horizontal scroll within the bar
- Auto-scroll to the end when new attachments are appended
This commit is contained in:
xintaofei
2026-04-23 23:36:24 +08:00
parent e80c9e69a9
commit 1148319eba
2 changed files with 169 additions and 109 deletions

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { memo, useCallback, useEffect, useMemo, useState } from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { Check, ChevronsUpDown, Folder, GitBranch, Loader2 } from "lucide-react" import { Check, ChevronsUpDown, Folder, GitBranch, Loader2 } from "lucide-react"
import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react"
import { useAppWorkspace } from "@/contexts/app-workspace-context" import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
import { useTaskContext } from "@/contexts/task-context" import { useTaskContext } from "@/contexts/task-context"
@@ -23,14 +24,21 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command" } from "@/components/ui/command"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface ConversationContextBarProps { interface ConversationContextBarProps {
tabId?: string | null tabId?: string | null
extraContent?: React.ReactNode
hasExtraContent?: boolean
scrollEndTrigger?: number
} }
export const ConversationContextBar = memo(function ConversationContextBar({ export const ConversationContextBar = memo(function ConversationContextBar({
tabId, tabId,
extraContent,
hasExtraContent = false,
scrollEndTrigger,
}: ConversationContextBarProps = {}) { }: ConversationContextBarProps = {}) {
const t = useTranslations("Folder.conversationContextBar") const t = useTranslations("Folder.conversationContextBar")
const tBd = useTranslations("Folder.branchDropdown") const tBd = useTranslations("Folder.branchDropdown")
@@ -52,14 +60,59 @@ export const ConversationContextBar = memo(function ConversationContextBar({
[ownTab, allFolders] [ownTab, allFolders]
) )
if (!ownTab || !ownFolder) return null const scrollRef = useRef<OverlayScrollbarsComponentRef>(null)
const innerRef = useRef<HTMLDivElement>(null)
const prevScrollTriggerRef = useRef<number>(scrollEndTrigger ?? 0)
useEffect(() => {
if (scrollEndTrigger == null) return
if (scrollEndTrigger <= prevScrollTriggerRef.current) {
prevScrollTriggerRef.current = scrollEndTrigger
return
}
prevScrollTriggerRef.current = scrollEndTrigger
requestAnimationFrame(() => {
const viewport = scrollRef.current?.osInstance()?.elements().viewport
if (!viewport) return
viewport.scrollTo({ left: viewport.scrollWidth, behavior: "smooth" })
})
}, [scrollEndTrigger])
const isNewConversation = ownTab.conversationId == null useEffect(() => {
const currentBranch = const inner = innerRef.current
branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null if (!inner) return
const handler = (e: WheelEvent) => {
const viewport = scrollRef.current?.osInstance()?.elements().viewport
if (!viewport) return
if (viewport.scrollWidth <= viewport.clientWidth) return
const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX
if (delta === 0) return
e.preventDefault()
viewport.scrollLeft += delta
}
inner.addEventListener("wheel", handler, { passive: false })
return () => inner.removeEventListener("wheel", handler)
}, [])
const hasSelectors = Boolean(ownTab && ownFolder)
if (!hasSelectors && !hasExtraContent) return null
const isNewConversation = ownTab?.conversationId == null
const currentBranch = ownFolder
? (branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null)
: null
const showBranchPicker = hasSelectors && currentBranch != null
return ( return (
<div className="flex shrink-0 items-center gap-1.5 px-2 pt-2 text-xs text-muted-foreground"> <ScrollArea x="scroll" y="hidden" className="shrink-0" ref={scrollRef}>
<div
ref={innerRef}
className={cn(
"flex w-max items-center gap-1.5 pr-2 pt-2 text-xs text-muted-foreground",
!hasSelectors && "pl-2"
)}
>
{hasSelectors && ownTab && ownFolder && (
<div className="sticky left-0 z-10 flex shrink-0 items-center gap-1.5 border-r border-border/50 bg-background pl-2 pr-2">
<FolderPicker <FolderPicker
folders={folders} folders={folders}
currentFolderId={ownFolder.id} currentFolderId={ownFolder.id}
@@ -71,9 +124,14 @@ export const ConversationContextBar = memo(function ConversationContextBar({
if (!target) return if (!target) return
try { try {
setTabFolder(ownTab.id, target.id, target.path) setTabFolder(ownTab.id, target.id, target.path)
toast.success(t("toasts.folderChanged", { name: target.name })) toast.success(
t("toasts.folderChanged", { name: target.name })
)
} catch (err) { } catch (err) {
console.error("[ConversationContextBar] switch folder failed:", err) console.error(
"[ConversationContextBar] switch folder failed:",
err
)
toast.error(t("toasts.openFolderFailed")) toast.error(t("toasts.openFolderFailed"))
} }
}} }}
@@ -81,6 +139,7 @@ export const ConversationContextBar = memo(function ConversationContextBar({
labelSearch={t("searchFolder")} labelSearch={t("searchFolder")}
/> />
{showBranchPicker && (
<BranchPicker <BranchPicker
folderId={ownFolder.id} folderId={ownFolder.id}
folderPath={ownFolder.path} folderPath={ownFolder.path}
@@ -102,7 +161,12 @@ export const ConversationContextBar = memo(function ConversationContextBar({
} }
}} }}
/> />
)}
</div> </div>
)}
{extraContent}
</div>
</ScrollArea>
) )
}) })

View File

@@ -1920,11 +1920,12 @@ export function MessageInput({
className className
)} )}
> >
<ConversationContextBar tabId={attachmentTabId} /> <ConversationContextBar
{(hasImageAttachments || hasResourceAttachments) && ( tabId={attachmentTabId}
<div className="flex shrink-0 flex-col gap-1 px-2 pt-2"> hasExtraContent={hasImageAttachments || hasResourceAttachments}
{hasImageAttachments && ( scrollEndTrigger={attachments.length}
<div className="flex items-center gap-1 overflow-x-auto pb-0.5"> extraContent={
<>
{imageAttachments.map((attachment) => ( {imageAttachments.map((attachment) => (
<div <div
key={attachment.id} key={attachment.id}
@@ -1956,10 +1957,6 @@ export function MessageInput({
</button> </button>
</div> </div>
))} ))}
</div>
)}
{hasResourceAttachments && (
<div className="flex items-center gap-1 overflow-x-auto">
{resourceAttachments.map((attachment) => ( {resourceAttachments.map((attachment) => (
<div <div
key={attachment.id} key={attachment.id}
@@ -1979,10 +1976,9 @@ export function MessageInput({
</button> </button>
</div> </div>
))} ))}
</div> </>
)} }
</div> />
)}
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={text} value={text}