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"
|
"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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user