refactor: reuse context bar blocks for AI chat transcripts
This commit is contained in:
parent
2c6f947cd4
commit
231b2ce083
|
|
@ -17,13 +17,14 @@ const BlockContainer: FC<{
|
|||
disabled?: boolean
|
||||
onDisableClick?: () => void
|
||||
content: ReactNode
|
||||
}> = memo(({ icon, label, onRemove, content, disabled, onDisableClick }) => {
|
||||
readOnly?: boolean
|
||||
}> = memo(({ icon, label, onRemove, content, disabled, onDisableClick, readOnly }) => {
|
||||
const isStringContent = typeof content === "string"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative flex h-7 min-w-0 max-w-[calc(50%-0.5rem)] flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg px-2.5",
|
||||
"group relative flex h-7 min-w-0 flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg px-2.5",
|
||||
"bg-fill-tertiary border-border border",
|
||||
disabled && "cursor-pointer border-dashed opacity-50",
|
||||
)}
|
||||
|
|
@ -36,7 +37,8 @@ const BlockContainer: FC<{
|
|||
<div
|
||||
className={clsx(
|
||||
"min-w-0",
|
||||
!disabled &&
|
||||
!readOnly &&
|
||||
!disabled &&
|
||||
"group-hover:[mask-image:linear-gradient(to_right,black_0%,black_calc(100%-3rem),rgba(0,0,0,0.8)_calc(100%-2rem),rgba(0,0,0,0.3)_calc(100%-1rem),transparent_100%)]",
|
||||
)}
|
||||
>
|
||||
|
|
@ -54,7 +56,7 @@ const BlockContainer: FC<{
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{onRemove && !disabled && (
|
||||
{onRemove && !disabled && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
|
|
@ -76,7 +78,8 @@ export const CombinedContextBlock: FC<{
|
|||
viewBlock?: MainViewBlock
|
||||
feedBlock?: MainFeedBlock
|
||||
unreadOnlyBlock?: UnreadOnlyBlock
|
||||
}> = memo(({ viewBlock, feedBlock, unreadOnlyBlock }) => {
|
||||
readOnly?: boolean
|
||||
}> = memo(({ viewBlock, feedBlock, unreadOnlyBlock, readOnly = false }) => {
|
||||
const { t } = useTranslation("common")
|
||||
const blockActions = useChatBlockActions()
|
||||
|
||||
|
|
@ -89,11 +92,16 @@ export const CombinedContextBlock: FC<{
|
|||
unreadOnlyBlock && blockActions.removeBlock(unreadOnlyBlock.id)
|
||||
}
|
||||
|
||||
const handleEnable = () => {
|
||||
viewBlock && blockActions.toggleBlockDisabled(viewBlock.id, false)
|
||||
feedBlock && blockActions.toggleBlockDisabled(feedBlock.id, false)
|
||||
}
|
||||
|
||||
// Determine what to display
|
||||
const displayContent = feedBlock ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} />
|
||||
{unreadOnlyBlock && <i className="i-mgc-round-cute-fi size-3" title="Unread Only" />}
|
||||
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} className="min-w-0 truncate" />
|
||||
{unreadOnlyBlock && <i className="i-mgc-round-cute-fi size-3 shrink-0" title="Unread Only" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
@ -109,41 +117,42 @@ export const CombinedContextBlock: FC<{
|
|||
return (
|
||||
<BlockContainer
|
||||
icon={viewIcon || feedIcon}
|
||||
disabled={viewBlock?.disabled || feedBlock?.disabled}
|
||||
onRemove={handleRemove}
|
||||
onDisableClick={() => {
|
||||
viewBlock && blockActions.toggleBlockDisabled(viewBlock.id, false)
|
||||
feedBlock && blockActions.toggleBlockDisabled(feedBlock.id, false)
|
||||
}}
|
||||
disabled={viewBlock?.disabled || feedBlock?.disabled || unreadOnlyBlock?.disabled}
|
||||
onRemove={!readOnly ? handleRemove : undefined}
|
||||
onDisableClick={!readOnly ? handleEnable : undefined}
|
||||
content={displayContent}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CombinedContextBlock.displayName = "CombinedContextBlock"
|
||||
|
||||
export const ContextBlock: FC<{ block: AIChatContextBlock }> = memo(({ block }) => {
|
||||
const blockActions = useChatBlockActions()
|
||||
export const ContextBlock: FC<{ block: AIChatContextBlock; readOnly?: boolean }> = memo(
|
||||
({ block, readOnly }) => {
|
||||
const blockActions = useChatBlockActions()
|
||||
|
||||
const { icon, label, displayContent } = useContextBlockPresentation(block)
|
||||
const { icon, label, displayContent } = useContextBlockPresentation(block)
|
||||
|
||||
return (
|
||||
<BlockContainer
|
||||
icon={icon}
|
||||
label={label}
|
||||
disabled={block.disabled}
|
||||
onRemove={() => {
|
||||
if (block.type === "mainEntry") {
|
||||
blockActions.toggleBlockDisabled(block.id, true)
|
||||
} else {
|
||||
blockActions.removeBlock(block.id)
|
||||
}
|
||||
}}
|
||||
onDisableClick={() => {
|
||||
if (block.type === "mainEntry") {
|
||||
blockActions.toggleBlockDisabled(block.id, false)
|
||||
}
|
||||
}}
|
||||
content={displayContent}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<BlockContainer
|
||||
icon={icon}
|
||||
label={label}
|
||||
disabled={block.disabled}
|
||||
onRemove={() => {
|
||||
if (block.type === "mainEntry") {
|
||||
blockActions.toggleBlockDisabled(block.id, true)
|
||||
} else {
|
||||
blockActions.removeBlock(block.id)
|
||||
}
|
||||
}}
|
||||
onDisableClick={() => {
|
||||
if (block.type === "mainEntry") {
|
||||
blockActions.toggleBlockDisabled(block.id, false)
|
||||
}
|
||||
}}
|
||||
content={displayContent}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ export const EntryTitle: FC<{ entryId?: string; fallback: string }> = ({ entryId
|
|||
return <span>{entryTitle}</span>
|
||||
}
|
||||
|
||||
export const FeedTitle: FC<{ feedId?: string; fallback: string }> = ({ feedId, fallback }) => {
|
||||
export const FeedTitle: FC<{ feedId?: string; fallback: string; className?: string }> = ({
|
||||
feedId,
|
||||
fallback,
|
||||
className,
|
||||
}) => {
|
||||
const category = feedId?.startsWith(ROUTE_FEED_IN_FOLDER)
|
||||
? feedId.slice(ROUTE_FEED_IN_FOLDER.length)
|
||||
: undefined
|
||||
|
|
@ -24,11 +28,11 @@ export const FeedTitle: FC<{ feedId?: string; fallback: string }> = ({ feedId, f
|
|||
|
||||
if (!feedId || !feedTitles) {
|
||||
if (category) {
|
||||
return <span>{category}</span>
|
||||
return <span className={className}>{category}</span>
|
||||
}
|
||||
|
||||
return <span className="text-text-tertiary">{fallback}</span>
|
||||
return <span className={`text-text-tertiary ${className}`}>{fallback}</span>
|
||||
}
|
||||
|
||||
return <span>{feedTitles}</span>
|
||||
return <span className={className}>{feedTitles}</span>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,227 +0,0 @@
|
|||
import { getView } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { t } from "i18next"
|
||||
import * as React from "react"
|
||||
|
||||
import type { AIChatContextBlock, ValueContextBlock } from "~/modules/ai-chat/store/types"
|
||||
|
||||
import { getBlockStyles } from "./ai-block-constants"
|
||||
import { FeedTitle } from "./BlockTitleComponents"
|
||||
import { ImageThumbnail } from "./ImageThumbnail"
|
||||
import { useContextBlockPresentation } from "./useContextBlockPresentation"
|
||||
|
||||
interface AIDataBlockItemProps {
|
||||
block: AIChatContextBlock
|
||||
index: number
|
||||
}
|
||||
|
||||
type ValueBlockOf<Type extends ValueContextBlock["type"]> = Omit<ValueContextBlock, "type"> & {
|
||||
type: Type
|
||||
}
|
||||
|
||||
interface CombinedDataBlockItemProps {
|
||||
viewBlock?: ValueBlockOf<"mainView">
|
||||
feedBlock?: ValueBlockOf<"mainFeed">
|
||||
unreadOnlyBlock?: ValueBlockOf<"unreadOnly">
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display content for a context block
|
||||
*/
|
||||
|
||||
/**
|
||||
* Renders the appropriate icon or image thumbnail for a block
|
||||
*/
|
||||
const BlockIcon: React.FC<{
|
||||
block: AIChatContextBlock
|
||||
styles: ReturnType<typeof getBlockStyles>
|
||||
presentation: ReturnType<typeof useContextBlockPresentation>
|
||||
}> = React.memo(({ block, styles, presentation }) => {
|
||||
if (
|
||||
block.type === "fileAttachment" &&
|
||||
presentation.attachment &&
|
||||
presentation.isImageAttachment
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex size-4 flex-shrink-0 items-center justify-center rounded", styles.icon)}
|
||||
>
|
||||
<ImageThumbnail attachment={presentation.attachment} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const iconClass = presentation.icon
|
||||
|
||||
if (!iconClass) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex size-4 flex-shrink-0 items-center justify-center rounded", styles.icon)}
|
||||
>
|
||||
<i className={cn("size-2.5", iconClass)} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
BlockIcon.displayName = "BlockIcon"
|
||||
|
||||
/**
|
||||
* Shared block container component to ensure consistency
|
||||
*/
|
||||
const BlockContainer: React.FC<{
|
||||
styles: ReturnType<typeof getBlockStyles>
|
||||
block: AIChatContextBlock
|
||||
label?: string
|
||||
displayContent: React.ReactNode
|
||||
title?: string
|
||||
presentation: ReturnType<typeof useContextBlockPresentation>
|
||||
}> = React.memo(({ styles, block, label, displayContent, title, presentation }) => {
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1.5 py-0.5",
|
||||
"border bg-gradient-to-r backdrop-blur-sm",
|
||||
styles.container,
|
||||
)}
|
||||
>
|
||||
<BlockIcon block={block} styles={styles} presentation={presentation} />
|
||||
|
||||
{/* Label and content */}
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{label && (
|
||||
<>
|
||||
<span className={cn("text-xs font-medium", styles.label)}>{label}</span>
|
||||
<span className="text-text-secondary text-xs">·</span>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className="text-text max-w-24 truncate text-xs font-medium"
|
||||
title={title || (typeof displayContent === "string" ? displayContent : undefined)}
|
||||
>
|
||||
{displayContent}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
BlockContainer.displayName = "BlockContainer"
|
||||
|
||||
/**
|
||||
* Individual block item component with optimized rendering and animations
|
||||
*/
|
||||
export const AIDataBlockItem: React.FC<AIDataBlockItemProps> = React.memo(({ block }) => {
|
||||
const styles = React.useMemo(() => getBlockStyles(block.type), [block.type])
|
||||
const presentation = useContextBlockPresentation(block)
|
||||
|
||||
return (
|
||||
<BlockContainer
|
||||
styles={styles}
|
||||
block={block}
|
||||
label={presentation.label}
|
||||
displayContent={presentation.displayContent}
|
||||
title={presentation.title}
|
||||
presentation={presentation}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
AIDataBlockItem.displayName = "AIDataBlockItem"
|
||||
|
||||
/**
|
||||
* Combined block item component for main view with optional feed and unread filter
|
||||
* Displays view icon with appropriate content based on available blocks
|
||||
*/
|
||||
export const CombinedDataBlockItem: React.FC<CombinedDataBlockItemProps> = React.memo(
|
||||
({ viewBlock, feedBlock, unreadOnlyBlock }) => {
|
||||
const { styles, displayContent, title, block } = React.useMemo(() => {
|
||||
const unreadOnlyText = unreadOnlyBlock ? " (Unread Only)" : ""
|
||||
const unreadIcon = unreadOnlyBlock && (
|
||||
<i className="i-mgc-round-cute-fi size-3" title="Unread Only" />
|
||||
)
|
||||
|
||||
// Helper function to create content with optional unread indicator
|
||||
const createContent = (mainContent: React.ReactNode) => (
|
||||
<span className="flex items-center gap-1">
|
||||
{mainContent}
|
||||
{unreadIcon}
|
||||
</span>
|
||||
)
|
||||
|
||||
// Handle case when viewBlock is null/undefined
|
||||
if (!viewBlock) {
|
||||
// Prioritize feedBlock if available
|
||||
if (feedBlock) {
|
||||
return {
|
||||
styles: getBlockStyles("mainFeed"),
|
||||
displayContent: createContent(
|
||||
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} />,
|
||||
),
|
||||
title: `${feedBlock.value}${unreadOnlyText}`,
|
||||
block: feedBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle unreadOnly-only case
|
||||
if (unreadOnlyBlock) {
|
||||
return {
|
||||
styles: getBlockStyles("unreadOnly"),
|
||||
displayContent: createContent("Unread Only"),
|
||||
title: "Unread Only",
|
||||
block: unreadOnlyBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback case
|
||||
const fallbackBlock = { id: "fallback", type: "mainView" as const, value: "" }
|
||||
return {
|
||||
styles: getBlockStyles("mainView"),
|
||||
displayContent: createContent("No context"),
|
||||
title: "No context",
|
||||
block: fallbackBlock,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case when viewBlock exists
|
||||
const view = getView(Number(viewBlock.value))
|
||||
const viewName = view?.name ? t(view.name, { ns: "common" }) : viewBlock.value
|
||||
|
||||
// Determine primary content: feedBlock takes precedence over viewBlock
|
||||
const primaryContent = feedBlock ? (
|
||||
<FeedTitle feedId={feedBlock.value} fallback={feedBlock.value} />
|
||||
) : (
|
||||
viewName
|
||||
)
|
||||
|
||||
const titleText = feedBlock
|
||||
? `${viewName} - ${feedBlock.value}${unreadOnlyText}`
|
||||
: `${viewName}${unreadOnlyText}`
|
||||
|
||||
return {
|
||||
styles: getBlockStyles("mainView"),
|
||||
displayContent: createContent(primaryContent),
|
||||
title: titleText,
|
||||
block: viewBlock,
|
||||
}
|
||||
}, [viewBlock, feedBlock, unreadOnlyBlock])
|
||||
|
||||
const presentation = useContextBlockPresentation(block)
|
||||
|
||||
return (
|
||||
<BlockContainer
|
||||
styles={styles}
|
||||
block={block}
|
||||
label={presentation.label}
|
||||
displayContent={displayContent}
|
||||
title={title}
|
||||
presentation={presentation}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
CombinedDataBlockItem.displayName = "CombinedDataBlockItem"
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import * as React from "react"
|
||||
|
||||
import { CombinedContextBlock, ContextBlock } from "~/modules/ai-chat/components/context-bar/blocks"
|
||||
import { useDisplayBlocks } from "~/modules/ai-chat/hooks/useDisplayBlocks"
|
||||
import type { AIChatContextBlock } from "~/modules/ai-chat/store/types"
|
||||
|
||||
import { AIDataBlockItem, CombinedDataBlockItem } from "./AIDataBlockItem"
|
||||
|
||||
interface AIDataBlockPartProps {
|
||||
blocks: AIChatContextBlock[]
|
||||
}
|
||||
|
|
@ -23,35 +21,22 @@ export const AIDataBlockPart: React.FC<AIDataBlockPartProps> = React.memo(({ blo
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full text-left">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex flex-wrap items-center gap-1.5 rounded-lg py-1 pl-2 pr-1",
|
||||
"bg-fill-secondary border-border/50 border",
|
||||
)}
|
||||
>
|
||||
{/* Compact context indicator */}
|
||||
<div className="text-text-secondary flex items-center gap-1">
|
||||
<i className="i-mgc-link-cute-re size-3" />
|
||||
<span className="text-xs">Context:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{displayBlocks.map((item) => {
|
||||
if (item.kind === "combined") {
|
||||
return (
|
||||
<CombinedContextBlock
|
||||
key={`combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`}
|
||||
viewBlock={item.viewBlock}
|
||||
feedBlock={item.feedBlock}
|
||||
unreadOnlyBlock={item.unreadOnlyBlock}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Render individual block items */}
|
||||
{displayBlocks.map((item, index) => {
|
||||
if (item.kind === "combined") {
|
||||
return (
|
||||
<CombinedDataBlockItem
|
||||
key={`combined-${item.viewBlock?.id}-${item.feedBlock?.id}-${item.unreadOnlyBlock?.id}`}
|
||||
viewBlock={item.viewBlock}
|
||||
feedBlock={item.feedBlock}
|
||||
unreadOnlyBlock={item.unreadOnlyBlock}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <AIDataBlockItem key={item.block.id} block={item.block} index={index} />
|
||||
})}
|
||||
</div>
|
||||
return <ContextBlock key={item.block.id} block={item.block} readOnly />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue