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:
committed by
GitHub
parent
ca18b8be0f
commit
79143bff4c
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user