feat(ui): polish chat and git workflows with mobile UX and reliability fixes (#569)

* feat: add chat option for user message rendering mode

* feat: add chat option to toggle sticky user header

* feat(ui): overhaul context panel with reusable tabs and embedded session chat

Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead.

* feat: polish context panel and git sidebar tabs

Refined context panel tab behavior and visuals for smoother switching and resizing
Reused the new tabs component in right sidebar and git sidebar with fit layout
Improved git section spacing, selection controls, and bulk revert confirmation flow

* feat: open diff files in editor at changed lines

Add edit actions in diff views to open files at the first changed line
Support per-file open-in-editor from All Files headers and icon-only action in single-file view
Improve file jump UX with load-aware navigation and reduced visual blink during line targeting

* fix: stabilize pill tabs and prevent git commit pathspec failures

Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome
Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen
Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files

* fix: align user message action row spacing and hover behavior

* fix: persist user message view preferences in settings

Save plain-text and sticky-header toggles to settings.json when changed
Restore both chat display preferences from settings.json on startup
Validate and accept both preference fields in the settings API

* fix: improve git and sidebar tab layout on mobile

* fix: refine mobile user message action row spacing

Show mobile user-message actions in a consistent external row for sticky and non-sticky modes
Tune button row height and vertical position to match both mobile variants
Reduce sticky-header gradient tail and tighten assistant gap after user messages

* fix: improve chat action hover zones and mobile top shadow logic

Expand desktop trigger area so user action buttons reveal across the full row
Add sticky-header phantom hover row so inline actions appear from the whole button lane
Hide chat top scroll shadow on mobile only when sticky user headers are enabled

* fix: remove commit message input scrollbar flicker

Added optional scrollbar class support to shared textarea wrapper.
Disabled overlay scrollbar for Git commit message input.
Kept auto-resize behavior while preventing one-line empty-state micro-scroll.

* feat: make model provider groups collapsible in selector

Add collapsible provider headers in the chat model dropdown
Persist expanded/collapsed provider state across sessions
Refine provider header UX with inline chevrons and no hover highlight

* feat: arrange chat settings into a compact two-column layout

Places User Message Rendering next to Mermaid Rendering.
Places Diff Layout next to Diff View Mode.
Reduces right-column spacing to better match other settings sections.

* fix: show worktree branch edit controls in draft sessions

Detect worktree mode from current directory when session metadata is not yet bound
Enable immediate branch rename UI in Git sidebar without session switching

* feat: add beta badge to side panel menu action
This commit is contained in:
Bohdan Triapitsyn
2026-03-02 02:11:33 +02:00
committed by GitHub
parent 73e533a315
commit b4cd16f55b
45 changed files with 3551 additions and 975 deletions
@@ -106,6 +106,7 @@ export const ChatContainer: React.FC = () => {
isTimelineDialogOpen,
setTimelineDialogOpen,
isExpandedInput,
stickyUserHeader,
} = useUIStore();
const sessionMessages = useSessionStore(
@@ -593,14 +594,14 @@ export const ChatContainer: React.FC = () => {
aria-hidden={isDesktopExpandedInput}
>
<div className="absolute inset-0">
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
observeMutations={false}
hideTopShadow={isMobile}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
<MessageList
ref={messageListRef}
+76 -32
View File
@@ -131,10 +131,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
} = sessionState;
const providers = useConfigStore((state) => state.providers);
const { showReasoningTraces, toolCallExpansion } = useUIStore(
const { showReasoningTraces, toolCallExpansion, stickyUserHeader } = useUIStore(
useShallow((state) => ({
showReasoningTraces: state.showReasoningTraces,
toolCallExpansion: state.toolCallExpansion,
stickyUserHeader: state.stickyUserHeader,
}))
);
@@ -164,6 +165,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
const sessionId = message.info.sessionID;
@@ -940,12 +943,16 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return null;
}
const assistantTopPaddingClass = !isUser && shouldShowHeader
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
: 'pt-0';
return (
<>
<div
className={cn(
'group w-full',
isUser ? (isMobile ? 'pt-2' : 'pt-6') : (shouldShowHeader ? (isMobile ? 'pt-10' : 'pt-6') : 'pt-0'),
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
)}
data-message-id={message.info.id}
@@ -955,37 +962,74 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
{isUser ? (
displayParts.length === 0 ? null : (
<FadeInOnReveal>
<div className="flex justify-end">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="max-w-[85%] rounded-2xl rounded-br-sm px-5 py-3 shadow-none border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
/>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className="max-w-[85%]">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-2xl rounded-br-sm px-5 py-3 shadow-none border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
stickyUserHeaderEnabled={stickyUserHeader}
/>
</div>
{useExternalUserActionsRow ? (
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
userActionsMode="external-actions"
stickyUserHeaderEnabled={stickyUserHeader}
/>
) : null}
</div>
{showStickyInlineHoverRow ? <div aria-hidden="true" className="absolute left-0 right-0 top-full h-11" /> : null}
</div>
</FadeInOnReveal>
)
@@ -15,6 +15,7 @@ import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic } from './contexts/TurnGroupingContext';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
@@ -414,7 +415,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-8 bg-gradient-to-b from-[var(--surface-background)] to-transparent"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
/>
</div>
) : (
@@ -528,7 +529,8 @@ const MessageListContent: React.FC<{
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
stickyUserHeader: boolean;
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader }) => {
return (
<>
{entries.map((entry) => (
@@ -538,7 +540,7 @@ const MessageListContent: React.FC<{
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader
stickyUserHeader={stickyUserHeader}
/>
))}
</>
@@ -560,6 +562,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
scrollRef,
}, ref) => {
const { isMobile } = useDeviceInfo();
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
React.useEffect(() => {
if (permissions.length === 0 && questions.length === 0) {
@@ -1044,6 +1047,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
stickyUserHeader={stickyUserHeader}
/>
)}
</FadeInDisabledProvider>
@@ -361,6 +361,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const {
toggleFavoriteModel,
isFavoriteModel,
collapsedModelProviders,
toggleModelProviderCollapsed,
addRecentModel,
addRecentAgent,
addRecentEffort,
@@ -370,6 +372,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setSettingsPage,
} = useUIStore();
const hiddenModels = useUIStore((state) => state.hiddenModels);
const collapsedProviderSet = React.useMemo(
() => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)),
[collapsedModelProviders]
);
// Separate state for agent selector to avoid conflict with model selector
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
@@ -2104,6 +2110,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
};
const renderModelSelector = () => {
const normalizedDesktopQuery = desktopModelQuery.trim();
const forceExpandProviders = normalizedDesktopQuery.length > 0;
// Filter favorites
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
const provider = providers.find(p => p.id === providerID);
@@ -2132,7 +2141,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
})
.filter((provider) => provider.models.length > 0);
const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0;
const providerSections = filteredProviders.map((provider) => {
const providerId = typeof provider.id === 'string' ? provider.id : '';
const isExpanded = forceExpandProviders || !collapsedProviderSet.has(providerId);
const models = Array.isArray(provider.models) ? (provider.models as ProviderModel[]) : [];
return {
provider,
isExpanded,
models,
visibleModels: isExpanded ? models : [],
};
});
const hasResults =
filteredFavorites.length > 0 ||
filteredRecents.length > 0 ||
filteredProviders.length > 0;
// Build flat list for keyboard navigation
type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
@@ -2144,8 +2168,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
filteredRecents.forEach(({ model, providerID, modelID }) => {
flatModelList.push({ model, providerID, modelID, section: 'recent' });
});
filteredProviders.forEach((provider) => {
(provider.models as ProviderModel[]).forEach((model) => {
providerSections.forEach(({ provider, visibleModels }) => {
visibleModels.forEach((model) => {
flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' });
});
});
@@ -2245,7 +2269,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
{/* Scrollable content */}
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<ScrollableOverlay
outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1"
className="overlay-scrollbar-target--no-gutter"
>
<div className="p-1">
<div
role="button"
@@ -2314,20 +2341,54 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)}
{/* All Providers - Flat List */}
{filteredProviders.map((provider, index) => (
{providerSections.map(({ provider, isExpanded, visibleModels }, index) => (
<React.Fragment key={provider.id}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel
style={{ backgroundColor: 'var(--surface-elevated)' }}
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
<div
role="button"
tabIndex={forceExpandProviders ? -1 : 0}
aria-disabled={forceExpandProviders}
onClick={() => {
if (forceExpandProviders) {
return;
}
toggleModelProviderCollapsed(String(provider.id));
setModelSelectedIndex(0);
}}
onKeyDown={(event) => {
if (forceExpandProviders) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleModelProviderCollapsed(String(provider.id));
setModelSelectedIndex(0);
}
}}
className={cn(
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30',
'bg-[var(--surface-elevated)] text-left transition-colors',
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
)}
aria-expanded={isExpanded}
title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')}
>
<ProviderLogo
providerId={provider.id}
className="h-4 w-4 flex-shrink-0"
/>
{provider.name}
</DropdownMenuLabel>
{(provider.models as ProviderModel[]).map((model: ProviderModel) => {
<div className="flex min-w-0 items-center gap-2">
<ProviderLogo
providerId={provider.id}
className="h-4 w-4 flex-shrink-0"
/>
<span className="min-w-0 truncate">{provider.name}</span>
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center text-muted-foreground">
{isExpanded ? (
<RiArrowDownSLine className="h-4 w-4" />
) : (
<RiArrowRightSLine className="h-4 w-4" />
)}
</span>
</div>
</div>
{isExpanded && visibleModels.map((model: ProviderModel) => {
const idx = currentFlatIndex++;
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx);
})}
@@ -286,6 +286,8 @@ interface MessageBodyProps {
onRevert?: () => void;
onFork?: () => void;
errorMessage?: string;
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
}
const UserMessageBody: React.FC<{
@@ -300,7 +302,9 @@ const UserMessageBody: React.FC<{
agentMention?: AgentMentionInfo;
onRevert?: () => void;
onFork?: () => void;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork }) => {
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
stickyUserHeaderEnabled?: boolean;
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
@@ -326,6 +330,8 @@ const UserMessageBody: React.FC<{
const isMessageCopied = Boolean(copiedMessage);
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
const hasCopyableText = Boolean(hasTextContent);
const showUserContent = userActionsMode !== 'external-actions';
const showUserActions = userActionsMode !== 'external-content';
const clearCopyHintTimeout = React.useCallback(() => {
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
@@ -371,6 +377,113 @@ const UserMessageBody: React.FC<{
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
);
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? (
<div className={cn(
'group/user-actions',
isMobile
? userActionsMode === 'inline'
? 'flex items-center justify-end pt-2 pb-3'
: stickyUserHeaderEnabled
? 'flex h-9 items-start justify-end pt-0'
: 'flex h-11 items-start justify-end pt-0'
: userActionsMode === 'inline'
? 'absolute top-full left-0 right-0 z-10 pt-5'
: 'flex h-8 items-start justify-end pt-2'
)}>
<div
className={cn(
'flex items-center justify-end gap-1',
isMobile
? userActionsMode === 'inline'
? 'translate-x-5'
: 'translate-x-0'
: userActionsMode === 'inline'
? 'translate-x-5'
: 'translate-x-0',
isMobile
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
)}
>
{onRevert && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Revert to this message"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
<RiArrowGoBackLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
</Tooltip>
)}
{onFork && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Fork from this message"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onFork();
}}
>
<RiGitBranchLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Copy message text"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
</Tooltip>
)}
</div>
</div>
) : null;
if (!showUserContent) {
return <>{actionsBlock}</>;
}
return (
<div
className="relative w-full group/message"
@@ -416,92 +529,7 @@ const UserMessageBody: React.FC<{
})}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} compact />
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
<div className={cn(
"absolute top-full left-0 right-0 z-10 group/user-actions",
isMobile ? "pt-2 pb-3" : "pt-5"
)}>
<div
className={cn(
"flex translate-x-5 items-center justify-end gap-1",
isMobile
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100"
)}
>
{onRevert && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Revert to this message"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onRevert();
}}
>
<RiArrowGoBackLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
</Tooltip>
)}
{onFork && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onFork();
}}
>
<RiGitBranchLine className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
</Tooltip>
)}
{canCopyMessage && hasCopyableText && (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label="Copy message text"
onPointerDown={(event) => event.stopPropagation()}
onClick={handleCopyButtonClick}
onFocus={() => setCopyHintVisible(true)}
onBlur={() => {
if (!isMessageCopied) {
setCopyHintVisible(false);
}
}}
>
{isMessageCopied ? (
<RiCheckLine className="h-3 w-3 text-[color:var(--status-success)]" />
) : (
<RiFileCopyLine className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
</Tooltip>
)}
</div>
</div>
) : null}
{actionsBlock}
</div>
);
};
@@ -1421,6 +1449,8 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
agentMention={props.agentMention}
onRevert={props.onRevert}
onFork={props.onFork}
userActionsMode={props.userActionsMode}
stickyUserHeaderEnabled={props.stickyUserHeaderEnabled}
/>
);
}
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
import type { Part } from '@opencode-ai/sdk/v2';
import type { AgentMentionInfo } from '../types';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import { useUIStore } from '@/stores/useUIStore';
type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -18,6 +19,10 @@ const buildMentionUrl = (name: string): string => {
return `https://opencode.ai/docs/agents/#${encoded}`;
};
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
const CLAMP_LINES = 2;
const partWithText = part as PartWithText;
@@ -27,6 +32,8 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
const [isExpanded, setIsExpanded] = React.useState(false);
const [isTruncated, setIsTruncated] = React.useState(false);
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
const textRef = React.useRef<HTMLDivElement>(null);
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
@@ -91,7 +98,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
}
}, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
const processedContent = React.useMemo(() => {
const processedMarkdownContent = React.useMemo(() => {
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
return textContent;
}
@@ -100,6 +107,31 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return textContent.replace(agentMention.token, mentionHtml);
}, [agentMention, textContent]);
const plainTextContent = React.useMemo(() => {
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
return textContent;
}
const idx = textContent.indexOf(agentMention.token);
const before = textContent.slice(0, idx);
const after = textContent.slice(idx + agentMention.token.length);
return (
<>
{before}
<a
href={buildMentionUrl(agentMention.name)}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{agentMention.token}
</a>
{after}
</>
);
}, [agentMention, textContent]);
if (!textContent || textContent.trim().length === 0) {
return null;
}
@@ -109,16 +141,21 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
<div
className={cn(
"break-words font-sans typography-markdown",
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
!isExpanded && "line-clamp-2",
isTruncated && !isExpanded && "cursor-pointer"
)}
ref={textRef}
onClick={handleClick}
>
<SimpleMarkdownRenderer
content={processedContent}
disableLinkSafety
/>
{normalizedRenderingMode === 'markdown' ? (
<SimpleMarkdownRenderer
content={processedMarkdownContent}
disableLinkSafety
/>
) : (
plainTextContent
)}
</div>
</div>
);