feat: massive chat reliability + UX pass (web/desktop/mobile/vscode) (#593)

## Added Features
- Add VS Code save-as-image flow for assistant messages via webview bridge + native save dialog.
- Add hourly desktop update checks after startup.
- Add new tool output display mode: `Changes` (auto-expand edit/write/patch only; keep activity expanded; mode guidance text).
- Add GitHub PR attachment flow in chat input with PR picker + attached PR chip/details.
- Add mobile overlay presentation for GitHub Issue and PR pickers (shared with desktop picker content).

## Fixes
- Save-gate project icon updates until explicit Save; allow icon removal with same save-gated behavior.
- Restore clickable chat action buttons in sticky header mode (desktop + Firefox hit-target issue).
- Clamp sticky user messages to bounded chat height and allow internal scrolling.
- Prevent drawer context crash during iPad/tablet orientation switching.
- Improve text-selection action menu placement on narrow screens.
- Move assistant message time into clock tooltip; keep duration display clean.
- Hide `Link GitHub Issue` row in VS Code chat input area (GitHub flow is not yet ready there).
- Remove laggy close animation in text-selection popover; keep open motion/positioning behavior.
- Fetch branches when picker opens and cache empty; show loading state instead of false “No branches found”.
- Fix share-image export metadata rendering (theme background resolution, timestamp rendering, footer alignment).
- Scope MCP services status/toggles to active directory to avoid cross-project leakage.
- Improve long user-message clamp behavior (40% cap variant, hidden scrollbar, scroll shadows, expansion detection).
- Fix desktop `Check for Updates` menu handler; prevent duplicate checks; show clear success/error toasts.
- Stabilize long user-message scrolling behavior (follow-up hardening).
- Avoid premature web update failure on slower servers.
- Restore user message image previews + fullscreen gallery navigation payload.
- Repair desktop chat drag-and-drop image attachments when native drop coords are missing.
- Move GitHub issue linking entry into Add attachment menu.
- Align header context usage percentage visuals with context panel.
- Align `@` file search with active project in all runtimes.
- Route `@` file discovery through OpenCode SDK `find.files`; remove legacy `/api/fs/search` reliance.
- Make chat `@` mention behavior consistent with files-style behavior.
- Keep status-row todos in stable order after status changes; add compact status icons; replace noisy priority labels.

## Refactors / UX Consistency
- Simplify chat attachment model and remove project file picker path.
- Keep composer focused on `@` mention file flow.
- Use direct `Attach files` action in VS Code instead of attachment dropdown path.
- Unify issue/PR picker behavior between desktop and mobile overlays.
This commit is contained in:
Bohdan Triapitsyn
2026-03-04 01:41:01 +02:00
committed by GitHub
parent ca18b8be0f
commit 79143bff4c
42 changed files with 2212 additions and 1477 deletions
@@ -327,6 +327,33 @@ export const ChatContainer: React.FC = () => {
trimToViewportWindow,
});
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) {
return;
}
const updateChatScrollHeight = () => {
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
};
updateChatScrollHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateChatScrollHeight);
return () => {
window.removeEventListener('resize', updateChatScrollHeight);
};
}
const resizeObserver = new ResizeObserver(updateChatScrollHeight);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
React.useEffect(() => {
cancelTurnBackfill();
if (!currentSessionId) {
+544 -204
View File
@@ -7,8 +7,8 @@ import {
RiCloseLine,
RiCommandLine,
RiExternalLinkLine,
RiFileUploadLine,
RiFullscreenLine,
RiGitPullRequestLine,
RiGithubLine,
RiSendPlane2Line,
} from '@remixicon/react';
@@ -26,7 +26,6 @@ import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAu
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
import { cn, isMacOS } from '@/lib/utils';
import { ServerFilePicker } from './ServerFilePicker';
import { ModelControls } from './ModelControls';
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
@@ -39,7 +38,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
import { useFileStore } from '@/stores/fileStore';
import { useMessageStore } from '@/stores/messageStore';
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime';
import { StopIcon } from '@/components/icons/StopIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -52,9 +51,13 @@ import {
} from '@/components/ui/dropdown-menu';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import { opencodeClient } from '@/lib/opencode/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
interface ChatInputProps {
onOpenSettings?: () => void;
@@ -127,6 +130,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const dropZoneRef = React.useRef<HTMLDivElement>(null);
const canAcceptDropRef = React.useRef(false);
const nativeDragInsideDropZoneRef = React.useRef(false);
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
@@ -142,7 +146,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
const attachedFiles = useSessionStore((state) => state.attachedFiles);
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
const addServerFile = useSessionStore((state) => state.addServerFile);
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
@@ -155,14 +158,177 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, isExpandedInput, setExpandedInput } = useUIStore();
const { working } = useAssistantStatus();
const { currentTheme } = useThemeSystem();
const chatSearchDirectory = useChatSearchDirectory();
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
const isDesktopExpanded = isExpandedInput && !isMobile;
const sendableAttachedFiles = React.useMemo(
() => attachedFiles.filter((file) => file.source !== 'server'),
[attachedFiles],
);
const hasInlineMentionForHighlight = React.useMemo(() => {
if (!message || !message.includes('@') || inputMode === 'shell') {
return false;
}
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
const mentionRegex = /@([^\s]+)/g;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(message)) !== null) {
const offset = match.index;
const charBefore = offset > 0 ? message[offset - 1] : null;
if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
continue;
}
const mentionPath = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
if (!mentionPath) {
continue;
}
if (knownAgentNames.has(mentionPath.toLowerCase())) {
return true;
}
if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) {
return true;
}
}
return false;
}, [agents, inputMode, message]);
const highlightedComposerContent = React.useMemo(() => {
if (!hasInlineMentionForHighlight) {
return null;
}
const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
const mentionRegex = /@([^\s]+)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(message)) !== null) {
const full = match[0];
const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
const start = match.index;
const end = start + full.length;
const charBefore = start > 0 ? message[start - 1] : null;
const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore);
const isAgentMention = isBoundary && mention.length > 0 && knownAgentNames.has(mention.toLowerCase());
const isFileMention = isBoundary
&& mention.length > 0
&& !knownAgentNames.has(mention.toLowerCase())
&& (mention.includes('/') || mention.includes('\\') || mention.includes('.'));
if (start > lastIndex) {
parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' });
}
parts.push({
text: full,
mentionKind: isFileMention ? 'file' : isAgentMention ? 'agent' : 'none',
});
lastIndex = end;
}
if (lastIndex < message.length) {
parts.push({ text: message.slice(lastIndex), mentionKind: 'none' });
}
return parts;
}, [agents, hasInlineMentionForHighlight, message]);
const sanitizeAttachmentsForSend = React.useCallback(
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
.filter((file) => file.source !== 'server')
.map((file) => ({ ...file })),
[],
);
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
if (!rawText || !rawText.includes('@')) {
return { sanitizedText: rawText, attachments: [] };
}
const clientDirectory = opencodeClient.getDirectory() || '';
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
const seenPaths = new Set<string>();
const attachments: AttachedFile[] = [];
const mentionRegex = /@([^\s]+)/g;
let match: RegExpExecArray | null;
while ((match = mentionRegex.exec(rawText)) !== null) {
const rawMentionPath = match[1];
const offset = match.index;
const original = rawText;
const charBefore = offset > 0 ? original[offset - 1] : null;
if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
continue;
}
const mentionPath = String(rawMentionPath || '')
.trim()
.replace(/^[`"'<(]+/, '')
.replace(/[),.;:!?`"'>]+$/g, '');
if (!mentionPath) {
continue;
}
if (knownAgentNames.has(mentionPath.toLowerCase())) {
continue;
}
const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.');
if (!looksLikeFilePath) {
continue;
}
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
if (!normalizedMentionPath) {
continue;
}
const serverPath = mentionPath.startsWith('/')
? mentionPath.replace(/\\/g, '/')
: root
? `${root}/${normalizedMentionPath}`
: null;
if (!serverPath) {
continue;
}
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
if (seenPaths.has(normalizedServerPath)) {
continue;
}
seenPaths.add(normalizedServerPath);
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
attachments.push({
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
file: new File([], filename, { type: 'text/plain' }),
filename,
mimeType: 'text/plain',
size: 0,
dataUrl: normalizedServerPath,
source: 'server',
serverPath: normalizedServerPath,
});
}
return {
sanitizedText: rawText,
attachments,
};
}, [agents, chatSearchDirectory]);
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWasAbortedRef = React.useRef(false);
// Issue linking state (for draft sessions)
// Issue linking state
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
const [linkedIssue, setLinkedIssue] = React.useState<{
number: number;
title: string;
@@ -170,6 +336,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
contextText: string;
author?: { login: string; avatarUrl?: string };
} | null>(null);
const [linkedPr, setLinkedPr] = React.useState<{
number: number;
title: string;
url: string;
head: string;
base: string;
includeDiff: boolean;
instructionsText: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
} | null>(null);
// Message queue
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
@@ -429,7 +606,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [pendingInputText, consumePendingInputText]);
const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts;
const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts;
const hasQueuedMessages = queuedMessages.length > 0;
const canSend = hasContent || hasQueuedMessages;
@@ -451,7 +628,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (drafts.length > 0) {
messageToQueue = appendInlineComments(messageToQueue, drafts);
}
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
addToQueue(currentSessionId, {
content: messageToQueue,
@@ -467,7 +644,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!isMobile) {
textareaRef.current?.focus();
}
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false;
@@ -499,6 +676,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
for (let i = 0; i < queuedMessages.length; i++) {
const queuedMsg = queuedMessages[i];
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
// Use agent mention from first message that has one
if (!agentMentionName && mention?.name) {
@@ -507,13 +685,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (i === 0) {
// First queued message becomes primary
primaryText = sanitizedText;
primaryAttachments = queuedMsg.attachments ?? [];
primaryText = queuedText;
primaryAttachments = [
...sanitizeAttachmentsForSend(queuedMsg.attachments),
...mentionAttachments,
];
} else {
// Subsequent queued messages become additional parts
const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments);
additionalParts.push({
text: sanitizedText,
attachments: queuedMsg.attachments,
text: queuedText,
attachments: [...queuedAttachments, ...mentionAttachments],
});
}
}
@@ -522,7 +704,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!queuedOnly && hasContent) {
const messageToSend = message.replace(/^\n+|\n+$/g, '');
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
if (!agentMentionName && mention?.name) {
agentMentionName = mention.name;
@@ -530,13 +713,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (queuedMessages.length === 0) {
// No queue - current input is primary
primaryText = sanitizedText;
primaryAttachments = attachmentsToSend;
primaryText = messageText;
primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
} else {
// Has queue - current input is additional part
additionalParts.push({
text: sanitizedText,
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
text: messageText,
attachments: [...attachmentsToSend, ...mentionAttachments],
});
}
}
@@ -570,13 +753,24 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Add linked issue as synthetic part (only the parts with synthetic: true)
// The text part (synthetic: false) is completely dropped per requirements
if (linkedIssue && newSessionDraftOpen) {
if (linkedIssue) {
additionalParts.push({
text: linkedIssue.contextText,
synthetic: true,
});
}
if (linkedPr) {
additionalParts.push({
text: linkedPr.instructionsText,
synthetic: true,
});
additionalParts.push({
text: linkedPr.contextText,
synthetic: true,
});
}
if (!primaryText && additionalParts.length === 0) return;
// Clear queue and input
@@ -649,10 +843,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
currentVariant,
inputMode
).then(() => {
// Clear linked issue after successful message send in draft mode
if (linkedIssue && newSessionDraftOpen) {
// Clear linked issue after successful message send
if (linkedIssue) {
setLinkedIssue(null);
}
if (linkedPr) {
setLinkedPr(null);
}
}).catch((error: unknown) => {
const rawMessage =
error instanceof Error
@@ -732,6 +929,48 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return;
}
if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) {
const textarea = textareaRef.current;
const selectionStart = textarea?.selectionStart ?? message.length;
const selectionEnd = textarea?.selectionEnd ?? message.length;
const hasCollapsedSelection = selectionStart === selectionEnd;
if (hasCollapsedSelection) {
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
if (probeIndex >= 0 && probeIndex < message.length) {
let tokenStart = probeIndex;
while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) {
tokenStart -= 1;
}
let tokenEnd = probeIndex + 1;
while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) {
tokenEnd += 1;
}
const token = message.slice(tokenStart, tokenEnd);
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
&& (token.includes('/') || token.includes('\\') || token.includes('.'));
if (looksLikeFileMention) {
const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd;
const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`;
e.preventDefault();
setMessage(nextMessage);
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = tokenStart;
textareaRef.current.selectionEnd = tokenStart;
}
adjustTextareaHeight();
});
updateAutocompleteState(nextMessage, tokenStart);
return;
}
}
}
}
if (showCommandAutocomplete && commandRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
@@ -1303,25 +1542,38 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
const handleFileSelect = (file: { name: string; path: string }) => {
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
const cursorPosition = textareaRef.current?.selectionStart || 0;
const textBeforeCursor = message.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
const mentionPath = (file.relativePath && file.relativePath.trim().length > 0)
? file.relativePath.trim()
: (toProjectRelativeMentionPath(file.path) || file.name);
if (lastAtSymbol !== -1) {
const newMessage =
message.substring(0, lastAtSymbol) +
file.name +
`@${mentionPath} ` +
message.substring(cursorPosition);
setMessage(newMessage);
const nextCursor = lastAtSymbol + mentionPath.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
textareaRef.current.selectionEnd = nextCursor;
}
adjustTextareaHeight();
updateAutocompleteState(newMessage, nextCursor);
});
} else if (textareaRef.current) {
const newMessage =
message.substring(0, cursorPosition) +
`@${file.name} ` +
`@${mentionPath} ` +
message.substring(cursorPosition);
setMessage(newMessage);
const nextCursor = cursorPosition + file.name.length + 2;
const nextCursor = cursorPosition + mentionPath.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
@@ -1600,6 +1852,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, []);
const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => {
const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim();
const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, '');
if (!normalizedRoot) {
return normalizedAbsolutePath;
}
if (normalizedAbsolutePath === normalizedRoot) {
return normalizedAbsolutePath;
}
const rootWithSlash = `${normalizedRoot}/`;
if (normalizedAbsolutePath.startsWith(rootWithSlash)) {
return normalizedAbsolutePath.slice(rootWithSlash.length);
}
return normalizedAbsolutePath;
}, [chatSearchDirectory]);
const handleDragEnter = (e: React.DragEvent) => {
if (!hasDraggedFiles(e.dataTransfer)) {
return;
@@ -1697,7 +1965,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Check if drop is inside the chat input area
const zone = dropZoneRef.current;
let inZone = false;
let inZone: boolean | null = null;
if (zone && typeof x === 'number' && typeof y === 'number') {
const rect = zone.getBoundingClientRect();
inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
@@ -1710,16 +1978,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
if (type === 'enter' || type === 'over') {
setIsDragging(inZone);
if (inZone !== null) {
nativeDragInsideDropZoneRef.current = inZone;
}
setIsDragging(nativeDragInsideDropZoneRef.current);
return;
}
if (type === 'leave') {
nativeDragInsideDropZoneRef.current = false;
setIsDragging(false);
return;
}
if (type === 'drop') {
const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current;
nativeDragInsideDropZoneRef.current = false;
setIsDragging(false);
if (!inZone) return;
if (!shouldHandleDrop) return;
const paths = Array.isArray(typed.paths)
? typed.paths.filter((p): p is string => typeof p === 'string')
@@ -1734,9 +2008,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
let file: File;
// In desktop shell on remote origin, local file paths are not readable via /api/fs/raw.
// Read bytes from local machine via Tauri command.
if (isTauriShell() && !isDesktopLocalOriginActive()) {
// In Tauri shell, dropped paths are local machine paths.
// Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
if (isTauriShell()) {
const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
const byteCharacters = atob(result.base64);
@@ -1788,30 +2062,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
};
}, [addAttachedFile, normalizeDroppedPath]);
const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => {
let attachedCount = 0;
for (const file of files) {
const sizeBefore = useSessionStore.getState().attachedFiles.length;
try {
await addServerFile(file.path, file.name);
const sizeAfter = useSessionStore.getState().attachedFiles.length;
if (sizeAfter > sizeBefore) {
attachedCount += 1;
}
} catch (error) {
console.error('Server file attach failed', error);
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
}
}
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
}
}, [addServerFile]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false);
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
let attachedCount = 0;
@@ -1992,55 +2243,60 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
/>
<div className="relative inline-flex">
<ServerFilePicker
onFilesSelected={handleServerFilesSelected}
multiSelect
presentation={isMobile ? 'modal' : 'dropdown'}
open={projectFilePickerOpen}
onOpenChange={setProjectFilePickerOpen}
>
{isMobile ? null : (
<button
type="button"
tabIndex={-1}
aria-hidden="true"
className="absolute inset-0 opacity-0 pointer-events-none"
/>
)}
</ServerFilePicker>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => handlePickLocalFiles());
}}
>
<RiAttachment2 />
Attach files
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => {
setProjectFilePickerOpen(true);
});
}}
>
<RiFileUploadLine />
Attach from project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isVSCode ? (
<button
type="button"
className={footerIconButtonClass}
onClick={() => handlePickLocalFiles()}
title="Attach files"
aria-label="Attach files"
>
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
</button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={footerIconButtonClass}
title="Add attachment"
aria-label="Add attachment"
>
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => handlePickLocalFiles());
}}
>
<RiAttachment2 />
Attach files
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => {
setIssuePickerOpen(true);
});
}}
>
<RiGithubLine />
Link GitHub Issue
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(() => {
setPrPickerOpen(true);
});
}}
>
<RiGitPullRequestLine />
Link GitHub PR
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
);
@@ -2165,67 +2421,104 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</div>
)}
{/* Linked Issue Button - only in draft mode */}
{newSessionDraftOpen && (
{/* Linked Issue row */}
{linkedIssue && !isVSCode && (
<div className="pb-2 w-full px-1">
{linkedIssue ? (
<button
type="button"
onClick={() => setIssuePickerOpen(true)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
{linkedIssue.author?.avatarUrl && (
<img
src={linkedIssue.author.avatarUrl}
alt={linkedIssue.author.login}
className="h-5 w-5 rounded-full flex-shrink-0"
/>
)}
<span className="text-muted-foreground flex-shrink-0">
#{linkedIssue.number}
{linkedIssue.author && (
<span className="ml-1">by {linkedIssue.author.login}</span>
)}
</span>
<span className="text-foreground truncate">
{linkedIssue.title}
</span>
<span className="flex items-center gap-0.5 flex-shrink-0">
<a
href={linkedIssue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open issue in browser"
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
<span
onClick={(e) => {
e.stopPropagation();
setLinkedIssue(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked issue"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</span>
</button>
) : (
<button
type="button"
onClick={() => setIssuePickerOpen(true)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
<RiGithubLine
className="h-4 w-4 flex-shrink-0"
style={{ color: currentTheme?.colors?.status?.success }}
<button
type="button"
onClick={() => setIssuePickerOpen(true)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
{linkedIssue.author?.avatarUrl && (
<img
src={linkedIssue.author.avatarUrl}
alt={linkedIssue.author.login}
className="h-5 w-5 rounded-full flex-shrink-0"
/>
<span className="text-muted-foreground">Link GitHub Issue</span>
</button>
)}
)}
<span className="text-muted-foreground flex-shrink-0">
#{linkedIssue.number}
{linkedIssue.author && (
<span className="ml-1">by {linkedIssue.author.login}</span>
)}
</span>
<span className="text-foreground truncate">
{linkedIssue.title}
</span>
<span className="flex items-center gap-0.5 flex-shrink-0">
<a
href={linkedIssue.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open issue in browser"
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
<span
onClick={(e) => {
e.stopPropagation();
setLinkedIssue(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked issue"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</span>
</button>
</div>
)}
{linkedPr && !isVSCode && (
<div className="pb-2 w-full px-1">
<button
type="button"
onClick={() => setPrPickerOpen(true)}
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
>
{linkedPr.author?.avatarUrl && (
<img
src={linkedPr.author.avatarUrl}
alt={linkedPr.author.login}
className="h-5 w-5 rounded-full flex-shrink-0"
/>
)}
<span className="text-muted-foreground flex-shrink-0">
PR #{linkedPr.number}
{linkedPr.author && (
<span className="ml-1">by {linkedPr.author.login}</span>
)}
</span>
<span className="text-foreground truncate">
{linkedPr.title}
</span>
<span className="text-muted-foreground flex-shrink-0 typography-meta">
{linkedPr.head} {linkedPr.base}
</span>
<span className="flex items-center gap-0.5 flex-shrink-0">
<a
href={linkedPr.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label="Open pull request in browser"
>
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
</a>
<span
onClick={(e) => {
e.stopPropagation();
setLinkedPr(null);
}}
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
aria-label="Remove linked pull request"
>
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
</span>
</span>
</button>
</div>
)}
<div
@@ -2332,49 +2625,85 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
: undefined}
/>
)}
<Textarea
ref={textareaRef}
data-chat-input="true"
value={message}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDownCapture={handleTextareaPointerDownCapture}
onKeyUp={updateAutocompleteOverlayPosition}
onClick={updateAutocompleteOverlayPosition}
onScroll={updateAutocompleteOverlayPosition}
onSelect={updateAutocompleteOverlayPosition}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? "Enter shell command..."
: "@ for files/agents; / for commands; ! for shell"
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"}
spellCheck={isMobile}
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
className={cn(
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
isDesktopExpanded
? 'h-full min-h-0 py-4'
: isMobile
? 'py-2.5'
: 'pt-4 pb-2',
inputMode === 'shell' && 'font-mono',
<div className="relative overflow-hidden">
{highlightedComposerContent && (
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none',
isDesktopExpanded
? 'h-full min-h-0 py-4'
: isMobile
? 'py-2.5'
: 'pt-4 pb-2',
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
)}
style={{ transform: `translateY(-${textareaScrollTop}px)` }}
>
{highlightedComposerContent.map((part, index) => (
<span
key={`${index}-${part.text.length}`}
className={
part.mentionKind === 'file'
? 'text-[var(--status-info)]'
: part.mentionKind === 'agent'
? 'text-[var(--status-success)]'
: 'text-foreground'
}
>
{part.text}
</span>
))}
</div>
)}
style={{
flex: isDesktopExpanded ? '1 1 auto' : 'none',
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
}}
rows={1}
/>
<Textarea
ref={textareaRef}
data-chat-input="true"
value={message}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPointerDownCapture={handleTextareaPointerDownCapture}
onKeyUp={updateAutocompleteOverlayPosition}
onClick={updateAutocompleteOverlayPosition}
onScroll={(event) => {
updateAutocompleteOverlayPosition();
setTextareaScrollTop(event.currentTarget.scrollTop);
}}
onSelect={updateAutocompleteOverlayPosition}
placeholder={currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? "Enter shell command..."
: "@ for files/agents; / for commands; ! for shell"
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"}
spellCheck={isMobile}
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
className={cn(
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
isDesktopExpanded
? 'h-full min-h-0 py-4'
: isMobile
? 'py-2.5'
: 'pt-4 pb-2',
inputMode === 'shell' && 'font-mono',
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
)}
style={{
flex: isDesktopExpanded ? '1 1 auto' : 'none',
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
}}
rows={1}
/>
</div>
<div
className={cn(
'bg-transparent',
@@ -2477,7 +2806,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
open={issuePickerOpen}
onOpenChange={setIssuePickerOpen}
mode="select"
onSelect={(issue) => setLinkedIssue(issue)}
onSelect={(issue) => {
setLinkedIssue(issue);
setLinkedPr(null);
}}
/>
<GitHubPrPickerDialog
open={prPickerOpen}
onOpenChange={setPrPickerOpen}
onSelect={(pr) => {
setLinkedPr(pr);
setLinkedIssue(null);
}}
/>
</>
);
@@ -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<string, Set<string>>();
@@ -48,8 +53,8 @@ const writeExpandedToolsCache = (messageId: string, value: Set<string>): 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<T>(value: T | null | undefined): T | null | undefined {
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
@@ -443,19 +448,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
// '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<string>();
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<ChatMessageProps> = ({
}
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<ChatMessageProps> = ({
/>
) : null}
</div>
{showStickyInlineHoverRow ? <div aria-hidden="true" className="absolute left-0 right-0 top-full h-11" /> : null}
{showStickyInlineHoverRow ? <div aria-hidden="true" className="pointer-events-none absolute left-0 right-0 top-full h-11" /> : null}
</div>
</FadeInOnReveal>
)
@@ -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 (
<div className="pb-4 w-full px-1 space-y-3">
@@ -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 (
<div className="space-y-1.5 mt-1.5">
{otherFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{otherFiles.map((file, index) => {
const fileName = extractFilename(file.filename || file.url);
const sizeText = formatFileSize(file.size);
return (
<Tooltip key={`file-${file.url || file.filename || index}`}>
<TooltipTrigger asChild>
<div className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg">
{file.mime?.includes('pdf') ? (
<RiFilePdfLine className="text-muted-foreground h-3.5 w-3.5" />
) : (
<RiFileLine className="text-muted-foreground h-3.5 w-3.5" />
)}
<div className="overflow-hidden max-w-[140px]">
<span className="truncate block" title={fileName}>{fileName}</span>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
{imageFiles.length > 0 && (
<div className="overflow-x-auto -mx-1 px-1 py-0.5 scrollbar-thin">
<div className="flex snap-x snap-mandatory gap-2">
{imageFiles.map((file, index) => {
const filename = extractFilename(file.filename) || 'Image';
return (
<Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleImageClick(index)}
className="relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
aria-label={filename}
>
{file.url ? (
<img
src={file.url}
alt={filename}
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.visibility = 'hidden';
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
<RiFileImageLine className="h-6 w-6" />
</div>
)}
<span className="sr-only">{filename}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
{filename}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
)}
</div>
);
}
return (
<div className={cn(
"grid gap-2",
@@ -1,11 +1,12 @@
import React from 'react';
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
import { cn, truncatePathMiddle } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
@@ -45,8 +46,24 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onTabSelect,
style,
}, ref) => {
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<FileMentionHandle, FileM
const normalizedSearchQuery = (searchQuery ?? '').trim();
const visibleAgents = normalizedSearchQuery.length > 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<string>();
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<FileMentionHandle, FileM
React.useEffect(() => {
if (!currentDirectory) {
setFiles([]);
setLoading(false);
return;
}
@@ -147,35 +153,27 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
.replace(/^\/+/, '')
.toLowerCase();
if (!normalizedQueryLower) {
setFiles([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
})
.then((hits) => {
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<FileMentionHandle, FileM
return () => {
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<FileMentionHandle, FileM
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [files, visibleAgents.length]);
}, [files, recentFiles.length, visibleAgents.length]);
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
@@ -292,11 +290,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
};
}, [selectedIndex]);
const handleFileSelect = React.useCallback(async (file: FileInfo) => {
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<FileMentionHandle, FileM
return;
}
const total = visibleAgents.length + files.length;
const total = visibleAgents.length + recentFiles.length + files.length;
if (total === 0) {
return;
}
@@ -333,13 +329,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
return;
}
const selectedFile = files[safeIndex - visibleAgents.length];
const fileIndex = safeIndex - visibleAgents.length;
const selectedFile = fileIndex < recentFiles.length
? recentFiles[fileIndex]
: files[fileIndex - recentFiles.length];
if (selectedFile) {
handleFileSelect(selectedFile);
}
}
}
}), [files, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
@@ -440,11 +439,68 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
);
})}
{visibleAgents.length > 0 && files.length > 0 && (
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
<div className="px-3 py-1 typography-meta text-muted-foreground">
Type to search more agents
</div>
)}
{visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{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 (
<div
key={`recent-${file.path}`}
ref={(el) => { 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)}
<span
ref={(el) => { 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}
>
<span
ref={(el) => { measureRefs.current[rowIndex] = el; }}
className="absolute invisible whitespace-nowrap pointer-events-none"
aria-hidden
>
{relativePath}
</span>
{isOverflowing && isSelected ? (
<span className="inline-block whitespace-nowrap file-mention-marquee">
{relativePath}
</span>
) : (
<span className="block truncate">
{displayPath}
</span>
)}
</span>
</div>
);
})}
{recentFiles.length > 0 && files.length > 0 && (
<div className="my-1 border-t border-border/60" />
)}
{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<FileMentionHandle, FileM
</React.Fragment>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
<div className="px-3 py-1 typography-meta text-muted-foreground">
Type to search more agents
</div>
)}
{files.length === 0 && visibleAgents.length === 0 && (
{files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found
</div>
@@ -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<ServerFilePickerProps> = ({
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<Set<string>>(new Set());
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileInfo[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
const [inFlightDirs, setInFlightDirs] = React.useState<Set<string>>(new Set());
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
const [searching, setSearching] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [attaching, setAttaching] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const updateInFlightDirs = React.useCallback((next: Set<string>) => {
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) ? (
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
) : (
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
);
}
return <FileTypeIcon filePath={file.path} extension={file.extension} className="h-3.5 w-3.5" />;
};
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<string, FileInfo>();
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 = (
<div
className={cn(
"flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded hover:bg-interactive-hover cursor-pointer typography-ui-label text-foreground text-left",
file.type === 'file' && selectedFiles.has(file.path) && "bg-primary/10"
)}
style={{ paddingLeft: `${level * 12}px` }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (file.type === 'file') {
toggleFileSelection(file.path);
}
}}
>
<div className="flex flex-1 items-center justify-start gap-1">
<span className="text-muted-foreground">{getFileIcon(file)}</span>
<span className="flex-1 truncate text-foreground text-left max-w-[360px]" aria-label={rawLabel}>
{displayLabel}
</span>
</div>
{file.type === 'file' && selectedFiles.has(file.path) && (
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
)}
</div>
);
if (!shouldCompact) {
return React.cloneElement(row, { key: file.path });
}
return (
<Tooltip key={file.path} delayDuration={120}>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<span className="typography-meta text-foreground/80 whitespace-pre-wrap break-all">
{rawLabel}
</span>
</TooltipContent>
</Tooltip>
);
};
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 (
<div key={file.path}>
<button
type="button"
className={cn(
'flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded cursor-pointer typography-ui-label text-foreground text-left',
!isDirectory && selectedFiles.has(file.path) && 'bg-primary/10'
)}
style={{ paddingLeft: `${level * 12}px` }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isDirectory) {
void toggleDirectory(file.path);
} else {
toggleFileSelection(file.path);
}
}}
>
<span className="text-muted-foreground">{getFileIcon(file)}</span>
<span className="flex-1 truncate text-foreground text-left">
{file.name}
</span>
{!isDirectory && selectedFiles.has(file.path) && (
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
)}
</button>
{isDirectory && isExpanded && children.length > 0 && (
<div>
{children.map((child) => renderFileTree(child, level + 1))}
</div>
)}
{isDirectory && isExpanded && isLoadingChildren && (
<div
className="px-2 py-1.5 typography-ui-label text-muted-foreground"
style={{ paddingLeft: `${(level + 1) * 12}px` }}
>
Loading
</div>
)}
</div>
);
};
const summaryLabel = selectedFiles.size > 0
? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected`
: 'No files selected';
const summarySection = (
<div className="flex items-center justify-between px-3 py-2 shrink-0">
<div className="typography-meta text-muted-foreground">{summaryLabel}</div>
<Button
size="sm"
onClick={handleConfirm}
disabled={selectedFiles.size === 0 || attaching}
className="h-6 typography-meta"
>
{attaching ? 'Attaching...' : 'Attach Files'}
</Button>
</div>
);
const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]';
const pickerBody = (
<>
<div className="px-3 py-2 border-b shrink-0">
<div className="font-medium typography-ui-label text-foreground">Select Project Files</div>
</div>
<div className="px-3 py-2 border-b shrink-0">
<div className="relative">
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files..."
className="pl-7 h-6 typography-ui-label"
onClick={(e) => e.stopPropagation()}
/>
{searchQuery && (
<button
onClick={(e) => {
e.stopPropagation();
setSearchQuery('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-interactive-hover rounded"
>
<RiCloseLine className="h-3 w-3"/>
</button>
)}
</div>
</div>
<ScrollArea className={scrollAreaClass}>
{loading && (
<div className="flex items-center justify-center py-8">
<div className="typography-ui-label text-muted-foreground">Loading files...</div>
</div>
)}
{error && (
<div className="flex items-center justify-center py-8">
<div className="typography-ui-label text-destructive">{error}</div>
</div>
)}
{!loading && !error && (
<div className="py-1 px-2">
{isSearchActive ? (
searching ? (
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
Searching files
</div>
) : (
searchResults.map((file) => renderFileItem(file, 0))
)
) : (
rootItems.map((file) => renderFileTree(file, 0))
)}
{!isSearchActive && rootItems.length === 0 && (
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
No files in this directory
</div>
)}
{isSearchActive && !searching && searchResults.length === 0 && (
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
No files found
</div>
)}
</div>
)}
</ScrollArea>
</>
);
const mobileTrigger = (
<span
className="inline-flex cursor-pointer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setMobileOpen(true);
}}
>
{children}
</span>
);
if (presentation === 'modal') {
return (
<>
{children ? (
<span
className="inline-flex cursor-pointer"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen(true);
}}
>
{children}
</span>
) : null}
<MobileOverlayPanel
open={open}
onClose={() => setOpen(false)}
title="Select Project Files"
footer={summarySection}
>
<div className="flex flex-col gap-0">{pickerBody}</div>
</MobileOverlayPanel>
</>
);
}
if (isCompact) {
return (
<>
{mobileTrigger}
<MobileOverlayPanel
open={mobileOpen}
onClose={() => setMobileOpen(false)}
title="Select Project Files"
footer={summarySection}
>
<div className="flex flex-col gap-0">{pickerBody}</div>
</MobileOverlayPanel>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
className={cn(
'p-0 overflow-hidden flex flex-col',
'w-[min(520px,calc(100vw-24px))]'
)}
align="start"
sideOffset={5}
collisionPadding={12}
>
{pickerBody}
<DropdownMenuSeparator />
{summarySection}
</DropdownMenuContent>
</DropdownMenu>
);
};
+52 -15
View File
@@ -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<TodoStatus, { textClassName: string }> = {
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <RiArrowUpDoubleLine className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <RiArrowUpSLine className="h-3.5 w-3.5" aria-hidden="true" />,
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
};
interface TodoItemRowProps {
todo: TodoItem;
}
@@ -29,8 +49,18 @@ interface TodoItemRowProps {
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const config = statusConfig[todo.status] || statusConfig.pending;
const statusIcon =
todo.status === "in_progress" ? (
<RiRecordCircleLine className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<RiCheckboxCircleLine className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<RiTimeLine className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-start min-w-0 py-0.5">
<div className="flex items-start min-w-0 py-0.5 gap-2">
<span className="mt-0.5 flex-shrink-0">{statusIcon}</span>
<span
className={cn(
"flex-1 typography-ui-label",
@@ -39,6 +69,15 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
>
{todo.content}
</span>
<span
className={cn(
"typography-meta flex-shrink-0",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
title={`${todo.priority} priority`}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</div>
);
};
@@ -89,18 +128,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
}
}, [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<TodoStatus, number> = {
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<StatusRowProps> = ({
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<StatusRowProps> = ({
<span className="typography-ui-label">Tasks</span>
)}
<span className="typography-meta">
{progress.completed}/{progress.total}
{statusSummary.active} active · {statusSummary.left} left
</span>
{isExpanded ? (
<RiArrowUpSLine className="h-3.5 w-3.5" />
@@ -514,7 +514,8 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ 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<ChatMessageEntry[]>(messages);
@@ -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
@@ -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}
>
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
<div
className={cn(
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
useStickyScrollableUserContent
? 'overflow-y-auto overscroll-contain scrollbar-none'
: 'overflow-y-hidden'
)}
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
>
{userContentParts.map((part, index) => {
if (isSubtaskPart(part)) {
return (
@@ -833,12 +843,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
contain: none;
`;
const timestampElements = clone.querySelectorAll<HTMLElement>('[aria-label^="Message time:"]');
const footerRowsAdjusted = new Set<HTMLElement>();
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<Omit<MessageBodyProps, 'isUser'>> = ({
{isSharing ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiShare2Line className="h-4 w-4" />
<RiImageDownloadLine className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
@@ -1399,10 +1462,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</span>
) : null}
{footerTimestamp ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiTimeLine className="h-3.5 w-3.5" />
{footerTimestamp}
</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<span
className="text-sm text-muted-foreground/60 tabular-nums flex items-center"
aria-label={`Message time: ${footerTimestamp}`}
>
<RiTimeLine className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{footerTimestamp}</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
@@ -1425,10 +1495,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</span>
) : null}
{footerTimestamp ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiTimeLine className="h-3.5 w-3.5" />
{footerTimestamp}
</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<span
className="text-sm text-muted-foreground/60 tabular-nums flex items-center"
aria-label={`Message time: ${footerTimestamp}`}
>
<RiTimeLine className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent sideOffset={6}>{footerTimestamp}</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
@@ -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<TextSelectionMenuProps> = ({ containerRef }) => {
const [position, setPosition] = React.useState<MenuPosition>({ 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<HTMLDivElement>(null);
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
const hideTimeoutRef = React.useRef<number | null>(null);
const openRafRef = React.useRef<number | null>(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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ containerR
y: menuY,
show: true,
});
isMenuVisibleRef.current = true;
if (shouldAnimateIn) {
setIsOpening(true);
@@ -99,7 +112,42 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ 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<TextSelectionMenuProps> = ({ containerR
>
<div
className={cn(
'flex items-center gap-1',
'flex items-center gap-1 whitespace-nowrap',
'rounded-lg border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none',
'px-1.5 py-1',
'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'
)}
>
<button
@@ -344,7 +384,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiAddLine className="h-4 w-4" />
<span>Add to chat</span>
<span className="whitespace-nowrap">Add to chat</span>
</button>
<div className="w-px h-4 bg-[var(--interactive-border)]" />
@@ -362,7 +402,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
type="button"
>
<RiChatNewLine className="h-4 w-4" />
<span>New session</span>
<span className="whitespace-nowrap">New session</span>
</button>
</div>
</div>,
+5 -1
View File
@@ -309,6 +309,9 @@ export const Header: React.FC<HeaderProps> = ({
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<HeaderProps> = ({
{showDesktopHeaderContextUsage && stableDesktopContextUsage && (
<ContextUsageDisplay
totalTokens={stableDesktopContextUsage.totalTokens}
percentage={stableDesktopContextUsage.percentage}
percentage={desktopHeaderDisplayPercentage}
colorPercentage={stableDesktopContextUsage.percentage}
contextLimit={stableDesktopContextUsage.contextLimit}
outputLimit={stableDesktopContextUsage.outputLimit ?? 0}
size="compact"
@@ -144,13 +144,24 @@ export const MainLayout: React.FC = () => {
}
}, [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(() => {
@@ -60,79 +60,147 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
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<File | null>(null);
const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState<string | null>(null);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(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<ProjectEditDialogProps> = ({
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [discoverProjectIcon, isDiscoveringIcon, projectId]);
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -273,7 +341,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
);
})}
</div>
{hasImageIcon && iconPreviewUrl && (
{effectiveHasImageIcon && iconPreviewUrl && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border/60 bg-[var(--surface-elevated)] p-1">
@@ -303,15 +371,20 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
</Button>
</>
)}
{hasCustomIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveCustomIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Custom Icon'}
{hasRemovableImageIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveImageIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
</Button>
)}
{pendingRemoveImageIcon && (
<Button size="sm" variant="outline" onClick={() => setPendingRemoveImageIcon(false)} disabled={isRemovingCustomIcon}>
Undo Remove
</Button>
)}
</div>
</div>
{hasImageIcon && (
{effectiveHasImageIcon && (
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
Icon Background
@@ -342,7 +415,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
<Button onClick={handleSave} disabled={!name.trim() || isUploadingIcon || isRemovingCustomIcon}>
Save
</Button>
</DialogFooter>
@@ -446,50 +446,6 @@ export const SidebarFilesTree: React.FC = () => {
// --- Fuzzy search scoring (matching FilesView) ---
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) return 0;
const c = candidate.toLowerCase();
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') continue;
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;
}, []);
React.useEffect(() => {
if (!currentDirectory) {
setSearchResults([]);
@@ -504,34 +460,20 @@ export const SidebarFilesTree: React.FC = () => {
return;
}
const normalizedQueryLower = trimmedQuery.toLowerCase();
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
})
.then((hits) => {
if (cancelled) return;
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
const ranked = filtered
.map((hit) => {
const label = hit.relativePath || hit.name || hit.path;
const score = fuzzyScore(normalizedQueryLower, label);
return score === null ? null : { hit, score, labelLength: label.length };
})
.filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>;
ranked.sort((a, b) => (
b.score - a.score
|| a.labelLength - b.labelLength
|| a.hit.path.localeCompare(b.hit.path)
));
const mapped: FileNode[] = ranked.map(({ hit }) => ({
const mapped: FileNode[] = filtered.map((hit) => ({
name: hit.name,
path: normalizePath(hit.path),
type: 'file',
@@ -555,7 +497,7 @@ export const SidebarFilesTree: React.FC = () => {
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
// --- Git status helpers (matching FilesView) ---
@@ -49,10 +49,11 @@ const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
},
];
const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed'; label: string; description: string }> = [
{ value: 'collapsed', label: 'Collapsed', description: 'Activity and tools start collapsed' },
{ value: 'activity', label: 'Summary', description: 'Activity expanded, tools collapsed' },
{ value: 'detailed', label: 'Detailed', description: 'Activity expanded, key tools expanded' },
const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed' | 'changes'; label: string; description: string }> = [
{ value: 'collapsed', label: 'Collapsed', description: 'Activity and tool calls stay collapsed by default.' },
{ value: 'activity', label: 'Summary', description: 'Activity opens by default; tool calls stay collapsed.' },
{ value: 'detailed', label: 'Detailed', description: 'Activity opens; key tools auto-expand for richer detail.' },
{ value: 'changes', label: 'Changes', description: 'Activity opens; only edit/write/patch tools auto-expand.' },
];
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
@@ -242,6 +243,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('queueMode')
|| shouldShow('textJustificationActivity')
|| shouldShow('persistDraft');
const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion);
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
const [pwaInstallName, setPwaInstallName] = React.useState('');
@@ -710,6 +712,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return (
<ButtonSmall
key={option.value}
type="button"
variant="outline"
size="xs"
className={cn(
@@ -725,6 +728,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
);
})}
</div>
{selectedToolExpansionOption && (
<p className="mt-2 typography-ui-label font-normal text-muted-foreground">
{selectedToolExpansionOption.description}
</p>
)}
</section>
)}
@@ -43,9 +43,22 @@ export const ProjectsPage: 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<File | null>(null);
const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState<string | null>(null);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const clearPendingUploadIcon = React.useCallback(() => {
setPendingUploadIconFile(null);
setPendingUploadIconPreviewUrl((previousUrl) => {
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
return null;
});
}, []);
const selectedProjectRef = React.useMemo(() => {
if (!selectedProject) {
return null;
@@ -65,71 +78,139 @@ export const ProjectsPage: React.FC = () => {
setIcon(selectedProject.icon ?? null);
setColor(selectedProject.color ?? null);
setIconBackground(selectedProject.iconBackground ?? null);
setPendingRemoveImageIcon(false);
clearPendingUploadIcon();
setPreviewImageFailed(false);
}, [selectedProject]);
}, [selectedProject, clearPendingUploadIcon]);
React.useEffect(() => {
return () => {
clearPendingUploadIcon();
};
}, [clearPendingUploadIcon]);
const hasChanges = Boolean(selectedProject) && (
name.trim() !== (selectedProject?.label ?? '').trim()
|| icon !== (selectedProject?.icon ?? null)
|| color !== (selectedProject?.color ?? null)
|| iconBackground !== (selectedProject?.iconBackground ?? null)
|| pendingRemoveImageIcon
|| Boolean(pendingUploadIconFile)
);
const handleSave = React.useCallback(() => {
const handleSave = React.useCallback(async () => {
if (!selectedProject) return;
updateProjectMeta(selectedProject.id, { label: name.trim(), icon, color, iconBackground });
}, [color, icon, iconBackground, name, selectedProject, updateProjectMeta]);
if (pendingUploadIconFile) {
setIsUploadingIcon(true);
const uploadResult = await uploadProjectIcon(selectedProject.id, 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 && Boolean(selectedProject.iconImage);
if (willRemoveImageIcon) {
setIsRemovingCustomIcon(true);
const removeResult = await removeProjectIcon(selectedProject.id);
setIsRemovingCustomIcon(false);
if (!removeResult.ok) {
toast.error(removeResult.error || 'Failed to remove project icon');
return;
}
toast.success('Project icon removed');
setPendingRemoveImageIcon(false);
setIconBackground(null);
}
updateProjectMeta(selectedProject.id, {
label: name.trim(),
icon,
color,
iconBackground: willRemoveImageIcon ? null : iconBackground,
});
}, [
color,
icon,
iconBackground,
name,
pendingUploadIconFile,
pendingRemoveImageIcon,
clearPendingUploadIcon,
uploadProjectIcon,
removeProjectIcon,
selectedProject,
updateProjectMeta,
]);
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
const hasImageIcon = Boolean(selectedProject?.iconImage);
const hasStoredImageIcon = Boolean(selectedProject?.iconImage);
const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl);
const hasCustomIcon = selectedProject?.iconImage?.source === 'custom';
const iconPreviewUrl = selectedProject && hasImageIcon && !previewImageFailed
? getProjectIconImageUrl(selectedProject)
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
const iconPreviewUrl = !previewImageFailed
? (hasPendingUploadImageIcon
? pendingUploadIconPreviewUrl
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
? getProjectIconImageUrl(selectedProject)
: null))
: null;
const handleUploadIcon = React.useCallback(async (file: File | null) => {
const handleUploadIcon = React.useCallback((file: File | null) => {
if (!selectedProject || !file || isUploadingIcon) {
return;
}
setIsUploadingIcon(true);
void uploadProjectIcon(selectedProject.id, 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, selectedProject, uploadProjectIcon]);
setPendingRemoveImageIcon(false);
setPreviewImageFailed(false);
setPendingUploadIconFile(file);
setPendingUploadIconPreviewUrl((previousUrl) => {
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
return URL.createObjectURL(file);
});
}, [isUploadingIcon, selectedProject]);
const handleRemoveCustomIcon = React.useCallback(async () => {
if (!selectedProject || !hasCustomIcon || isRemovingCustomIcon) {
const handleRemoveImageIcon = React.useCallback(() => {
if (!selectedProject || !hasRemovableImageIcon || isRemovingCustomIcon) {
return;
}
setIsRemovingCustomIcon(true);
void removeProjectIcon(selectedProject.id)
.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, removeProjectIcon, selectedProject]);
if (hasPendingUploadImageIcon) {
clearPendingUploadIcon();
}
if (hasStoredImageIcon) {
setPendingRemoveImageIcon(true);
} else {
setPendingRemoveImageIcon(false);
}
setPreviewImageFailed(false);
}, [
clearPendingUploadIcon,
hasPendingUploadImageIcon,
hasRemovableImageIcon,
hasStoredImageIcon,
isRemovingCustomIcon,
selectedProject,
]);
const handleDiscoverIcon = React.useCallback(async () => {
if (!selectedProject || isDiscoveringIcon) {
return;
}
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
setPreviewImageFailed(false);
setIsDiscoveringIcon(true);
void discoverProjectIcon(selectedProject.id)
.then((result) => {
@@ -146,7 +227,7 @@ export const ProjectsPage: React.FC = () => {
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [discoverProjectIcon, isDiscoveringIcon, selectedProject]);
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject]);
if (!selectedProject) {
return (
@@ -279,7 +360,7 @@ export const ProjectsPage: React.FC = () => {
);
})}
</div>
{hasImageIcon && iconPreviewUrl && (
{effectiveHasImageIcon && iconPreviewUrl && (
<div className="mt-2 flex items-center gap-2">
<span className="typography-meta text-muted-foreground">Preview</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
@@ -298,7 +379,7 @@ export const ProjectsPage: React.FC = () => {
</span>
</div>
)}
{hasImageIcon && (
{effectiveHasImageIcon && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
type="color"
@@ -349,15 +430,26 @@ export const ProjectsPage: React.FC = () => {
</ButtonSmall>
</>
)}
{hasCustomIcon && (
{hasRemovableImageIcon && (
<ButtonSmall
size="xs"
className="!font-normal"
variant="outline"
onClick={() => void handleRemoveCustomIcon()}
onClick={() => void handleRemoveImageIcon()}
disabled={isRemovingCustomIcon}
>
{isRemovingCustomIcon ? 'Removing...' : 'Remove Custom Icon'}
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
</ButtonSmall>
)}
{pendingRemoveImageIcon && (
<ButtonSmall
size="xs"
className="!font-normal"
variant="outline"
onClick={() => setPendingRemoveImageIcon(false)}
disabled={isRemovingCustomIcon}
>
Undo Remove
</ButtonSmall>
)}
</div>
@@ -368,7 +460,7 @@ export const ProjectsPage: React.FC = () => {
<div className="mt-0.5 px-2 py-1">
<ButtonSmall
onClick={handleSave}
disabled={!hasChanges || name.trim().length === 0}
disabled={!hasChanges || name.trim().length === 0 || isUploadingIcon || isRemovingCustomIcon}
size="xs"
className="!font-normal"
>
@@ -8,6 +8,7 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import {
RiCheckboxBlankLine,
@@ -79,6 +80,7 @@ export function GitHubIssuePickerDialog({
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isMobile = useUIStore((state) => state.isMobile);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
@@ -512,32 +514,24 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
}
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
{mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue'}
</DialogTitle>
<DialogDescription>
{mode === 'select'
? 'Select an issue to link to this session.'
: 'Seeds a new session with hidden issue context (title/body/labels/comments).'}
</DialogDescription>
</DialogHeader>
const title = mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue';
const description = mode === 'select'
? 'Select an issue to link to this session.'
: 'Seeds a new session with hidden issue context (title/body/labels/comments).';
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste issue URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
const content = (
<>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste issue URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className="flex-1 overflow-y-auto mt-2">
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
) : null}
@@ -649,9 +643,9 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
</button>
</div>
) : null}
</div>
</div>
{mode !== 'select' && (
{mode !== 'select' && (
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
@@ -703,7 +697,45 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
</div>
</div>
</div>
)}
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
@@ -0,0 +1,489 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import {
RiGithubLine,
RiLoader4Line,
RiSearchLine,
RiExternalLinkLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
const parsePrNumber = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i);
if (urlMatch) {
const parsed = Number(urlMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
const hashMatch = trimmed.match(/^#?(\d+)$/);
if (hashMatch) {
const parsed = Number(hashMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
};
const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => {
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
const PR_REVIEW_INSTRUCTIONS = `Before reporting issues:
- First identify the PR intent (what it's trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep.
- Gather any needed repository context (code, config, docs) to validate assumptions.
- No speculation: if something is unclear or cannot be verified, say what's missing and ask for it instead of guessing.
Output rules:
- Start with a 1-2 sentence summary.
- Provide a single concise PR review comment.
- No emojis. No code snippets. No fenced blocks.
- Short inline code identifiers allowed, but no snippets or fenced blocks.
- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren't available, cite the file and say "approx" + why.
- Keep the entire comment under ~300 words.
Report:
- Must-fix issues (blocking)-brief why and a one-line action each.
- Nice-to-have improvements (optional)-brief why and a one-line action each.
Quality & safety (general):
- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks.
- Call out missing tests/verification steps and suggest the minimal validation needed.
- Note readability/maintainability issues when they materially affect future changes.
Applicability (only if relevant):
- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why.
Architecture:
- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility).
Precedence:
- If local precedent conflicts with best practices, state it and suggest a follow-up task.
Do not implement changes until I confirm; end with a short "Next actions" sentence describing the recommended plan.
Format exactly:
Must-fix:
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
Nice-to-have:
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
If no issues, write:
Must-fix:
- None
Nice-to-have:
- None`;
export function GitHubPrPickerDialog({
open,
onOpenChange,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect?: (pr: {
number: number;
title: string;
url: string;
head: string;
base: string;
includeDiff: boolean;
instructionsText: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
}) => void;
}) {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isMobile = useUIStore((state) => state.isMobile);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const [query, setQuery] = React.useState('');
const [includeDiff, setIncludeDiff] = React.useState(false);
const [result, setResult] = React.useState<GitHubPullRequestsListResult | null>(null);
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [loadingPrNumber, setLoadingPrNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setPrs([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!github?.prsList) {
setResult(null);
setError('GitHub runtime API unavailable');
return;
}
setIsLoading(true);
setError(null);
try {
const next = await github.prsList(projectDirectory, { page: 1 });
setResult(next);
setPrs(next.prs ?? []);
setPage(next.page ?? 1);
setHasMore(Boolean(next.hasMore));
if (next.connected === false) {
setError(null);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!github?.prsList) return;
if (isLoadingMore || isLoading) return;
if (!hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = await github.prsList(projectDirectory, { page: nextPage });
setResult(next);
setPrs((prev) => [...prev, ...(next.prs ?? [])]);
setPage(next.page ?? nextPage);
setHasMore(Boolean(next.hasMore));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load more pull requests', { description: message });
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
React.useEffect(() => {
if (!open) {
setQuery('');
setIncludeDiff(false);
setLoadingPrNumber(null);
setError(null);
setResult(null);
setPrs([]);
setPage(1);
setHasMore(false);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setPrs([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [githubAuthChecked, githubAuthStatus, open]);
const connected = githubAuthChecked ? result?.connected !== false : true;
const openGitHubSettings = React.useCallback(() => {
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return prs;
return prs.filter((pr) => {
if (String(pr.number) === q.replace(/^#/, '')) return true;
return pr.title.toLowerCase().includes(q);
});
}, [prs, query]);
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
const attachPr = React.useCallback(async (prNumber: number) => {
if (!projectDirectory) {
toast.error('No active project');
return;
}
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
return;
}
if (loadingPrNumber) return;
setLoadingPrNumber(prNumber);
try {
const context = await github.prContext(projectDirectory, prNumber, {
includeDiff,
includeCheckDetails: false,
});
if (context.connected === false) {
toast.error('GitHub not connected');
return;
}
if (!context.pr) {
toast.error('Pull request not found');
return;
}
if (!context.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
});
return;
}
if (onSelect) {
onSelect({
number: context.pr.number,
title: context.pr.title,
url: context.pr.url,
head: context.pr.head,
base: context.pr.base,
includeDiff,
instructionsText: PR_REVIEW_INSTRUCTIONS,
contextText: buildPullRequestContextText(context),
author: context.pr.author
? {
login: context.pr.author.login,
avatarUrl: context.pr.author.avatarUrl,
}
: undefined,
});
}
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load pull request details', { description: message });
} finally {
setLoadingPrNumber(null);
}
}, [github, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory]);
const title = 'Link GitHub Pull Request';
const description = 'Select a pull request to attach review context to this message.';
const content = (
<>
<div className="mt-2 flex items-center gap-3">
<div className="relative flex-1 min-w-0">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste pull request URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<button
type="button"
onClick={() => setIncludeDiff((prev) => !prev)}
className="h-9 shrink-0 flex items-center gap-1 text-left"
aria-pressed={includeDiff}
aria-label="Include PR diff in attached context"
>
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel="Include PR diff in attached context"
className="size-6"
iconClassName="size-5"
/>
<span className="typography-small text-muted-foreground whitespace-nowrap">Include PR diff</span>
</button>
</div>
<div className={cn(isMobile ? 'min-h-0' : 'flex-1 overflow-y-auto')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading pull requests...
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>GitHub not connected. Connect your GitHub account in settings.</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
Open settings
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && github && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use pull request #{directNumber}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === directNumber ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No pull requests found' : 'No open pull requests found'}</div>
) : null}
{filtered.map((pr) => (
<div
key={pr.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === pr.number && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(pr.number)}
>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
<span className="text-muted-foreground mr-1">#{pr.number}</span>
{pr.title}
</p>
<p className="typography-meta text-muted-foreground truncate">{pr.head} {pr.base}</p>
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === pr.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && github ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(loadingPrNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(loadingPrNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
</span>
) : (
'Load more'
)}
</button>
</div>
) : null}
</div>
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}
@@ -45,7 +45,7 @@ import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { opencodeClient } from '@/lib/opencode/client';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitBranches } from '@/stores/useGitStore';
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
@@ -195,7 +195,7 @@ export function NewWorktreeDialog({
onOpenChange,
onWorktreeCreated,
}: NewWorktreeDialogProps) {
const { github } = useRuntimeAPIs();
const { github, git } = useRuntimeAPIs();
const isMobile = useUIStore((state) => state.isMobile);
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -230,6 +230,14 @@ export function NewWorktreeDialog({
// Use cached branches from Git store (instant if already fetched)
const branches = useGitBranches(projectDirectory);
const isLoadingBranches = useGitStore((state) => state.isLoadingBranches);
const fetchBranches = useGitStore((state) => state.fetchBranches);
React.useEffect(() => {
if (!open || !projectDirectory || !git) return;
if (branches?.all) return;
void fetchBranches(projectDirectory, git);
}, [open, projectDirectory, git, branches?.all, fetchBranches]);
// Compute local and remote branch lists (same pattern as GitView)
const localBranches = React.useMemo(() => {
@@ -1052,7 +1060,11 @@ Nice-to-have:
onClose={() => setExistingBranchPickerOpen(false)}
>
<div className="space-y-4">
{localBranches.length === 0 && remoteBranches.length === 0 ? (
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
No branches found
</div>
@@ -1267,7 +1279,11 @@ Nice-to-have:
onClose={() => setSourceBranchPickerOpen(false)}
>
<div className="space-y-4">
{localBranches.length === 0 && remoteBranches.length === 0 ? (
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
No branches found
</div>
@@ -1438,7 +1454,11 @@ Nice-to-have:
<SelectValue placeholder="Choose a branch..." />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{localBranches.length === 0 && remoteBranches.length === 0 ? (
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
No branches found
</div>
@@ -1598,7 +1618,11 @@ Nice-to-have:
<SelectValue placeholder="Select source branch..." />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{localBranches.length === 0 && remoteBranches.length === 0 ? (
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
No branches found
</div>
@@ -7,6 +7,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
interface ContextUsageDisplayProps {
totalTokens: number;
percentage: number;
colorPercentage?: number;
contextLimit: number;
outputLimit?: number;
size?: 'default' | 'compact';
@@ -23,6 +24,7 @@ interface ContextUsageDisplayProps {
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
totalTokens,
percentage,
colorPercentage,
contextLimit,
outputLimit,
size = 'default',
@@ -36,6 +38,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
pressed = false,
}) => {
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
const colorPct = typeof colorPercentage === 'number' ? colorPercentage : percentage;
const formatTokens = (tokens: number) => {
if (tokens >= 1_000_000) {
@@ -69,14 +72,14 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
{showPercentIcon ? (
<>
<RiDonutChartFill
className={cn('h-3.5 w-3.5', percentIconClassName, getPercentageColor(percentage))}
className={cn('h-3.5 w-3.5', percentIconClassName, getPercentageColor(colorPct))}
aria-hidden="true"
/>
<span className="text-foreground">{Math.min(percentage, 999).toFixed(1)}%</span>
</>
) : (
<>
<span className={getPercentageColor(percentage)}>{Math.min(percentage, 999).toFixed(1)}</span>%
<span className={getPercentageColor(colorPct)}>{Math.min(percentage, 999).toFixed(1)}</span>%
</>
)}
</span>
@@ -141,7 +144,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
</div>
<div className="flex justify-between items-center pt-1 border-t border-border/40">
<span className="typography-meta text-muted-foreground">Usage</span>
<span className={cn('typography-meta font-semibold', getPercentageColor(percentage))}>
<span className={cn('typography-meta font-semibold', getPercentageColor(colorPct))}>
{Math.min(percentage, 999).toFixed(1)}%
</span>
</div>
@@ -131,7 +131,12 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
mutationObserver?.observe(el, { childList: true, subtree: true });
mutationObserver?.observe(el, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
@@ -114,6 +114,9 @@ type InstallWebUpdateResult = {
autoRestart?: boolean;
};
const WEB_UPDATE_POLL_INTERVAL_MS = 2000;
const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000;
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
try {
const response = await fetch('/api/openchamber/update-install', {
@@ -148,7 +151,11 @@ async function isServerReachable(): Promise<boolean> {
}
}
async function waitForUpdateApplied(previousVersion?: string, maxAttempts = 40, intervalMs = 2000): Promise<boolean> {
async function waitForUpdateApplied(
previousVersion?: string,
maxAttempts = Math.ceil(WEB_UPDATE_MAX_WAIT_MS / WEB_UPDATE_POLL_INTERVAL_MS),
intervalMs = WEB_UPDATE_POLL_INTERVAL_MS,
): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch('/api/openchamber/update-check', {
@@ -268,7 +275,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
window.location.reload();
} else {
setWebUpdateState('error');
setWebError('Update did not apply. Refresh and try again, or run: openchamber update');
setWebError('Update is taking longer than expected. Wait a bit and refresh, or run: openchamber update');
}
}, [info?.currentVersion]);
+3 -68
View File
@@ -1019,56 +1019,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
done();
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) {
return 0;
}
const c = candidate.toLowerCase();
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') {
continue;
}
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;
}, []);
React.useEffect(() => {
if (!currentDirectory) {
setSearchResults([]);
@@ -1083,13 +1033,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
const normalizedQueryLower = trimmedQuery.toLowerCase();
let cancelled = false;
setSearching(true);
searchFiles(currentDirectory, trimmedQuery, 150, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
})
.then((hits) => {
if (cancelled) {
@@ -1098,22 +1048,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
// Apply fuzzy scoring and sort by score
const ranked = filtered
.map((hit) => {
const label = hit.relativePath || hit.name || hit.path;
const score = fuzzyScore(normalizedQueryLower, label);
return score === null ? null : { hit, score, labelLength: label.length };
})
.filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>;
ranked.sort((a, b) => (
b.score - a.score
|| a.labelLength - b.labelLength
|| a.hit.path.localeCompare(b.hit.path)
));
const mapped: FileNode[] = ranked.map(({ hit }) => ({
const mapped: FileNode[] = filtered.map((hit) => ({
name: hit.name,
path: normalizePath(hit.path),
type: 'file',
@@ -1137,7 +1072,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
const readFile = React.useCallback(async (path: string): Promise<string> => {
if (files.readFile) {