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:
@@ -1,9 +1,10 @@
|
||||
"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 { toast } from "sonner"
|
||||
import { Check, ChevronsUpDown, Folder, GitBranch, Loader2 } from "lucide-react"
|
||||
import type { OverlayScrollbarsComponentRef } from "overlayscrollbars-react"
|
||||
import { useAppWorkspace } from "@/contexts/app-workspace-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTaskContext } from "@/contexts/task-context"
|
||||
@@ -23,14 +24,21 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ConversationContextBarProps {
|
||||
tabId?: string | null
|
||||
extraContent?: React.ReactNode
|
||||
hasExtraContent?: boolean
|
||||
scrollEndTrigger?: number
|
||||
}
|
||||
|
||||
export const ConversationContextBar = memo(function ConversationContextBar({
|
||||
tabId,
|
||||
extraContent,
|
||||
hasExtraContent = false,
|
||||
scrollEndTrigger,
|
||||
}: ConversationContextBarProps = {}) {
|
||||
const t = useTranslations("Folder.conversationContextBar")
|
||||
const tBd = useTranslations("Folder.branchDropdown")
|
||||
@@ -52,14 +60,59 @@ export const ConversationContextBar = memo(function ConversationContextBar({
|
||||
[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
|
||||
const currentBranch =
|
||||
branches.get(ownFolder.id) ?? ownFolder.git_branch ?? null
|
||||
useEffect(() => {
|
||||
const inner = innerRef.current
|
||||
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 (
|
||||
<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
|
||||
folders={folders}
|
||||
currentFolderId={ownFolder.id}
|
||||
@@ -71,9 +124,14 @@ export const ConversationContextBar = memo(function ConversationContextBar({
|
||||
if (!target) return
|
||||
try {
|
||||
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) {
|
||||
console.error("[ConversationContextBar] switch folder failed:", err)
|
||||
console.error(
|
||||
"[ConversationContextBar] switch folder failed:",
|
||||
err
|
||||
)
|
||||
toast.error(t("toasts.openFolderFailed"))
|
||||
}
|
||||
}}
|
||||
@@ -81,6 +139,7 @@ export const ConversationContextBar = memo(function ConversationContextBar({
|
||||
labelSearch={t("searchFolder")}
|
||||
/>
|
||||
|
||||
{showBranchPicker && (
|
||||
<BranchPicker
|
||||
folderId={ownFolder.id}
|
||||
folderPath={ownFolder.path}
|
||||
@@ -102,7 +161,12 @@ export const ConversationContextBar = memo(function ConversationContextBar({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{extraContent}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1920,11 +1920,12 @@ export function MessageInput({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ConversationContextBar tabId={attachmentTabId} />
|
||||
{(hasImageAttachments || hasResourceAttachments) && (
|
||||
<div className="flex shrink-0 flex-col gap-1 px-2 pt-2">
|
||||
{hasImageAttachments && (
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-0.5">
|
||||
<ConversationContextBar
|
||||
tabId={attachmentTabId}
|
||||
hasExtraContent={hasImageAttachments || hasResourceAttachments}
|
||||
scrollEndTrigger={attachments.length}
|
||||
extraContent={
|
||||
<>
|
||||
{imageAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
@@ -1956,10 +1957,6 @@ export function MessageInput({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasResourceAttachments && (
|
||||
<div className="flex items-center gap-1 overflow-x-auto">
|
||||
{resourceAttachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
@@ -1979,10 +1976,9 @@ export function MessageInput({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
|
||||
Reference in New Issue
Block a user