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,57 +60,113 @@ 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}>
<FolderPicker <div
folders={folders} ref={innerRef}
currentFolderId={ownFolder.id} className={cn(
currentFolderName={ownFolder.name} "flex w-max items-center gap-1.5 pr-2 pt-2 text-xs text-muted-foreground",
title={`${t("folderTitle")}: ${ownFolder.name}`} !hasSelectors && "pl-2"
editable={isNewConversation} )}
onSelect={async (folderId) => { >
const target = folders.find((f) => f.id === folderId) {hasSelectors && ownTab && ownFolder && (
if (!target) return <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">
try { <FolderPicker
setTabFolder(ownTab.id, target.id, target.path) folders={folders}
toast.success(t("toasts.folderChanged", { name: target.name })) currentFolderId={ownFolder.id}
} catch (err) { currentFolderName={ownFolder.name}
console.error("[ConversationContextBar] switch folder failed:", err) title={`${t("folderTitle")}: ${ownFolder.name}`}
toast.error(t("toasts.openFolderFailed")) editable={isNewConversation}
} onSelect={async (folderId) => {
}} const target = folders.find((f) => f.id === folderId)
labelEmpty={t("noFolders")} if (!target) return
labelSearch={t("searchFolder")} try {
/> setTabFolder(ownTab.id, target.id, target.path)
toast.success(
t("toasts.folderChanged", { name: target.name })
)
} catch (err) {
console.error(
"[ConversationContextBar] switch folder failed:",
err
)
toast.error(t("toasts.openFolderFailed"))
}
}}
labelEmpty={t("noFolders")}
labelSearch={t("searchFolder")}
/>
<BranchPicker {showBranchPicker && (
folderId={ownFolder.id} <BranchPicker
folderPath={ownFolder.path} folderId={ownFolder.id}
currentBranch={currentBranch} folderPath={ownFolder.path}
title={`${t("branchTitle")}: ${currentBranch ?? t("noBranch")}`} currentBranch={currentBranch}
onCheckout={async (branchName) => { title={`${t("branchTitle")}: ${currentBranch ?? t("noBranch")}`}
const taskId = `checkout-${ownFolder.id}-${Date.now()}` onCheckout={async (branchName) => {
addTask(taskId, tBd("tasks.checkoutTo", { branchName })) const taskId = `checkout-${ownFolder.id}-${Date.now()}`
updateTask(taskId, { status: "running" }) addTask(taskId, tBd("tasks.checkoutTo", { branchName }))
try { updateTask(taskId, { status: "running" })
await gitCheckout(ownFolder.path, branchName) try {
setBranch(ownFolder.id, branchName) await gitCheckout(ownFolder.path, branchName)
await refreshFolder(ownFolder.id) setBranch(ownFolder.id, branchName)
updateTask(taskId, { status: "completed" }) await refreshFolder(ownFolder.id)
} catch (err) { updateTask(taskId, { status: "completed" })
const msg = err instanceof Error ? err.message : String(err) } catch (err) {
updateTask(taskId, { status: "failed", error: msg }) const msg = err instanceof Error ? err.message : String(err)
toast.error(msg) updateTask(taskId, { status: "failed", error: msg })
} toast.error(msg)
}} }
/> }}
</div> />
)}
</div>
)}
{extraContent}
</div>
</ScrollArea>
) )
}) })

View File

@@ -1920,69 +1920,65 @@ 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) => ( <>
<div {imageAttachments.map((attachment) => (
key={attachment.id} <div
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30" key={attachment.id}
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
>
<button
type="button"
onClick={() => setPreviewAttachmentId(attachment.id)}
className="cursor-pointer transition-opacity hover:opacity-80"
> >
<button <Image
type="button" src={`data:${attachment.mimeType};base64,${attachment.data}`}
onClick={() => setPreviewAttachmentId(attachment.id)} alt={attachment.name}
className="cursor-pointer transition-opacity hover:opacity-80" width={56}
> height={56}
<Image unoptimized
src={`data:${attachment.mimeType};base64,${attachment.data}`} className="h-14 w-14 object-cover"
alt={attachment.name} />
width={56} </button>
height={56} <button
unoptimized type="button"
className="h-14 w-14 object-cover" onClick={() => removeAttachment(attachment.id)}
/> className="absolute right-1 top-1 rounded-sm bg-background/70 p-0.5 hover:bg-background"
</button> aria-label={t("removeAttachmentAria", {
<button name: attachment.name,
type="button" })}
onClick={() => removeAttachment(attachment.id)}
className="absolute right-1 top-1 rounded-sm bg-background/70 p-0.5 hover:bg-background"
aria-label={t("removeAttachmentAria", {
name: attachment.name,
})}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{hasResourceAttachments && (
<div className="flex items-center gap-1 overflow-x-auto">
{resourceAttachments.map((attachment) => (
<div
key={attachment.id}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
> >
<FileSearch className="h-3 w-3" /> <X className="h-3 w-3" />
<span className="max-w-40 truncate">{attachment.name}</span> </button>
<button </div>
type="button" ))}
onClick={() => removeAttachment(attachment.id)} {resourceAttachments.map((attachment) => (
className="rounded-sm p-0.5 hover:bg-muted-foreground/15" <div
aria-label={t("removeAttachmentAria", { key={attachment.id}
name: attachment.name, className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
})} >
> <FileSearch className="h-3 w-3" />
<X className="h-3 w-3" /> <span className="max-w-40 truncate">{attachment.name}</span>
</button> <button
</div> type="button"
))} onClick={() => removeAttachment(attachment.id)}
</div> className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
)} aria-label={t("removeAttachmentAria", {
</div> name: attachment.name,
)} })}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</>
}
/>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
value={text} value={text}