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
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -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) {
@@ -36,3 +36,6 @@ export const useDrawer = (): DrawerContextValue => {
}
return context;
};
// eslint-disable-next-line react-refresh/only-export-components
export const useOptionalDrawer = (): DrawerContextValue | null => React.useContext(DrawerContext);
@@ -0,0 +1,42 @@
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import type { Session } from '@opencode-ai/sdk/v2';
export const useChatSearchDirectory = (): string | undefined => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const worktreeMap = useSessionStore((state) => state.worktreeMetadata);
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
if (currentSessionId) {
const worktreeMetadata = worktreeMap.get(currentSessionId);
if (worktreeMetadata?.path) {
return worktreeMetadata.path;
}
type SessionWithDirectory = Session & { directory?: string };
const currentSession = sessions.find((session) => session.id === currentSessionId) as SessionWithDirectory | undefined;
if (currentSession?.directory) {
return currentSession.directory;
}
}
if (newSessionDraft?.open && newSessionDraft.directoryOverride) {
return newSessionDraft.directoryOverride;
}
if (activeProjectId) {
const activeProject = projects.find((project) => project.id === activeProjectId);
if (activeProject?.path) {
return activeProject.path;
}
}
return fallbackDirectory ?? undefined;
};
+6 -3
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { animate } from 'motion/react';
import { useDrawer } from '@/contexts/DrawerContext';
import { useOptionalDrawer } from '@/contexts/DrawerContext';
type DrawerSwipeOptions = {
edgeSide?: 'left' | 'right';
@@ -11,7 +11,7 @@ type DrawerSwipeOptions = {
};
export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
const drawer = useDrawer();
const drawer = useOptionalDrawer();
const {
edgeSide,
strictHorizontalIntent = false,
@@ -25,13 +25,15 @@ export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
const isDraggingDrawerRef = React.useRef<'left' | 'right' | null>(null);
const handleTouchStart = React.useCallback((e: React.TouchEvent) => {
if (!drawer) return;
touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY;
isHorizontalSwipeRef.current = null;
isDraggingDrawerRef.current = null;
}, []);
}, [drawer]);
const handleTouchMove = React.useCallback((e: React.TouchEvent) => {
if (!drawer) return;
const currentX = e.touches[0].clientX;
const currentY = e.touches[0].clientY;
const deltaX = currentX - touchStartXRef.current;
@@ -106,6 +108,7 @@ export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
}, [activationDistance, drawer, edgeSide, horizontalIntentRatio, onlyWhenClosed, strictHorizontalIntent]);
const handleTouchEnd = React.useCallback((e: React.TouchEvent) => {
if (!drawer) return;
if (isHorizontalSwipeRef.current !== true) return;
const endX = e.changedTouches[0].clientX;
+38 -2
View File
@@ -3,6 +3,7 @@ import { toast } from '@/components/ui';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { sessionEvents } from '@/lib/sessionEvents';
import { isTauriShell } from '@/lib/desktop';
@@ -58,8 +59,35 @@ export const useMenuActions = (
setAboutDialogOpen,
} = useUIStore();
const { addProject } = useProjectsStore();
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
const { requestAccess, startAccessing } = useFileSystemAccess();
const { setThemeMode } = useThemeSystem();
const checkUpdatesInFlightRef = React.useRef(false);
const handleCheckForUpdates = React.useCallback(() => {
if (checkUpdatesInFlightRef.current) {
return;
}
checkUpdatesInFlightRef.current = true;
void checkForUpdates()
.then(() => {
const { available, error } = useUpdateStore.getState();
if (error) {
toast.error('Failed to check for updates', {
description: error,
});
return;
}
if (!available) {
toast.success('You are on the latest version');
}
})
.finally(() => {
checkUpdatesInFlightRef.current = false;
});
}, [checkForUpdates]);
const handleChangeWorkspace = React.useCallback(() => {
if (isTauriShell()) {
@@ -216,9 +244,17 @@ export const useMenuActions = (
handleAction(action);
};
const handleCheckForUpdatesEvent = () => {
handleCheckForUpdates();
};
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
}, [handleAction]);
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
return () => {
window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
};
}, [handleAction, handleCheckForUpdates]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
+1 -1
View File
@@ -524,7 +524,7 @@ export interface SettingsPayload {
autoDeleteAfterDays?: number;
queueModeEnabled?: boolean;
gitmojiEnabled?: boolean;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
fontSize?: number;
terminalFontSize?: number;
padding?: number;
+1 -1
View File
@@ -24,7 +24,7 @@ type AppearanceSlice = {
maxLastMessageLength: number;
autoDeleteEnabled: boolean;
autoDeleteAfterDays: number;
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
toolCallExpansion: 'collapsed' | 'activity' | 'detailed' | 'changes';
fontSize: number;
terminalFontSize: number;
padding: number;
+1 -1
View File
@@ -111,7 +111,7 @@ export type DesktopSettings = {
gitProviderId?: string;
gitModelId?: string;
pwaAppName?: string;
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
userMessageRenderingMode?: 'markdown' | 'plain';
stickyUserHeader?: boolean;
fontSize?: number;
+27 -75
View File
@@ -2054,91 +2054,43 @@ class OpencodeService {
limit?: number;
includeHidden?: boolean;
respectGitignore?: boolean;
dirs?: boolean;
type?: 'file' | 'directory';
}
): Promise<ProjectFileSearchHit[]> {
const desktopFiles = getDesktopFilesApi();
const directory = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim()
: this.currentDirectory;
const normalizedDirectory = directory ? normalizeFsPath(directory) : null;
const scopedClient = directory ? this.getScopedApiClient(directory) : this.client;
if (desktopFiles) {
try {
const results = await desktopFiles.search({
directory: directory || '',
query,
maxResults: options?.limit,
includeHidden: options?.includeHidden,
respectGitignore: options?.respectGitignore,
});
try {
const response = await scopedClient.find.files({
query,
limit: typeof options?.limit === 'number' && Number.isFinite(options.limit) ? options.limit : undefined,
dirs: options?.dirs === false || options?.type === 'file' ? 'false' : 'true',
type: options?.type,
});
if (!Array.isArray(results)) {
return [];
}
const items = Array.isArray(response?.data) ? response.data : [];
return items.map<ProjectFileSearchHit>((item) => {
const normalizedRelativePath = normalizeFsPath(item);
const name = normalizedRelativePath.split('/').filter(Boolean).pop() || normalizedRelativePath;
const normalizedPath = normalizedDirectory
? normalizeFsPath(`${normalizedDirectory}/${normalizedRelativePath}`)
: normalizeFsPath(normalizedRelativePath);
return results.map<ProjectFileSearchHit>((file) => {
const normalizedPath = normalizeFsPath(file.path);
const name = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath;
const relativePath = (() => {
if (file.preview && file.preview.length > 0 && typeof file.preview[0] === 'string') {
return normalizeFsPath(file.preview[0]);
}
if (normalizedDirectory && normalizedPath.startsWith(normalizedDirectory)) {
const suffix = normalizedPath.slice(normalizedDirectory.length).replace(/^\/+/, '');
return suffix || name;
}
return name;
})();
return {
name,
path: normalizedPath,
relativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
});
} catch (error) {
console.error('Failed to search files:', error);
throw error;
}
return {
name,
path: normalizedPath,
relativePath: normalizedRelativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
});
} catch (error) {
console.error('Failed to search files:', error);
throw error;
}
const params = new URLSearchParams();
if (directory && directory.length > 0) {
params.set('directory', directory);
}
if (typeof query === 'string') {
params.set('q', query);
}
if (typeof options?.limit === 'number' && Number.isFinite(options.limit)) {
params.set('limit', String(options.limit));
}
if (options?.includeHidden) {
params.set('includeHidden', 'true');
}
if (options?.respectGitignore === false) {
params.set('respectGitignore', 'false');
}
const searchUrl = `${this.baseUrl}/fs/search${params.toString() ? `?${params.toString()}` : ''}`;
const response = await fetch(searchUrl, {
method: 'GET',
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message = typeof error.error === 'string' ? error.error : 'Failed to search files';
throw new Error(message);
}
const result = await response.json();
if (!result || !Array.isArray(result.files)) {
return [];
}
return result.files as ProjectFileSearchHit[];
}
async getFilesystemHome(): Promise<string | null> {
+6 -2
View File
@@ -366,7 +366,10 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setMaxLastMessageLength(settings.maxLastMessageLength);
}
if (typeof settings.toolCallExpansion === 'string'
&& (settings.toolCallExpansion === 'collapsed' || settings.toolCallExpansion === 'activity' || settings.toolCallExpansion === 'detailed')) {
&& (settings.toolCallExpansion === 'collapsed'
|| settings.toolCallExpansion === 'activity'
|| settings.toolCallExpansion === 'detailed'
|| settings.toolCallExpansion === 'changes')) {
if (settings.toolCallExpansion !== store.toolCallExpansion) {
store.setToolCallExpansion(settings.toolCallExpansion);
}
@@ -733,7 +736,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
typeof candidate.toolCallExpansion === 'string'
&& (candidate.toolCallExpansion === 'collapsed'
|| candidate.toolCallExpansion === 'activity'
|| candidate.toolCallExpansion === 'detailed')
|| candidate.toolCallExpansion === 'detailed'
|| candidate.toolCallExpansion === 'changes')
) {
result.toolCallExpansion = candidate.toolCallExpansion;
}
+8 -4
View File
@@ -19,7 +19,7 @@ interface FileSearchStoreState {
directory: string,
query: string,
limit?: number,
options?: { includeHidden?: boolean; respectGitignore?: boolean }
options?: { includeHidden?: boolean; respectGitignore?: boolean; type?: 'file' | 'directory' }
) => Promise<ProjectFileSearchHit[]>;
invalidateDirectory: (directory?: string | null) => void;
}
@@ -29,11 +29,12 @@ const buildCacheKey = (
query: string,
limit: number,
includeHidden: boolean,
respectGitignore: boolean
respectGitignore: boolean,
type: 'file' | 'directory'
) => {
const normalizedDirectory = directory.trim();
const normalizedQuery = query.trim().toLowerCase();
return `${normalizedDirectory}::${normalizedQuery}::${limit}::${includeHidden ? '1' : '0'}::${respectGitignore ? '1' : '0'}`;
return `${normalizedDirectory}::${normalizedQuery}::${limit}::${includeHidden ? '1' : '0'}::${respectGitignore ? '1' : '0'}::${type}`;
};
export const useFileSearchStore = create<FileSearchStoreState>()(
@@ -51,7 +52,8 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
const includeHidden = Boolean(options?.includeHidden);
const respectGitignore = options?.respectGitignore ?? true;
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore);
const type = options?.type === 'directory' ? 'directory' : 'file';
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type);
const now = Date.now();
const cached = get().cache[key];
@@ -70,6 +72,8 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
limit,
includeHidden,
respectGitignore,
dirs: type !== 'file',
type,
})
.then((files) => {
set((state) => {
+14 -7
View File
@@ -25,6 +25,14 @@ const normalizeDirectory = (directory: string | null | undefined): string | null
const toKey = (directory: string | null | undefined): string => normalizeDirectory(directory) ?? '__global__';
const getMcpApiClient = (directory: string | null | undefined) => {
const normalized = normalizeDirectory(directory);
if (!normalized) {
return opencodeClient.getApiClient();
}
return opencodeClient.getScopedApiClient(normalized);
};
export const computeMcpHealth = (status: McpStatusMap | null | undefined): McpHealth => {
const entries = Object.entries(status ?? {});
const connected = entries.filter(([, s]) => s?.status === 'connected').length;
@@ -73,8 +81,8 @@ export const useMcpStore = create<McpStore>()(
}
try {
const api = opencodeClient.getApiClient();
const result = await api.mcp.status(directory ? { directory } : undefined);
const api = getMcpApiClient(directory);
const result = await api.mcp.status();
const data = (result.data ?? {}) as McpStatusMap;
set((state) => ({
@@ -93,18 +101,17 @@ export const useMcpStore = create<McpStore>()(
connect: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = opencodeClient.getApiClient();
await api.mcp.connect({ name, ...(normalized ? { directory: normalized } : {}) }, { throwOnError: true });
const api = getMcpApiClient(normalized);
await api.mcp.connect({ name }, { throwOnError: true });
await get().refresh({ directory: normalized, silent: true });
},
disconnect: async (name, directory) => {
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
const api = opencodeClient.getApiClient();
await api.mcp.disconnect({ name, ...(normalized ? { directory: normalized } : {}) }, { throwOnError: true });
const api = getMcpApiClient(normalized);
await api.mcp.disconnect({ name }, { throwOnError: true });
await get().refresh({ directory: normalized, silent: true });
},
}))
);
+2 -2
View File
@@ -504,7 +504,7 @@ interface UIStore {
autoDeleteLastRunAt: number | null;
messageLimit: number;
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
toolCallExpansion: 'collapsed' | 'activity' | 'detailed' | 'changes';
fontSize: number;
terminalFontSize: number;
padding: number;
@@ -618,7 +618,7 @@ interface UIStore {
setAutoDeleteAfterDays: (days: number) => void;
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
setMessageLimit: (value: number) => void;
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed' | 'changes') => void;
setFontSize: (size: number) => void;
setTerminalFontSize: (size: number) => void;
setPadding: (size: number) => void;