feat: add text truncation hook and marquee support (#217)

Introduce a hook to detect text truncation for dynamic UI updates
Replace static marquee spans with a reusable marquee component in files and labels
Enable copying of terminal selection on mouse up and touch end
This commit is contained in:
Bohdan Triapitsyn
2026-01-26 00:24:40 +02:00
committed by GitHub
parent f34027df10
commit efe9ad27b7
18 changed files with 537 additions and 57 deletions
@@ -6,6 +6,7 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
@@ -125,6 +126,21 @@ interface FileChipProps {
onRemove: () => void;
}
const TruncatedMarquee = memo(({ text, title }: { text: string; title?: string }) => {
const labelRef = useRef<HTMLSpanElement>(null);
const isTruncated = useIsTextTruncated(labelRef, [text]);
return (
<span
ref={labelRef}
className={cn('marquee-text', isTruncated && 'marquee-text--active')}
title={title ?? text}
>
{text}
</span>
);
});
const FileChip = memo(({ file, onRemove }: FileChipProps) => {
const getFileIcon = () => {
if (file.mimeType.startsWith('image/')) {
@@ -169,9 +185,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
</div>
{getFileIcon()}
<div className="overflow-hidden max-w-[200px]">
<span className="marquee-text" title={file.serverPath || displayName}>
{displayName}
</span>
<TruncatedMarquee text={displayName} title={file.serverPath || displayName} />
</div>
<span className="text-muted-foreground flex-shrink-0">
({formatFileSize(file.size)})
@@ -292,9 +306,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
>
{getFileIcon(file.mime)}
<div className="overflow-hidden max-w-[200px]">
<span className="marquee-text">
{extractFilename(file.filename)}
</span>
<TruncatedMarquee text={extractFilename(file.filename)} />
</div>
</div>
))}
@@ -47,6 +47,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type IconComponent = ComponentType<any>;
@@ -1083,6 +1084,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
return getModelDisplayName(currentModel);
};
const currentModelDisplayName = getCurrentModelDisplayName();
const modelLabelRef = React.useRef<HTMLSpanElement>(null);
const isModelLabelTruncated = useIsTextTruncated(modelLabelRef, [currentModelDisplayName, isCompact]);
const getAgentDisplayName = () => {
if (!uiAgentName) {
const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode));
@@ -2122,19 +2127,20 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label overflow-hidden',
controlTextSize,
'font-medium whitespace-nowrap text-foreground min-w-0',
'max-w-[260px]'
)}
>
<span className="marquee-text">
{getCurrentModelDisplayName()}
<span
ref={modelLabelRef}
key={`${currentProviderId}-${currentModelId}`}
className={cn(
'model-controls__model-label overflow-hidden',
controlTextSize,
'font-medium whitespace-nowrap text-foreground min-w-0',
'max-w-[260px]'
)}
>
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</span>
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -2246,13 +2252,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
ref={modelLabelRef}
className={cn(
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)}
>
<span className="marquee-text">
{getCurrentModelDisplayName()}
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</button>
@@ -122,6 +122,55 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
}, []);
const copySelectionToClipboard = React.useCallback(async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const selection = window.getSelection();
if (!selection) {
return;
}
const text = selection.toString();
if (!text.trim()) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
if (anchorNode && !container.contains(anchorNode)) {
return;
}
if (focusNode && !container.contains(focusNode)) {
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch {
// fall through to execCommand
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
} catch {
// ignore
}
}, []);
const resetWriteState = React.useCallback(() => {
pendingWriteRef.current = '';
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
@@ -782,6 +831,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
terminalRef.current?.focus();
}
}}
onMouseUp={() => {
void copySelectionToClipboard();
}}
onTouchEnd={() => {
void copySelectionToClipboard();
}}
>
{enableTouchScroll ? (
<textarea
+15 -18
View File
@@ -49,6 +49,7 @@ type GitViewSnapshot = {
directory?: string;
selectedPaths: string[];
commitMessage: string;
generatedHighlights: string[];
};
type GitmojiEntry = {
@@ -179,7 +180,7 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
return null;
};
let gitViewSnapshot: GitViewSnapshot | null = null;
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
const useEffectiveDirectory = () => {
const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore();
@@ -226,9 +227,8 @@ export const GitView: React.FC = () => {
} = useGitStore();
const initialSnapshot = React.useMemo(() => {
if (!gitViewSnapshot) return null;
if (gitViewSnapshot.directory !== currentDirectory) return null;
return gitViewSnapshot;
if (!currentDirectory) return null;
return gitViewSnapshots.get(currentDirectory) ?? null;
}, [currentDirectory]);
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
@@ -273,7 +273,9 @@ export const GitView: React.FC = () => {
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>([]);
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
initialSnapshot?.generatedHighlights ?? []
);
const clearGeneratedHighlights = React.useCallback(() => {
setGeneratedHighlights([]);
}, []);
@@ -346,19 +348,14 @@ export const GitView: React.FC = () => {
}, [expandedCommitHashes, currentDirectory, git, commitFilesMap, loadingCommitHashes]);
React.useEffect(() => {
return () => {
if (!currentDirectory) {
gitViewSnapshot = null;
return;
}
gitViewSnapshot = {
directory: currentDirectory,
selectedPaths: Array.from(selectedPaths),
commitMessage,
};
};
}, [commitMessage, currentDirectory, selectedPaths]);
if (!currentDirectory) return;
gitViewSnapshots.set(currentDirectory, {
directory: currentDirectory,
selectedPaths: Array.from(selectedPaths),
commitMessage,
generatedHighlights,
});
}, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]);
React.useEffect(() => {
loadProfiles();
@@ -44,6 +44,7 @@ const MODIFIER_ARROW_SUFFIX: Record<Modifier, string> = {
cmd: '3',
};
const STREAM_OPTIONS = {
retry: {
maxRetries: 3,
@@ -429,6 +430,7 @@ export const TerminalView: React.FC = () => {
}
}, [clearBuffer, effectiveDirectory, setConnectionError, terminal]);
const handleViewportInput = React.useCallback(
(data: string) => {
if (!data) {
@@ -60,6 +60,15 @@ const branchToTitle = (branch: string): string => {
.replace(/\b\w/g, (c) => c.toUpperCase());
};
type PullRequestDraftSnapshot = {
title: string;
body: string;
draft: boolean;
isOpen: boolean;
};
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
const openExternal = async (url: string) => {
if (typeof window === 'undefined') return;
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
@@ -96,14 +105,20 @@ export const PullRequestSection: React.FC<{
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSidebarSection]);
const [isOpen, setIsOpen] = React.useState(true);
const snapshotKey = React.useMemo(() => `${directory}::${branch}`, [directory, branch]);
const initialSnapshot = React.useMemo(
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
[snapshotKey]
);
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
const [isLoading, setIsLoading] = React.useState(false);
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [title, setTitle] = React.useState(() => branchToTitle(branch));
const [body, setBody] = React.useState('');
const [draft, setDraft] = React.useState(false);
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
const [isGenerating, setIsGenerating] = React.useState(false);
@@ -359,11 +374,25 @@ export const PullRequestSection: React.FC<{
}, [branch, canShow, directory, github]);
React.useEffect(() => {
setTitle(branchToTitle(branch));
setBody('');
setDraft(false);
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
setTitle(snapshot?.title ?? branchToTitle(branch));
setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false);
setIsOpen(snapshot?.isOpen ?? true);
void refresh();
}, [branch, refresh]);
}, [branch, refresh, snapshotKey]);
React.useEffect(() => {
if (!directory || !branch) {
return;
}
pullRequestDraftSnapshots.set(snapshotKey, {
title,
body,
draft,
isOpen,
});
}, [snapshotKey, title, body, draft, isOpen, directory, branch]);
const generateDescription = React.useCallback(async () => {
if (isGenerating) return;