= ({ onOpenSettings, scrollToBo
)}
- {/* Linked Issue Button - only in draft mode */}
- {newSessionDraftOpen && (
+ {/* Linked Issue row */}
+ {linkedIssue && !isVSCode && (
+ )}
+ {linkedPr && !isVSCode && (
+
)}
= ({ onOpenSettings, scrollToBo
: undefined}
/>
)}
-
= ({ onOpenSettings, scrollToBo
open={issuePickerOpen}
onOpenChange={setIssuePickerOpen}
mode="select"
- onSelect={(issue) => setLinkedIssue(issue)}
+ onSelect={(issue) => {
+ setLinkedIssue(issue);
+ setLinkedPr(null);
+ }}
+ />
+ {
+ setLinkedPr(pr);
+ setLinkedIssue(null);
+ }}
/>
>
);
diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx
index 77b71b07..f80aad40 100644
--- a/packages/ui/src/components/chat/ChatMessage.tsx
+++ b/packages/ui/src/components/chat/ChatMessage.tsx
@@ -29,7 +29,12 @@ import { copyTextToClipboard } from '@/lib/clipboard';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
-const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']);
+const TOOL_DEFAULT_EXPANSION_BY_MODE = {
+ detailed: new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']),
+ changes: new Set(['edit', 'multiedit', 'write', 'apply_patch']),
+} as const;
+
+type DefaultExpandedToolMode = keyof typeof TOOL_DEFAULT_EXPANSION_BY_MODE;
const EXPANDED_TOOLS_CACHE_MAX = 4000;
const expandedToolsStateCache = new Map>();
@@ -48,8 +53,8 @@ const writeExpandedToolsCache = (messageId: string, value: Set): void =>
expandedToolsStateCache.set(messageId, new Set(value));
};
-const isDetailedDefaultTool = (toolName: unknown): boolean =>
- typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
+const isDefaultExpandedTool = (toolName: unknown, mode: DefaultExpandedToolMode): boolean =>
+ typeof toolName === 'string' && TOOL_DEFAULT_EXPANSION_BY_MODE[mode].has(toolName.toLowerCase());
function useStickyDisplayValue(value: T | null | undefined): T | null | undefined {
const [stickyValue, setStickyValue] = React.useState(value);
@@ -443,19 +448,29 @@ const ChatMessage: React.FC = ({
// 'collapsed': Activity and tools start collapsed
// 'activity': Activity expanded, tools collapsed
// 'detailed': Activity expanded, only key tools expanded
+ // 'changes': Activity expanded, only edit/diff tools expanded
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
return expandedTools;
}
- // 'detailed': expand only allowlisted tools by default.
+ const defaultExpansionMode =
+ toolCallExpansion === 'detailed' || toolCallExpansion === 'changes'
+ ? toolCallExpansion
+ : null;
+
+ if (!defaultExpansionMode) {
+ return expandedTools;
+ }
+
+ // 'detailed'/'changes': expand only allowlisted tools by default.
// expandedTools acts as a "toggled" set (XOR with defaults).
const defaultExpandedToolIds = new Set();
for (const part of toolParts) {
const toolName = (part as { tool?: unknown }).tool;
- if (part.id && isDetailedDefaultTool(toolName)) {
+ if (part.id && isDefaultExpandedTool(toolName, defaultExpansionMode)) {
defaultExpandedToolIds.add(part.id);
}
}
@@ -467,7 +482,7 @@ const ChatMessage: React.FC = ({
}
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
- if (isDetailedDefaultTool(toolPart.tool)) {
+ if (isDefaultExpandedTool(toolPart.tool, defaultExpansionMode)) {
if (toolPart.id) {
defaultExpandedToolIds.add(toolPart.id);
}
@@ -1029,7 +1044,7 @@ const ChatMessage: React.FC = ({
/>
) : null}
- {showStickyInlineHoverRow ? : null}
+ {showStickyInlineHoverRow ? : null}
)
diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx
index 254dbb1b..f9b8a4eb 100644
--- a/packages/ui/src/components/chat/FileAttachment.tsx
+++ b/packages/ui/src/components/chat/FileAttachment.tsx
@@ -265,10 +265,12 @@ FileChip.displayName = 'FileChip';
export const AttachedFilesList = memo(() => {
const { attachedFiles, removeAttachedFile } = useSessionStore();
- if (attachedFiles.length === 0) return null;
+ const localFiles = attachedFiles.filter((file) => file.source !== 'server');
- const images = attachedFiles.filter(f => f.mimeType.startsWith('image/'));
- const otherFiles = attachedFiles.filter(f => !f.mimeType.startsWith('image/'));
+ if (localFiles.length === 0) return null;
+
+ const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
+ const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/'));
return (
@@ -338,8 +340,135 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
+ const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
+ const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
+
+ const imageGallery = React.useMemo(
+ () =>
+ imageFiles.flatMap((file) => {
+ if (!file.url) return [];
+ const filename = extractFilename(file.filename) || 'Image';
+ return [{
+ url: file.url,
+ mimeType: file.mime,
+ filename,
+ size: file.size,
+ }];
+ }),
+ [imageFiles]
+ );
+
+ const handleImageClick = React.useCallback((index: number) => {
+ if (!onShowPopup) {
+ return;
+ }
+
+ const file = imageGallery[index];
+ if (!file?.url) return;
+
+ const filename = file.filename || 'Image';
+
+ onShowPopup({
+ open: true,
+ title: filename,
+ content: '',
+ metadata: {
+ tool: 'image-preview',
+ filename,
+ mime: file.mimeType,
+ size: file.size,
+ },
+ image: {
+ url: file.url,
+ mimeType: file.mimeType,
+ filename,
+ size: file.size,
+ gallery: imageGallery,
+ index,
+ },
+ });
+ }, [imageGallery, onShowPopup]);
+
if (fileItems.length === 0) return null;
+ if (compact) {
+ return (
+
+ {otherFiles.length > 0 && (
+
+ {otherFiles.map((file, index) => {
+ const fileName = extractFilename(file.filename || file.url);
+ const sizeText = formatFileSize(file.size);
+ return (
+
+
+
+ {file.mime?.includes('pdf') ? (
+
+ ) : (
+
+ )}
+
+ {fileName}
+
+
+
+
+ {fileName}{sizeText ? ` (${sizeText})` : ''}
+
+
+ );
+ })}
+
+ )}
+
+ {imageFiles.length > 0 && (
+
+
+ {imageFiles.map((file, index) => {
+ const filename = extractFilename(file.filename) || 'Image';
+
+ return (
+
+
+
+
+
+ {filename}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+
return (
{
- const { currentDirectory } = useDirectoryStore();
- const { addServerFile } = useSessionStore();
+ const currentDirectory = useChatSearchDirectory() ?? '';
+ const activeProjectId = useProjectsStore((state) => state.activeProjectId);
+ const activeProjectPath = useProjectsStore(
+ React.useCallback(
+ (state) => state.projects.find((project) => project.id === activeProjectId)?.path ?? null,
+ [activeProjectId],
+ ),
+ );
+ const projectRoot = React.useMemo(() => {
+ const candidate = activeProjectPath || currentDirectory;
+ return candidate ? candidate.replace(/\\/g, '/').replace(/\/+$/, '') : null;
+ }, [activeProjectPath, currentDirectory]);
+ const projectTabs = useFilesViewTabsStore(
+ React.useCallback(
+ (state) => (projectRoot ? state.byRoot[projectRoot] : undefined),
+ [projectRoot],
+ ),
+ );
const { getVisibleAgents } = useConfigStore();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const debouncedQuery = useDebouncedValue(searchQuery, 180);
@@ -67,55 +84,43 @@ export const FileMentionAutocomplete = React.forwardRef
0 ? agents : agents.slice(0, 2);
- const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
- const q = query.trim().toLowerCase();
- if (!q) {
- return 0;
+ const recentFiles = React.useMemo(() => {
+ if (!projectRoot || !projectTabs) {
+ return [] as FileInfo[];
}
- const c = candidate.toLowerCase();
- let score = 0;
- let lastIndex = -1;
- let consecutive = 0;
+ const ordered = [
+ projectTabs.selectedPath,
+ ...projectTabs.openPaths.slice().reverse(),
+ ].filter((value): value is string => typeof value === 'string' && value.length > 0);
- for (let i = 0; i < q.length; i += 1) {
- const ch = q[i];
- if (!ch || ch === ' ') {
- continue;
- }
+ const seen = new Set();
+ const queryLower = normalizedSearchQuery.toLowerCase();
+ const mapped = ordered
+ .filter((filePath) => {
+ if (seen.has(filePath)) return false;
+ seen.add(filePath);
+ const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
+ if (!queryLower) return true;
+ return relative.toLowerCase().includes(queryLower);
+ })
+ .slice(0, 6)
+ .map((filePath) => {
+ const normalizedPath = filePath.replace(/\\/g, '/');
+ const name = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath;
+ const relativePath = normalizedPath.startsWith(`${projectRoot}/`)
+ ? normalizedPath.slice(projectRoot.length + 1)
+ : normalizedPath;
+ return {
+ name,
+ path: normalizedPath,
+ relativePath,
+ extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
+ } satisfies FileInfo;
+ });
- const idx = c.indexOf(ch, lastIndex + 1);
- if (idx === -1) {
- return null;
- }
-
- const gap = idx - lastIndex - 1;
- if (gap === 0) {
- consecutive += 1;
- } else {
- consecutive = 0;
- }
-
- score += 10;
- score += Math.max(0, 18 - idx);
- score -= Math.max(0, gap);
-
- if (idx === 0) {
- score += 12;
- } else {
- const prev = c[idx - 1];
- if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
- score += 10;
- }
- }
-
- score += consecutive > 0 ? 12 : 0;
- lastIndex = idx;
- }
-
- score += Math.max(0, 24 - Math.round(c.length / 3));
- return score;
- }, []);
+ return mapped;
+ }, [normalizedSearchQuery, projectRoot, projectTabs]);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -138,6 +143,7 @@ export const FileMentionAutocomplete = React.forwardRef {
if (!currentDirectory) {
setFiles([]);
+ setLoading(false);
return;
}
@@ -147,35 +153,27 @@ export const FileMentionAutocomplete = React.forwardRef {
if (cancelled) {
return;
}
- const ranked = normalizedQueryLower
- ? hits
- .map((file) => {
- const label = file.relativePath || file.name || file.path;
- const score = fuzzyScore(normalizedQueryLower, label);
- return score === null ? null : { file, score, labelLength: label.length };
- })
- .filter(Boolean) as Array<{ file: FileInfo; score: number; labelLength: number }>
- : hits.map((file) => ({ file, score: 0, labelLength: (file.relativePath || file.name || file.path).length }));
-
- ranked.sort((a, b) => (
- b.score - a.score
- || a.labelLength - b.labelLength
- || a.file.path.localeCompare(b.file.path)
- ));
-
- setFiles(ranked.slice(0, 15).map((entry) => entry.file));
+ const recentSet = new Set(recentFiles.map((file) => file.path));
+ setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
})
.catch(() => {
if (!cancelled) {
@@ -191,7 +189,7 @@ export const FileMentionAutocomplete = React.forwardRef {
cancelled = true;
};
- }, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
+ }, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
@@ -216,7 +214,7 @@ export const FileMentionAutocomplete = React.forwardRef {
itemRefs.current[selectedIndex]?.scrollIntoView({
@@ -292,11 +290,9 @@ export const FileMentionAutocomplete = React.forwardRef {
-
- await addServerFile(file.path, file.name);
+ const handleFileSelect = React.useCallback((file: FileInfo) => {
onFileSelect(file);
- }, [addServerFile, onFileSelect]);
+ }, [onFileSelect]);
const handleAgentPick = React.useCallback((agentName: string) => {
onAgentSelect?.(agentName);
@@ -309,7 +305,7 @@ export const FileMentionAutocomplete = React.forwardRef {
const ext = file.extension?.toLowerCase();
@@ -440,11 +439,68 @@ export const FileMentionAutocomplete = React.forwardRef
);
})}
- {visibleAgents.length > 0 && files.length > 0 && (
+ {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
+
+ Type to search more agents
+
+ )}
+ {visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
+
+ )}
+ {recentFiles.map((file, index) => {
+ const rowIndex = visibleAgents.length + index;
+ const relativePath = file.relativePath || file.name;
+ const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
+ const isSelected = selectedIndex === rowIndex;
+ const isOverflowing = overflowMap[rowIndex] ?? false;
+ const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6;
+
+ return (
+ { itemRefs.current[rowIndex] = el; }}
+ className={cn(
+ "flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
+ isSelected && "bg-interactive-selection"
+ )}
+ onClick={() => handleFileSelect(file)}
+ onMouseEnter={() => setSelectedIndex(rowIndex)}
+ >
+ {getFileIcon(file)}
+ { labelRefs.current[rowIndex] = el; }}
+ className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
+ style={isSelected ? {
+ ['--file-mention-marquee-width' as string]: `${marqueeWidth}px`,
+ ['--file-mention-marquee-duration' as string]: `${marqueeDuration}s`
+ } : undefined}
+ aria-label={relativePath}
+ >
+ { measureRefs.current[rowIndex] = el; }}
+ className="absolute invisible whitespace-nowrap pointer-events-none"
+ aria-hidden
+ >
+ {relativePath}
+
+ {isOverflowing && isSelected ? (
+
+ {relativePath}
+
+ ) : (
+
+ {displayPath}
+
+ )}
+
+
+ );
+ })}
+ {recentFiles.length > 0 && files.length > 0 && (
)}
{files.map((file, index) => {
- const rowIndex = visibleAgents.length + index;
+ const rowIndex = visibleAgents.length + recentFiles.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -497,12 +553,7 @@ export const FileMentionAutocomplete = React.forwardRef
);
})}
- {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
-
- Type to search more agents
-
- )}
- {files.length === 0 && visibleAgents.length === 0 && (
+ {files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
No matches found
diff --git a/packages/ui/src/components/chat/ServerFilePicker.tsx b/packages/ui/src/components/chat/ServerFilePicker.tsx
deleted file mode 100644
index f346b86d..00000000
--- a/packages/ui/src/components/chat/ServerFilePicker.tsx
+++ /dev/null
@@ -1,634 +0,0 @@
-import React from 'react';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { RiCloseLine, RiFolder6Line, RiSearchLine } from '@remixicon/react';
-import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { cn, truncatePathMiddle } from '@/lib/utils';
-import { useDirectoryStore } from '@/stores/useDirectoryStore';
-import { opencodeClient } from '@/lib/opencode/client';
-import { useDeviceInfo } from '@/lib/device';
-import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
-import { useFileSearchStore } from '@/stores/useFileSearchStore';
-import { useDebouncedValue } from '@/hooks/useDebouncedValue';
-import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
-import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
-import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
-interface FileInfo {
- name: string;
- path: string;
- type: 'file' | 'directory';
- size?: number;
- extension?: string;
- relativePath?: string;
-}
-
-interface ServerFilePickerProps {
- onFilesSelected: (files: FileInfo[]) => void;
- multiSelect?: boolean;
- children: React.ReactNode;
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- presentation?: 'dropdown' | 'modal';
-}
-
-export const ServerFilePicker: React.FC = ({
- onFilesSelected,
- multiSelect = false,
- children,
- open: controlledOpen,
- onOpenChange,
- presentation = 'dropdown',
-}) => {
- const { isMobile } = useDeviceInfo();
- // Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
- const isCompact = isMobile;
- const { currentDirectory } = useDirectoryStore();
- const searchFiles = useFileSearchStore((state) => state.searchFiles);
- const showHidden = useDirectoryShowHidden();
- const showGitignored = useFilesViewShowGitignored();
- const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
- const [cacheNonce, setCacheNonce] = React.useState(0);
- const [mobileOpen, setMobileOpen] = React.useState(false);
- const [searchQuery, setSearchQuery] = React.useState('');
- const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
- const [selectedFiles, setSelectedFiles] = React.useState>(new Set());
- const [expandedDirs, setExpandedDirs] = React.useState>(new Set());
- const [childrenByDir, setChildrenByDir] = React.useState>({});
- const loadedDirsRef = React.useRef>(new Set());
- const inFlightDirsRef = React.useRef>(new Set());
- const [inFlightDirs, setInFlightDirs] = React.useState>(new Set());
- const [searchResults, setSearchResults] = React.useState([]);
- const [searching, setSearching] = React.useState(false);
- const [loading, setLoading] = React.useState(false);
- const [attaching, setAttaching] = React.useState(false);
- const [error, setError] = React.useState(null);
-
- const open = controlledOpen ?? uncontrolledOpen;
- const setOpen = onOpenChange ?? setUncontrolledOpen;
-
- const updateInFlightDirs = React.useCallback((next: Set) => {
- inFlightDirsRef.current = next;
- setInFlightDirs(next);
- }, []);
-
- const sortDirectoryItems = React.useCallback((items: FileInfo[]) => (
- items.slice().sort((a, b) => {
- if (a.type !== b.type) {
- return a.type === 'directory' ? -1 : 1;
- }
- return a.name.localeCompare(b.name);
- })
- ), []);
-
- const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => (
- sortDirectoryItems(entries
- .filter((item) => showHidden || !item.name.startsWith('.'))
- .map((item) => {
- const name = item.name;
- const extension = !item.isDirectory && name.includes('.')
- ? name.split('.').pop()?.toLowerCase()
- : undefined;
- return {
- name,
- path: item.path || `${dirPath}/${name}`,
- type: item.isDirectory ? 'directory' : 'file',
- size: 0,
- extension,
- };
- }))
- ), [sortDirectoryItems, showHidden]);
-
- const loadDirectory = React.useCallback(async (dirPath: string) => {
- setLoading(true);
- setError(null);
- await opencodeClient.listLocalDirectory(dirPath, { respectGitignore: !showGitignored })
- .then((entries) => {
- const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
- name: entry.name,
- path: entry.path,
- isDirectory: entry.isDirectory,
- })));
-
- loadedDirsRef.current = new Set([dirPath]);
- updateInFlightDirs(new Set());
- setChildrenByDir({ [dirPath]: items });
- setExpandedDirs(new Set());
- })
- .catch(() => {
- setError('Failed to load directory contents');
- loadedDirsRef.current = new Set([dirPath]);
- updateInFlightDirs(new Set());
- setChildrenByDir({ [dirPath]: [] });
- setExpandedDirs(new Set());
- })
- .finally(() => {
- setLoading(false);
- });
- }, [mapFilesystemEntries, showGitignored, updateInFlightDirs]);
-
- const loadDirectoryChildren = React.useCallback(async (dirPath: string) => {
- const normalizedDir = dirPath.trim();
- if (!normalizedDir) {
- return;
- }
- const cacheKey = `${normalizedDir}::${cacheNonce}`;
- if (loadedDirsRef.current.has(cacheKey)) {
- return;
- }
- if (inFlightDirsRef.current.has(cacheKey)) {
- return;
- }
-
- const nextInFlight = new Set(inFlightDirsRef.current);
- nextInFlight.add(cacheKey);
- updateInFlightDirs(nextInFlight);
-
- await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: !showGitignored })
- .then((entries) => {
- const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
- name: entry.name,
- path: entry.path,
- isDirectory: entry.isDirectory,
- })));
-
- loadedDirsRef.current = new Set(loadedDirsRef.current);
- loadedDirsRef.current.add(cacheKey);
- setChildrenByDir((prev) => ({
- ...prev,
- [normalizedDir]: items,
- }));
- })
- .catch(() => {
- setChildrenByDir((prev) => {
- if (prev[normalizedDir]) {
- return prev;
- }
- return {
- ...prev,
- [normalizedDir]: [],
- };
- });
- })
- .finally(() => {
- const updatedInFlightDirs = new Set(inFlightDirsRef.current);
- updatedInFlightDirs.delete(cacheKey);
- updateInFlightDirs(updatedInFlightDirs);
- });
- }, [mapFilesystemEntries, showGitignored, cacheNonce, updateInFlightDirs]);
-
- React.useEffect(() => {
- if ((open || mobileOpen) && currentDirectory) {
- void loadDirectory(currentDirectory);
- }
- }, [open, mobileOpen, currentDirectory, loadDirectory]);
-
- React.useEffect(() => {
- setCacheNonce((prev) => prev + 1);
- }, [showHidden, showGitignored]);
-
- React.useEffect(() => {
- if (!(open || mobileOpen) || !currentDirectory) {
- setSearchResults([]);
- setSearching(false);
- return;
- }
-
- const trimmedQuery = debouncedSearchQuery
- .trim()
- .replace(/^\.\//, '')
- .replace(/^\/+/, '');
- if (!trimmedQuery) {
- setSearchResults([]);
- setSearching(false);
- return;
- }
-
- const normalizedQuery = trimmedQuery.toLowerCase();
-
- let cancelled = false;
- setSearching(true);
-
- searchFiles(currentDirectory, normalizedQuery, 150, {
- includeHidden: showHidden,
- respectGitignore: !showGitignored,
- })
- .then((hits) => {
- if (cancelled) {
- return;
- }
- const mappedHits: FileInfo[] = hits.map((hit) => ({
- name: hit.name,
- path: hit.path,
- type: 'file',
- extension: hit.extension,
- relativePath: hit.relativePath,
- size: 0,
- }));
- setSearchResults(mappedHits);
- })
- .catch(() => {
- if (!cancelled) {
- setSearchResults([]);
- }
- })
- .finally(() => {
- if (!cancelled) {
- setSearching(false);
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
-
- React.useEffect(() => {
- if (!open && !mobileOpen) {
- setSelectedFiles(new Set());
- setSearchQuery('');
- setSearchResults([]);
- setSearching(false);
- loadedDirsRef.current = new Set();
- updateInFlightDirs(new Set());
- setChildrenByDir({});
- setExpandedDirs(new Set());
- }
- }, [open, mobileOpen, updateInFlightDirs]);
-
- const getFileIcon = (file: FileInfo) => {
- if (file.type === 'directory') {
- return expandedDirs.has(file.path) ? (
-
- ) : (
-
- );
- }
-
- return ;
- };
-
- const toggleDirectory = async (dirPath: string) => {
- const isExpanded = expandedDirs.has(dirPath);
-
- if (isExpanded) {
- setExpandedDirs(prev => {
- const next = new Set(prev);
- next.delete(dirPath);
- return next;
- });
- } else {
- setExpandedDirs((prev) => {
- const next = new Set(prev);
- next.add(dirPath);
- return next;
- });
-
- await loadDirectoryChildren(dirPath);
- }
- };
-
- const toggleFileSelection = (filePath: string) => {
- if (multiSelect) {
- setSelectedFiles(prev => {
- const next = new Set(prev);
- if (next.has(filePath)) {
- next.delete(filePath);
- } else {
- next.add(filePath);
- }
- return next;
- });
- } else {
- setSelectedFiles(new Set([filePath]));
- }
- };
-
- const handleConfirm = async () => {
- const treeFileMap = new Map();
- Object.values(childrenByDir).forEach((items) => {
- items.forEach((file) => {
- if (file.type === 'file') {
- treeFileMap.set(file.path, file);
- }
- });
- });
- const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
-
- const selected = Array.from(selectedFiles)
- .map((filePath) => treeFileMap.get(filePath) ?? searchFileMap.get(filePath))
- .filter((file): file is FileInfo => Boolean(file));
-
- setAttaching(true);
- await Promise.resolve(onFilesSelected(selected))
- .then(() => {
- setSelectedFiles(new Set());
- setOpen(false);
- setMobileOpen(false);
- })
- .finally(() => {
- setAttaching(false);
- });
- };
-
- const rootItems = React.useMemo(() => {
- if (!currentDirectory) {
- return [];
- }
- return childrenByDir[currentDirectory] ?? [];
- }, [childrenByDir, currentDirectory]);
-
- const isSearchActive = searchQuery.trim().length > 0;
-
- const getChildItems = (parentPath: string) => {
- return childrenByDir[parentPath] ?? [];
- };
-
- const getRelativePath = (fullPath: string) => {
- if (currentDirectory && fullPath.startsWith(currentDirectory)) {
- const relativePath = fullPath.substring(currentDirectory.length);
- return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
- }
- return fullPath.split('/').pop() || fullPath;
- };
-
- const renderFileItem = (file: FileInfo, level: number) => {
- const rawLabel = isSearchActive
- ? file.relativePath || getRelativePath(file.path)
- : file.name;
- const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45;
- const displayLabel = shouldCompact
- ? truncatePathMiddle(rawLabel, { maxLength: isCompact ? 42 : 48 })
- : rawLabel;
-
- const row = (
- {
- e.preventDefault();
- e.stopPropagation();
- if (file.type === 'file') {
- toggleFileSelection(file.path);
- }
- }}
- >
-
- {getFileIcon(file)}
-
- {displayLabel}
-
-
- {file.type === 'file' && selectedFiles.has(file.path) && (
-
- )}
-
- );
-
- if (!shouldCompact) {
- return React.cloneElement(row, { key: file.path });
- }
-
- return (
-
- {row}
-
-
- {rawLabel}
-
-
-
- );
- };
-
- const renderFileTree = (file: FileInfo, level: number): React.ReactNode => {
- const isDirectory = file.type === 'directory';
- const children = isDirectory ? getChildItems(file.path) : [];
- const isExpanded = expandedDirs.has(file.path);
- const isLoadingChildren = isDirectory && isExpanded && inFlightDirs.has(file.path) && children.length === 0;
-
- return (
-
-
-
- {isDirectory && isExpanded && children.length > 0 && (
-
- {children.map((child) => renderFileTree(child, level + 1))}
-
- )}
-
- {isDirectory && isExpanded && isLoadingChildren && (
-
- Loading…
-
- )}
-
- );
- };
-
- const summaryLabel = selectedFiles.size > 0
- ? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected`
- : 'No files selected';
-
- const summarySection = (
-
-
{summaryLabel}
-
-
- );
-
- const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]';
-
- const pickerBody = (
- <>
-
-
-
-
- setSearchQuery(e.target.value)}
- placeholder="Search files..."
- className="pl-7 h-6 typography-ui-label"
- onClick={(e) => e.stopPropagation()}
- />
- {searchQuery && (
-
- )}
-
-
-
- {loading && (
-
- )}
-
- {error && (
-
- )}
-
- {!loading && !error && (
-
- {isSearchActive ? (
- searching ? (
-
- Searching files…
-
- ) : (
- searchResults.map((file) => renderFileItem(file, 0))
- )
- ) : (
- rootItems.map((file) => renderFileTree(file, 0))
- )}
-
- {!isSearchActive && rootItems.length === 0 && (
-
- No files in this directory
-
- )}
-
- {isSearchActive && !searching && searchResults.length === 0 && (
-
- No files found
-
- )}
-
- )}
-
- >
- );
-
- const mobileTrigger = (
- {
- event.preventDefault();
- event.stopPropagation();
- setMobileOpen(true);
- }}
- >
- {children}
-
- );
-
- if (presentation === 'modal') {
- return (
- <>
- {children ? (
- {
- event.preventDefault();
- event.stopPropagation();
- setOpen(true);
- }}
- >
- {children}
-
- ) : null}
-
- setOpen(false)}
- title="Select Project Files"
- footer={summarySection}
- >
- {pickerBody}
-
- >
- );
- }
-
- if (isCompact) {
- return (
- <>
- {mobileTrigger}
- setMobileOpen(false)}
- title="Select Project Files"
- footer={summarySection}
- >
- {pickerBody}
-
- >
- );
- }
-
- return (
-
-
- {children}
-
-
- {pickerBody}
-
- {summarySection}
-
-
- );
-};
diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx
index 88065a00..60d130c9 100644
--- a/packages/ui/src/components/chat/StatusRow.tsx
+++ b/packages/ui/src/components/chat/StatusRow.tsx
@@ -1,7 +1,15 @@
import React from "react";
-import { RiArrowUpSLine, RiArrowDownSLine, RiCloseCircleLine } from "@remixicon/react";
+import {
+ RiArrowDownSLine,
+ RiArrowUpDoubleLine,
+ RiArrowUpSLine,
+ RiCheckboxCircleLine,
+ RiCloseCircleLine,
+ RiRecordCircleLine,
+ RiTimeLine,
+} from "@remixicon/react";
import { cn } from "@/lib/utils";
-import { useTodoStore, type TodoItem, type TodoStatus } from "@/stores/useTodoStore";
+import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
import { useSessionStore } from "@/stores/useSessionStore";
import { useUIStore } from "@/stores/useUIStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
@@ -22,6 +30,18 @@ const statusConfig: Record = {
},
};
+const priorityClassName: Record = {
+ high: "text-[var(--status-warning)]",
+ medium: "text-muted-foreground",
+ low: "text-muted-foreground/70",
+};
+
+const priorityIcon: Record = {
+ high: ,
+ medium: ,
+ low: ,
+};
+
interface TodoItemRowProps {
todo: TodoItem;
}
@@ -29,8 +49,18 @@ interface TodoItemRowProps {
const TodoItemRow: React.FC = ({ todo }) => {
const config = statusConfig[todo.status] || statusConfig.pending;
+ const statusIcon =
+ todo.status === "in_progress" ? (
+
+ ) : todo.status === "completed" ? (
+
+ ) : (
+
+ );
+
return (
-
+
+ {statusIcon}
= ({ todo }) => {
>
{todo.content}
+
+ {priorityIcon[todo.priority] ?? priorityIcon.medium}
+
);
};
@@ -89,18 +128,10 @@ export const StatusRow: React.FC
= ({
}
}, [currentSessionId, loadTodos]);
- // Filter out cancelled todos for display, sort by status priority
+ // Filter out cancelled todos for display and keep original order.
+ // This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
- const statusOrder: Record = {
- in_progress: 0,
- pending: 1,
- completed: 2,
- cancelled: 3,
- };
-
- return [...todos]
- .filter((todo) => todo.status !== "cancelled")
- .sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
+ return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
// Find the current active todo (first in_progress, or first pending)
@@ -119,6 +150,12 @@ export const StatusRow: React.FC = ({
return { completed, total };
}, [todos]);
+ const statusSummary = React.useMemo(() => {
+ const active = visibleTodos.filter((t) => t.status === "in_progress").length;
+ const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
+ return { active, left };
+ }, [visibleTodos]);
+
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
@@ -178,7 +215,7 @@ export const StatusRow: React.FC = ({
Tasks
)}
- {progress.completed}/{progress.total}
+ {statusSummary.active} active · {statusSummary.left} left
{isExpanded ? (
diff --git a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx
index aeba0270..c9add4fc 100644
--- a/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx
+++ b/packages/ui/src/components/chat/contexts/TurnGroupingContext.tsx
@@ -514,7 +514,8 @@ export const TurnGroupingProvider: React.FC = ({ mess
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
- const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
+ const defaultActivityExpanded =
+ toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
const [structuredMessages, setStructuredMessages] = React.useState(messages);
diff --git a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts
index 3336e39c..0f8dd827 100644
--- a/packages/ui/src/components/chat/hooks/useTurnGrouping.ts
+++ b/packages/ui/src/components/chat/hooks/useTurnGrouping.ts
@@ -423,8 +423,9 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
);
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
- // Activity group is expanded for 'activity' and 'detailed', collapsed for 'collapsed'
- const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
+ // Activity group is expanded for 'activity', 'detailed', and 'changes'; collapsed for 'collapsed'
+ const defaultActivityExpanded =
+ toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
// Reset turn UI states when the expansion preference changes
// This ensures the setting takes precedence over manual toggles
diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx
index c1c3520f..d02a3681 100644
--- a/packages/ui/src/components/chat/message/MessageBody.tsx
+++ b/packages/ui/src/components/chat/message/MessageBody.tsx
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react';
+import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -28,6 +28,7 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
import { useConfigStore } from '@/stores/useConfigStore';
import { TextSelectionMenu } from './TextSelectionMenu';
import { copyTextToClipboard } from '@/lib/clipboard';
+import { isVSCodeRuntime } from '@/lib/desktop';
import { toPng } from 'html-to-image';
import { toast } from '@/components/ui';
import { formatTimestampForDisplay } from './timeFormat';
@@ -332,6 +333,7 @@ const UserMessageBody: React.FC<{
const hasCopyableText = Boolean(hasTextContent);
const showUserContent = userActionsMode !== 'external-actions';
const showUserActions = userActionsMode !== 'external-content';
+ const useStickyScrollableUserContent = stickyUserHeaderEnabled && userActionsMode === 'inline';
const clearCopyHintTimeout = React.useCallback(() => {
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
@@ -490,7 +492,15 @@ const UserMessageBody: React.FC<{
style={{ contain: 'layout', transform: 'translateZ(0)' }}
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
>
-
+
{userContentParts.map((part, index) => {
if (isSubtaskPart(part)) {
return (
@@ -833,12 +843,17 @@ const AssistantMessageBody: React.FC> = ({
try {
const originalElement = messageContentRef.current;
const computedStyle = window.getComputedStyle(originalElement);
+ const rootStyle = window.getComputedStyle(document.documentElement);
+ const resolvedBackgroundColor =
+ rootStyle.getPropertyValue('--surface-background').trim() ||
+ computedStyle.backgroundColor ||
+ window.getComputedStyle(document.body).backgroundColor;
const paddingSize = 24;
wrapper = document.createElement('div');
wrapper.style.cssText = `
padding: ${paddingSize}px;
- background-color: var(--surface-background);
+ background-color: ${resolvedBackgroundColor};
display: inline-block;
`;
@@ -849,21 +864,69 @@ const AssistantMessageBody: React.FC> = ({
contain: none;
`;
+ const timestampElements = clone.querySelectorAll('[aria-label^="Message time:"]');
+ const footerRowsAdjusted = new Set();
+ timestampElements.forEach((element) => {
+ const label = element.getAttribute('aria-label');
+ const timestamp = label?.replace('Message time:', '').trim();
+ if (!timestamp || element.textContent?.includes(timestamp)) {
+ return;
+ }
+
+ const timestampText = document.createElement('span');
+ timestampText.style.marginLeft = '4px';
+ timestampText.textContent = timestamp;
+ element.appendChild(timestampText);
+
+ const metaGroup = element.parentElement;
+ const footerRow = metaGroup?.parentElement as HTMLElement | null;
+ const actionsGroup = footerRow?.firstElementChild as HTMLElement | null;
+ if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) {
+ return;
+ }
+
+ actionsGroup.style.display = 'none';
+ footerRow.style.justifyContent = 'flex-start';
+ footerRowsAdjusted.add(footerRow);
+ });
+
wrapper.appendChild(clone);
document.body.appendChild(wrapper);
const dataUrl = await toPng(wrapper, {
quality: 1,
pixelRatio: 2,
- backgroundColor: 'var(--surface-background)',
+ backgroundColor: resolvedBackgroundColor,
});
- const link = document.createElement('a');
- link.download = `message-${messageId}.png`;
- link.href = dataUrl;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
+ const fileName = `message-${messageId}.png`;
+
+ if (isVSCodeRuntime()) {
+ const response = await fetch('/api/vscode/save-image', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fileName, dataUrl }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save image in VS Code');
+ }
+
+ const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string };
+ if (payload.saved !== true) {
+ if (payload.canceled) {
+ return;
+ }
+ throw new Error(payload.error || 'Failed to save image in VS Code');
+ }
+ } else {
+ const link = document.createElement('a');
+ link.download = fileName;
+ link.href = dataUrl;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
toast.success('Image saved');
} catch (error) {
@@ -1289,7 +1352,7 @@ const AssistantMessageBody: React.FC> = ({
{isSharing ? (
) : (
-
+
)}
@@ -1399,10 +1462,17 @@ const AssistantMessageBody: React.FC> = ({
) : null}
{footerTimestamp ? (
-
-
- {footerTimestamp}
-
+
+
+
+
+
+
+ {footerTimestamp}
+
) : null}
@@ -1425,10 +1495,17 @@ const AssistantMessageBody: React.FC> = ({
) : null}
{footerTimestamp ? (
-
-
- {footerTimestamp}
-
+
+
+
+
+
+
+ {footerTimestamp}
+
) : null}
diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
index 64d672b3..2487908f 100644
--- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
+++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx
@@ -16,28 +16,29 @@ interface MenuPosition {
show: boolean;
}
-const MENU_TRANSITION_MS = 200;
+const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
+const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
export const TextSelectionMenu: React.FC
= ({ containerRef }) => {
const [position, setPosition] = React.useState({ x: 0, y: 0, show: false });
const [selectedText, setSelectedText] = React.useState('');
const [isDragging, setIsDragging] = React.useState(false);
- const [isClosing, setIsClosing] = React.useState(false);
const [isOpening, setIsOpening] = React.useState(false);
const menuRef = React.useRef(null);
+ const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
- const hideTimeoutRef = React.useRef(null);
const openRafRef = React.useRef(null);
+ const isMenuVisibleRef = React.useRef(false);
const createSession = useSessionStore((state) => state.createSession);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile);
+ React.useEffect(() => {
+ isMenuVisibleRef.current = position.show;
+ }, [position.show]);
+
React.useEffect(() => {
return () => {
- if (hideTimeoutRef.current !== null) {
- window.clearTimeout(hideTimeoutRef.current);
- hideTimeoutRef.current = null;
- }
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
@@ -46,40 +47,51 @@ export const TextSelectionMenu: React.FC = ({ containerR
}, []);
const hideMenu = React.useCallback(() => {
- if (hideTimeoutRef.current !== null) {
- window.clearTimeout(hideTimeoutRef.current);
- hideTimeoutRef.current = null;
+ pendingSelectionRef.current = null;
+
+ if (!isMenuVisibleRef.current) {
+ return;
}
+
if (openRafRef.current !== null) {
window.cancelAnimationFrame(openRafRef.current);
openRafRef.current = null;
}
setIsOpening(false);
- setIsClosing(true);
- hideTimeoutRef.current = window.setTimeout(() => {
- setPosition((prev) => ({ ...prev, show: false }));
- setSelectedText('');
- pendingSelectionRef.current = null;
- setIsClosing(false);
- hideTimeoutRef.current = null;
- }, MENU_TRANSITION_MS);
+ setPosition((prev) => ({ ...prev, show: false }));
+ setSelectedText('');
+ isMenuVisibleRef.current = false;
+ }, []);
+
+ const getDesktopClampedX = React.useCallback((anchorX: number) => {
+ if (typeof window === 'undefined') {
+ return anchorX;
+ }
+
+ const viewportWidth = window.innerWidth;
+ const menuWidth = menuWidthRef.current;
+ const halfWidth = menuWidth / 2;
+ const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
+ const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
+
+ if (minX > maxX) {
+ return viewportWidth / 2;
+ }
+
+ return Math.min(Math.max(anchorX, minX), maxX);
}, []);
const showMenu = React.useCallback(() => {
if (!pendingSelectionRef.current) return;
- if (hideTimeoutRef.current !== null) {
- window.clearTimeout(hideTimeoutRef.current);
- hideTimeoutRef.current = null;
- }
- setIsClosing(false);
-
const { text, rect } = pendingSelectionRef.current;
const shouldAnimateIn = !position.show;
// Position menu above the selection
- const menuX = rect.left + rect.width / 2;
+ const menuX = isMobile
+ ? rect.left + rect.width / 2
+ : getDesktopClampedX(rect.left + rect.width / 2);
const menuY = rect.top - 10;
setSelectedText(text);
@@ -88,6 +100,7 @@ export const TextSelectionMenu: React.FC = ({ containerR
y: menuY,
show: true,
});
+ isMenuVisibleRef.current = true;
if (shouldAnimateIn) {
setIsOpening(true);
@@ -99,7 +112,42 @@ export const TextSelectionMenu: React.FC = ({ containerR
openRafRef.current = null;
});
}
- }, [position.show]);
+ }, [getDesktopClampedX, isMobile, position.show]);
+
+ React.useLayoutEffect(() => {
+ if (!position.show || isMobile || !menuRef.current) {
+ return;
+ }
+
+ const measuredWidth = menuRef.current.offsetWidth;
+ if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) {
+ return;
+ }
+
+ menuWidthRef.current = measuredWidth;
+ setPosition((prev) => ({
+ ...prev,
+ x: getDesktopClampedX(prev.x),
+ }));
+ }, [getDesktopClampedX, isMobile, position.show]);
+
+ React.useEffect(() => {
+ if (!position.show || isMobile) {
+ return;
+ }
+
+ const handleViewportResize = () => {
+ setPosition((prev) => ({
+ ...prev,
+ x: getDesktopClampedX(prev.x),
+ }));
+ };
+
+ window.addEventListener('resize', handleViewportResize);
+ return () => {
+ window.removeEventListener('resize', handleViewportResize);
+ };
+ }, [getDesktopClampedX, isMobile, position.show]);
const handleSelectionChange = React.useCallback(() => {
const selection = window.getSelection();
@@ -247,11 +295,7 @@ export const TextSelectionMenu: React.FC = ({ containerR
'px-3 py-2',
'safe-area-bottom',
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
- isClosing
- ? 'opacity-0 translate-y-[4px] pointer-events-none'
- : isOpening
- ? 'opacity-0 translate-y-[4px]'
- : 'opacity-100 translate-y-0'
+ isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
)}
style={{
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
@@ -319,16 +363,12 @@ export const TextSelectionMenu: React.FC = ({ containerR
>
@@ -362,7 +402,7 @@ export const TextSelectionMenu: React.FC
= ({ containerR
type="button"
>
- New session
+ New session
,
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx
index fd16c8a5..cffb7f32 100644
--- a/packages/ui/src/components/layout/Header.tsx
+++ b/packages/ui/src/components/layout/Header.tsx
@@ -309,6 +309,9 @@ export const Header: React.FC = ({
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const showDesktopHeaderContextUsage = !isVSCode && activeMainTab === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0;
+ const desktopHeaderDisplayPercentage = stableDesktopContextUsage && stableDesktopContextUsage.contextLimit > 0
+ ? Math.min(999, (stableDesktopContextUsage.totalTokens / stableDesktopContextUsage.contextLimit) * 100)
+ : 0;
const refreshCurrentInstanceLabel = React.useCallback(async () => {
if (typeof window === 'undefined' || !isDesktopApp) {
@@ -1063,7 +1066,8 @@ export const Header: React.FC = ({
{showDesktopHeaderContextUsage && stableDesktopContextUsage && (
{
}
}, [isRightSidebarOpen, isMobile]);
- // Trigger update check 3 seconds after mount (for both mobile and desktop)
+ // Trigger initial update check shortly after mount, then every hour.
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
React.useEffect(() => {
- const timer = setTimeout(() => {
+ const initialDelayMs = 3000;
+ const periodicIntervalMs = 60 * 60 * 1000;
+
+ const timer = window.setTimeout(() => {
checkForUpdates();
- }, 3000);
- return () => clearTimeout(timer);
+ }, initialDelayMs);
+
+ const interval = window.setInterval(() => {
+ checkForUpdates();
+ }, periodicIntervalMs);
+
+ return () => {
+ window.clearTimeout(timer);
+ window.clearInterval(interval);
+ };
}, [checkForUpdates]);
React.useEffect(() => {
diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx
index 6a700174..c6296bab 100644
--- a/packages/ui/src/components/layout/ProjectEditDialog.tsx
+++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx
@@ -60,79 +60,147 @@ export const ProjectEditDialog: React.FC = ({
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
+ const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false);
+ const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState(null);
+ const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState(null);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef(null);
+ const clearPendingUploadIcon = React.useCallback(() => {
+ setPendingUploadIconFile(null);
+ setPendingUploadIconPreviewUrl((previousUrl) => {
+ if (previousUrl) {
+ URL.revokeObjectURL(previousUrl);
+ }
+ return null;
+ });
+ }, []);
+
React.useEffect(() => {
if (open) {
setName(projectName);
setIcon(initialIcon);
setColor(initialColor);
setIconBackground(normalizeIconBackground(initialIconBackground));
+ setPendingRemoveImageIcon(false);
+ clearPendingUploadIcon();
+ setPreviewImageFailed(false);
}
- }, [open, projectName, initialIcon, initialColor, initialIconBackground]);
+ }, [open, projectName, initialIcon, initialColor, initialIconBackground, clearPendingUploadIcon]);
- const handleSave = () => {
+ React.useEffect(() => {
+ return () => {
+ clearPendingUploadIcon();
+ };
+ }, [clearPendingUploadIcon]);
+
+ const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
- onSave({ label: trimmed, icon, color, iconBackground: normalizeIconBackground(iconBackground) });
+
+ if (pendingUploadIconFile) {
+ setIsUploadingIcon(true);
+ const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile);
+ setIsUploadingIcon(false);
+ if (!uploadResult.ok) {
+ toast.error(uploadResult.error || 'Failed to upload project icon');
+ return;
+ }
+ toast.success('Project icon updated');
+ clearPendingUploadIcon();
+ setPendingRemoveImageIcon(false);
+ }
+
+ const willRemoveImageIcon = pendingRemoveImageIcon && hasStoredImageIcon;
+
+ if (willRemoveImageIcon) {
+ setIsRemovingCustomIcon(true);
+ const result = await removeProjectIcon(projectId);
+ setIsRemovingCustomIcon(false);
+ if (!result.ok) {
+ toast.error(result.error || 'Failed to remove project icon');
+ return;
+ }
+ toast.success('Project icon removed');
+ setPendingRemoveImageIcon(false);
+ setIconBackground(null);
+ }
+
+ onSave({
+ label: trimmed,
+ icon,
+ color,
+ iconBackground: normalizeIconBackground(willRemoveImageIcon ? null : iconBackground),
+ });
onOpenChange(false);
};
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
- const hasImageIcon = Boolean(currentIconImage);
+ const hasStoredImageIcon = Boolean(currentIconImage);
+ const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl);
const hasCustomIcon = currentIconImage?.source === 'custom';
- const iconPreviewUrl = hasImageIcon && !previewImageFailed
- ? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null })
+ const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
+ const hasRemovableImageIcon = effectiveHasImageIcon;
+ const iconPreviewUrl = !previewImageFailed
+ ? (hasPendingUploadImageIcon
+ ? pendingUploadIconPreviewUrl
+ : (hasStoredImageIcon && !pendingRemoveImageIcon
+ ? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null })
+ : null))
: null;
React.useEffect(() => {
setPreviewImageFailed(false);
}, [projectId, currentIconImage?.updatedAt]);
- const handleUploadIcon = React.useCallback(async (file: File | null) => {
+ const handleUploadIcon = React.useCallback((file: File | null) => {
if (!projectId || !file || isUploadingIcon) {
return;
}
- setIsUploadingIcon(true);
- void uploadProjectIcon(projectId, file)
- .then((result) => {
- if (!result.ok) {
- toast.error(result.error || 'Failed to upload project icon');
- return;
- }
- toast.success('Project icon updated');
- })
- .finally(() => {
- setIsUploadingIcon(false);
- });
- }, [isUploadingIcon, projectId, uploadProjectIcon]);
+ setPendingRemoveImageIcon(false);
+ setPreviewImageFailed(false);
+ setPendingUploadIconFile(file);
+ setPendingUploadIconPreviewUrl((previousUrl) => {
+ if (previousUrl) {
+ URL.revokeObjectURL(previousUrl);
+ }
+ return URL.createObjectURL(file);
+ });
+ }, [isUploadingIcon, projectId]);
- const handleRemoveCustomIcon = React.useCallback(async () => {
- if (!projectId || !hasCustomIcon || isRemovingCustomIcon) {
+ const handleRemoveImageIcon = React.useCallback(() => {
+ if (!projectId || !hasRemovableImageIcon || isRemovingCustomIcon) {
return;
}
- setIsRemovingCustomIcon(true);
- void removeProjectIcon(projectId)
- .then((result) => {
- if (!result.ok) {
- toast.error(result.error || 'Failed to remove project icon');
- return;
- }
- toast.success('Custom project icon removed');
- })
- .finally(() => {
- setIsRemovingCustomIcon(false);
- });
- }, [hasCustomIcon, isRemovingCustomIcon, projectId, removeProjectIcon]);
+ if (hasPendingUploadImageIcon) {
+ clearPendingUploadIcon();
+ }
+ if (hasStoredImageIcon) {
+ setPendingRemoveImageIcon(true);
+ } else {
+ setPendingRemoveImageIcon(false);
+ }
+ setPreviewImageFailed(false);
+ }, [
+ clearPendingUploadIcon,
+ hasPendingUploadImageIcon,
+ hasRemovableImageIcon,
+ hasStoredImageIcon,
+ isRemovingCustomIcon,
+ projectId,
+ ]);
const handleDiscoverIcon = React.useCallback(async () => {
if (!projectId || isDiscoveringIcon) {
return;
}
+ clearPendingUploadIcon();
+ setPendingRemoveImageIcon(false);
+ setPreviewImageFailed(false);
+
setIsDiscoveringIcon(true);
void discoverProjectIcon(projectId)
.then((result) => {
@@ -149,7 +217,7 @@ export const ProjectEditDialog: React.FC = ({
.finally(() => {
setIsDiscoveringIcon(false);
});
- }, [discoverProjectIcon, isDiscoveringIcon, projectId]);
+ }, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId]);
return (