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
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -327,6 +327,33 @@ export const ChatContainer: React.FC = () => {
|
|||||||
trimToViewportWindow,
|
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(() => {
|
React.useEffect(() => {
|
||||||
cancelTurnBackfill();
|
cancelTurnBackfill();
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
RiCloseLine,
|
RiCloseLine,
|
||||||
RiCommandLine,
|
RiCommandLine,
|
||||||
RiExternalLinkLine,
|
RiExternalLinkLine,
|
||||||
RiFileUploadLine,
|
|
||||||
RiFullscreenLine,
|
RiFullscreenLine,
|
||||||
|
RiGitPullRequestLine,
|
||||||
RiGithubLine,
|
RiGithubLine,
|
||||||
RiSendPlane2Line,
|
RiSendPlane2Line,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
@@ -26,7 +26,6 @@ import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAu
|
|||||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||||
import { cn, isMacOS } from '@/lib/utils';
|
import { cn, isMacOS } from '@/lib/utils';
|
||||||
import { ServerFilePicker } from './ServerFilePicker';
|
|
||||||
import { ModelControls } from './ModelControls';
|
import { ModelControls } from './ModelControls';
|
||||||
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
@@ -39,7 +38,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
|||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useFileStore } from '@/stores/fileStore';
|
import { useFileStore } from '@/stores/fileStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { isIMECompositionEvent } from '@/lib/ime';
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { StopIcon } from '@/components/icons/StopIcon';
|
import { StopIcon } from '@/components/icons/StopIcon';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@@ -52,9 +51,13 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
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 MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
|
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
@@ -127,6 +130,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||||
const canAcceptDropRef = React.useRef(false);
|
const canAcceptDropRef = React.useRef(false);
|
||||||
|
const nativeDragInsideDropZoneRef = React.useRef(false);
|
||||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||||
const skillRef = React.useRef<SkillAutocompleteHandle>(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 clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||||
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
||||||
const addServerFile = useSessionStore((state) => state.addServerFile);
|
|
||||||
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
||||||
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
||||||
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
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 { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, isExpandedInput, setExpandedInput } = useUIStore();
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const chatSearchDirectory = useChatSearchDirectory();
|
||||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||||
|
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
|
||||||
|
|
||||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
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 [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
||||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const prevWasAbortedRef = React.useRef(false);
|
const prevWasAbortedRef = React.useRef(false);
|
||||||
|
|
||||||
// Issue linking state (for draft sessions)
|
// Issue linking state
|
||||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||||
|
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -170,6 +336,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
contextText: string;
|
contextText: string;
|
||||||
author?: { login: string; avatarUrl?: string };
|
author?: { login: string; avatarUrl?: string };
|
||||||
} | null>(null);
|
} | 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
|
// Message queue
|
||||||
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
||||||
@@ -429,7 +606,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [pendingInputText, consumePendingInputText]);
|
}, [pendingInputText, consumePendingInputText]);
|
||||||
|
|
||||||
const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts;
|
const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts;
|
||||||
const hasQueuedMessages = queuedMessages.length > 0;
|
const hasQueuedMessages = queuedMessages.length > 0;
|
||||||
const canSend = hasContent || hasQueuedMessages;
|
const canSend = hasContent || hasQueuedMessages;
|
||||||
|
|
||||||
@@ -451,7 +628,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (drafts.length > 0) {
|
if (drafts.length > 0) {
|
||||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||||
}
|
}
|
||||||
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
|
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
||||||
|
|
||||||
addToQueue(currentSessionId, {
|
addToQueue(currentSessionId, {
|
||||||
content: messageToQueue,
|
content: messageToQueue,
|
||||||
@@ -467,7 +644,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!isMobile) {
|
if (!isMobile) {
|
||||||
textareaRef.current?.focus();
|
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 handleSubmit = async (options?: SubmitOptions) => {
|
||||||
const queuedOnly = options?.queuedOnly ?? false;
|
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++) {
|
for (let i = 0; i < queuedMessages.length; i++) {
|
||||||
const queuedMsg = queuedMessages[i];
|
const queuedMsg = queuedMessages[i];
|
||||||
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
||||||
|
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
||||||
|
|
||||||
// Use agent mention from first message that has one
|
// Use agent mention from first message that has one
|
||||||
if (!agentMentionName && mention?.name) {
|
if (!agentMentionName && mention?.name) {
|
||||||
@@ -507,13 +685,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
// First queued message becomes primary
|
// First queued message becomes primary
|
||||||
primaryText = sanitizedText;
|
primaryText = queuedText;
|
||||||
primaryAttachments = queuedMsg.attachments ?? [];
|
primaryAttachments = [
|
||||||
|
...sanitizeAttachmentsForSend(queuedMsg.attachments),
|
||||||
|
...mentionAttachments,
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
// Subsequent queued messages become additional parts
|
// Subsequent queued messages become additional parts
|
||||||
|
const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments);
|
||||||
additionalParts.push({
|
additionalParts.push({
|
||||||
text: sanitizedText,
|
text: queuedText,
|
||||||
attachments: queuedMsg.attachments,
|
attachments: [...queuedAttachments, ...mentionAttachments],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,7 +704,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
if (!queuedOnly && hasContent) {
|
if (!queuedOnly && hasContent) {
|
||||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
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) {
|
if (!agentMentionName && mention?.name) {
|
||||||
agentMentionName = mention.name;
|
agentMentionName = mention.name;
|
||||||
@@ -530,13 +713,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
if (queuedMessages.length === 0) {
|
if (queuedMessages.length === 0) {
|
||||||
// No queue - current input is primary
|
// No queue - current input is primary
|
||||||
primaryText = sanitizedText;
|
primaryText = messageText;
|
||||||
primaryAttachments = attachmentsToSend;
|
primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
|
||||||
} else {
|
} else {
|
||||||
// Has queue - current input is additional part
|
// Has queue - current input is additional part
|
||||||
additionalParts.push({
|
additionalParts.push({
|
||||||
text: sanitizedText,
|
text: messageText,
|
||||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
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)
|
// Add linked issue as synthetic part (only the parts with synthetic: true)
|
||||||
// The text part (synthetic: false) is completely dropped per requirements
|
// The text part (synthetic: false) is completely dropped per requirements
|
||||||
if (linkedIssue && newSessionDraftOpen) {
|
if (linkedIssue) {
|
||||||
additionalParts.push({
|
additionalParts.push({
|
||||||
text: linkedIssue.contextText,
|
text: linkedIssue.contextText,
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (linkedPr) {
|
||||||
|
additionalParts.push({
|
||||||
|
text: linkedPr.instructionsText,
|
||||||
|
synthetic: true,
|
||||||
|
});
|
||||||
|
additionalParts.push({
|
||||||
|
text: linkedPr.contextText,
|
||||||
|
synthetic: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!primaryText && additionalParts.length === 0) return;
|
if (!primaryText && additionalParts.length === 0) return;
|
||||||
|
|
||||||
// Clear queue and input
|
// Clear queue and input
|
||||||
@@ -649,10 +843,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
currentVariant,
|
currentVariant,
|
||||||
inputMode
|
inputMode
|
||||||
).then(() => {
|
).then(() => {
|
||||||
// Clear linked issue after successful message send in draft mode
|
// Clear linked issue after successful message send
|
||||||
if (linkedIssue && newSessionDraftOpen) {
|
if (linkedIssue) {
|
||||||
setLinkedIssue(null);
|
setLinkedIssue(null);
|
||||||
}
|
}
|
||||||
|
if (linkedPr) {
|
||||||
|
setLinkedPr(null);
|
||||||
|
}
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
const rawMessage =
|
const rawMessage =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@@ -732,6 +929,48 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
return;
|
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 (showCommandAutocomplete && commandRef.current) {
|
||||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1303,25 +1542,38 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
|
}, [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 cursorPosition = textareaRef.current?.selectionStart || 0;
|
||||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
|
const mentionPath = (file.relativePath && file.relativePath.trim().length > 0)
|
||||||
|
? file.relativePath.trim()
|
||||||
|
: (toProjectRelativeMentionPath(file.path) || file.name);
|
||||||
|
|
||||||
if (lastAtSymbol !== -1) {
|
if (lastAtSymbol !== -1) {
|
||||||
const newMessage =
|
const newMessage =
|
||||||
message.substring(0, lastAtSymbol) +
|
message.substring(0, lastAtSymbol) +
|
||||||
file.name +
|
`@${mentionPath} ` +
|
||||||
message.substring(cursorPosition);
|
message.substring(cursorPosition);
|
||||||
setMessage(newMessage);
|
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) {
|
} else if (textareaRef.current) {
|
||||||
const newMessage =
|
const newMessage =
|
||||||
message.substring(0, cursorPosition) +
|
message.substring(0, cursorPosition) +
|
||||||
`@${file.name} ` +
|
`@${mentionPath} ` +
|
||||||
message.substring(cursorPosition);
|
message.substring(cursorPosition);
|
||||||
setMessage(newMessage);
|
setMessage(newMessage);
|
||||||
const nextCursor = cursorPosition + file.name.length + 2;
|
const nextCursor = cursorPosition + mentionPath.length + 2;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.selectionStart = nextCursor;
|
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) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
if (!hasDraggedFiles(e.dataTransfer)) {
|
if (!hasDraggedFiles(e.dataTransfer)) {
|
||||||
return;
|
return;
|
||||||
@@ -1697,7 +1965,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
// Check if drop is inside the chat input area
|
// Check if drop is inside the chat input area
|
||||||
const zone = dropZoneRef.current;
|
const zone = dropZoneRef.current;
|
||||||
let inZone = false;
|
let inZone: boolean | null = null;
|
||||||
if (zone && typeof x === 'number' && typeof y === 'number') {
|
if (zone && typeof x === 'number' && typeof y === 'number') {
|
||||||
const rect = zone.getBoundingClientRect();
|
const rect = zone.getBoundingClientRect();
|
||||||
inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
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') {
|
if (type === 'enter' || type === 'over') {
|
||||||
setIsDragging(inZone);
|
if (inZone !== null) {
|
||||||
|
nativeDragInsideDropZoneRef.current = inZone;
|
||||||
|
}
|
||||||
|
setIsDragging(nativeDragInsideDropZoneRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'leave') {
|
if (type === 'leave') {
|
||||||
|
nativeDragInsideDropZoneRef.current = false;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === 'drop') {
|
if (type === 'drop') {
|
||||||
|
const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current;
|
||||||
|
nativeDragInsideDropZoneRef.current = false;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
if (!inZone) return;
|
if (!shouldHandleDrop) return;
|
||||||
|
|
||||||
const paths = Array.isArray(typed.paths)
|
const paths = Array.isArray(typed.paths)
|
||||||
? typed.paths.filter((p): p is string => typeof p === 'string')
|
? 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;
|
const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
|
||||||
let file: File;
|
let file: File;
|
||||||
|
|
||||||
// In desktop shell on remote origin, local file paths are not readable via /api/fs/raw.
|
// In Tauri shell, dropped paths are local machine paths.
|
||||||
// Read bytes from local machine via Tauri command.
|
// Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
|
||||||
if (isTauriShell() && !isDesktopLocalOriginActive()) {
|
if (isTauriShell()) {
|
||||||
const { invoke } = await import('@tauri-apps/api/core');
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
|
const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
|
||||||
const byteCharacters = atob(result.base64);
|
const byteCharacters = atob(result.base64);
|
||||||
@@ -1788,30 +2062,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
};
|
};
|
||||||
}, [addAttachedFile, normalizeDroppedPath]);
|
}, [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 fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
||||||
let attachedCount = 0;
|
let attachedCount = 0;
|
||||||
@@ -1992,23 +2243,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative inline-flex">
|
<div className="relative inline-flex">
|
||||||
<ServerFilePicker
|
{isVSCode ? (
|
||||||
onFilesSelected={handleServerFilesSelected}
|
|
||||||
multiSelect
|
|
||||||
presentation={isMobile ? 'modal' : 'dropdown'}
|
|
||||||
open={projectFilePickerOpen}
|
|
||||||
onOpenChange={setProjectFilePickerOpen}
|
|
||||||
>
|
|
||||||
{isMobile ? null : (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
className={footerIconButtonClass}
|
||||||
aria-hidden="true"
|
onClick={() => handlePickLocalFiles()}
|
||||||
className="absolute inset-0 opacity-0 pointer-events-none"
|
title="Attach files"
|
||||||
/>
|
aria-label="Attach files"
|
||||||
)}
|
>
|
||||||
</ServerFilePicker>
|
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -2032,15 +2277,26 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setProjectFilePickerOpen(true);
|
setIssuePickerOpen(true);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiFileUploadLine />
|
<RiGithubLine />
|
||||||
Attach from project
|
Link GitHub Issue
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setPrPickerOpen(true);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiGitPullRequestLine />
|
||||||
|
Link GitHub PR
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2165,10 +2421,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linked Issue Button - only in draft mode */}
|
{/* Linked Issue row */}
|
||||||
{newSessionDraftOpen && (
|
{linkedIssue && !isVSCode && (
|
||||||
<div className="pb-2 w-full px-1">
|
<div className="pb-2 w-full px-1">
|
||||||
{linkedIssue ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIssuePickerOpen(true)}
|
onClick={() => setIssuePickerOpen(true)}
|
||||||
@@ -2213,19 +2468,57 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
</div>
|
||||||
|
)}
|
||||||
|
{linkedPr && !isVSCode && (
|
||||||
|
<div className="pb-2 w-full px-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIssuePickerOpen(true)}
|
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"
|
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||||
>
|
>
|
||||||
<RiGithubLine
|
{linkedPr.author?.avatarUrl && (
|
||||||
className="h-4 w-4 flex-shrink-0"
|
<img
|
||||||
style={{ color: currentTheme?.colors?.status?.success }}
|
src={linkedPr.author.avatarUrl}
|
||||||
|
alt={linkedPr.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">
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -2332,6 +2625,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
: undefined}
|
: undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
data-chat-input="true"
|
data-chat-input="true"
|
||||||
@@ -2345,7 +2669,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||||
onKeyUp={updateAutocompleteOverlayPosition}
|
onKeyUp={updateAutocompleteOverlayPosition}
|
||||||
onClick={updateAutocompleteOverlayPosition}
|
onClick={updateAutocompleteOverlayPosition}
|
||||||
onScroll={updateAutocompleteOverlayPosition}
|
onScroll={(event) => {
|
||||||
|
updateAutocompleteOverlayPosition();
|
||||||
|
setTextareaScrollTop(event.currentTarget.scrollTop);
|
||||||
|
}}
|
||||||
onSelect={updateAutocompleteOverlayPosition}
|
onSelect={updateAutocompleteOverlayPosition}
|
||||||
placeholder={currentSessionId || newSessionDraftOpen
|
placeholder={currentSessionId || newSessionDraftOpen
|
||||||
? inputMode === 'shell'
|
? inputMode === 'shell'
|
||||||
@@ -2358,13 +2685,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
spellCheck={isMobile}
|
spellCheck={isMobile}
|
||||||
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
|
||||||
isDesktopExpanded
|
isDesktopExpanded
|
||||||
? 'h-full min-h-0 py-4'
|
? 'h-full min-h-0 py-4'
|
||||||
: isMobile
|
: isMobile
|
||||||
? 'py-2.5'
|
? 'py-2.5'
|
||||||
: 'pt-4 pb-2',
|
: 'pt-4 pb-2',
|
||||||
inputMode === 'shell' && 'font-mono',
|
inputMode === 'shell' && 'font-mono',
|
||||||
|
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||||
@@ -2375,6 +2703,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
@@ -2477,7 +2806,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
open={issuePickerOpen}
|
open={issuePickerOpen}
|
||||||
onOpenChange={setIssuePickerOpen}
|
onOpenChange={setIssuePickerOpen}
|
||||||
mode="select"
|
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 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 EXPANDED_TOOLS_CACHE_MAX = 4000;
|
||||||
const expandedToolsStateCache = new Map<string, Set<string>>();
|
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));
|
expandedToolsStateCache.set(messageId, new Set(value));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDetailedDefaultTool = (toolName: unknown): boolean =>
|
const isDefaultExpandedTool = (toolName: unknown, mode: DefaultExpandedToolMode): boolean =>
|
||||||
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
|
typeof toolName === 'string' && TOOL_DEFAULT_EXPANSION_BY_MODE[mode].has(toolName.toLowerCase());
|
||||||
|
|
||||||
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
|
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
|
||||||
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
|
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
|
||||||
@@ -443,19 +448,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
// 'collapsed': Activity and tools start collapsed
|
// 'collapsed': Activity and tools start collapsed
|
||||||
// 'activity': Activity expanded, tools collapsed
|
// 'activity': Activity expanded, tools collapsed
|
||||||
// 'detailed': Activity expanded, only key tools expanded
|
// 'detailed': Activity expanded, only key tools expanded
|
||||||
|
// 'changes': Activity expanded, only edit/diff tools expanded
|
||||||
|
|
||||||
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
|
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
|
||||||
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
|
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
|
||||||
return expandedTools;
|
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).
|
// expandedTools acts as a "toggled" set (XOR with defaults).
|
||||||
const defaultExpandedToolIds = new Set<string>();
|
const defaultExpandedToolIds = new Set<string>();
|
||||||
|
|
||||||
for (const part of toolParts) {
|
for (const part of toolParts) {
|
||||||
const toolName = (part as { tool?: unknown }).tool;
|
const toolName = (part as { tool?: unknown }).tool;
|
||||||
if (part.id && isDetailedDefaultTool(toolName)) {
|
if (part.id && isDefaultExpandedTool(toolName, defaultExpansionMode)) {
|
||||||
defaultExpandedToolIds.add(part.id);
|
defaultExpandedToolIds.add(part.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +482,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
|
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
|
||||||
if (isDetailedDefaultTool(toolPart.tool)) {
|
if (isDefaultExpandedTool(toolPart.tool, defaultExpansionMode)) {
|
||||||
if (toolPart.id) {
|
if (toolPart.id) {
|
||||||
defaultExpandedToolIds.add(toolPart.id);
|
defaultExpandedToolIds.add(toolPart.id);
|
||||||
}
|
}
|
||||||
@@ -1029,7 +1044,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -265,10 +265,12 @@ FileChip.displayName = 'FileChip';
|
|||||||
export const AttachedFilesList = memo(() => {
|
export const AttachedFilesList = memo(() => {
|
||||||
const { attachedFiles, removeAttachedFile } = useSessionStore();
|
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/'));
|
if (localFiles.length === 0) return null;
|
||||||
const otherFiles = attachedFiles.filter(f => !f.mimeType.startsWith('image/'));
|
|
||||||
|
const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
|
||||||
|
const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-4 w-full px-1 space-y-3">
|
<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`;
|
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 (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 (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"grid gap-2",
|
"grid gap-2",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
|
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
|
||||||
import { cn, truncatePathMiddle } from '@/lib/utils';
|
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
|
||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
|
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||||
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
@@ -45,8 +46,24 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
onTabSelect,
|
onTabSelect,
|
||||||
style,
|
style,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { currentDirectory } = useDirectoryStore();
|
const currentDirectory = useChatSearchDirectory() ?? '';
|
||||||
const { addServerFile } = useSessionStore();
|
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 { getVisibleAgents } = useConfigStore();
|
||||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||||
@@ -67,55 +84,43 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
const normalizedSearchQuery = (searchQuery ?? '').trim();
|
||||||
const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
|
const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
|
||||||
|
|
||||||
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
const recentFiles = React.useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
if (!projectRoot || !projectTabs) {
|
||||||
if (!q) {
|
return [] as FileInfo[];
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const c = candidate.toLowerCase();
|
const ordered = [
|
||||||
let score = 0;
|
projectTabs.selectedPath,
|
||||||
let lastIndex = -1;
|
...projectTabs.openPaths.slice().reverse(),
|
||||||
let consecutive = 0;
|
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||||
|
|
||||||
for (let i = 0; i < q.length; i += 1) {
|
const seen = new Set<string>();
|
||||||
const ch = q[i];
|
const queryLower = normalizedSearchQuery.toLowerCase();
|
||||||
if (!ch || ch === ' ') {
|
const mapped = ordered
|
||||||
continue;
|
.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);
|
return mapped;
|
||||||
if (idx === -1) {
|
}, [normalizedSearchQuery, projectRoot, projectTabs]);
|
||||||
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(() => {
|
React.useEffect(() => {
|
||||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
@@ -138,6 +143,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,35 +153,27 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
.replace(/^\/+/, '')
|
.replace(/^\/+/, '')
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedQueryLower) {
|
||||||
|
setFiles([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
||||||
includeHidden: showHidden,
|
includeHidden: showHidden,
|
||||||
respectGitignore: !showGitignored,
|
respectGitignore: !showGitignored,
|
||||||
|
type: 'file',
|
||||||
})
|
})
|
||||||
.then((hits) => {
|
.then((hits) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranked = normalizedQueryLower
|
const recentSet = new Set(recentFiles.map((file) => file.path));
|
||||||
? hits
|
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
|
||||||
.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));
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -191,7 +189,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const visibleAgents = getVisibleAgents();
|
const visibleAgents = getVisibleAgents();
|
||||||
@@ -216,7 +214,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
setOverflowMap({});
|
setOverflowMap({});
|
||||||
setMarqueeDurations({});
|
setMarqueeDurations({});
|
||||||
}, [files, visibleAgents.length]);
|
}, [files, recentFiles.length, visibleAgents.length]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
@@ -292,11 +290,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
};
|
};
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
|
|
||||||
const handleFileSelect = React.useCallback(async (file: FileInfo) => {
|
const handleFileSelect = React.useCallback((file: FileInfo) => {
|
||||||
|
|
||||||
await addServerFile(file.path, file.name);
|
|
||||||
onFileSelect(file);
|
onFileSelect(file);
|
||||||
}, [addServerFile, onFileSelect]);
|
}, [onFileSelect]);
|
||||||
|
|
||||||
const handleAgentPick = React.useCallback((agentName: string) => {
|
const handleAgentPick = React.useCallback((agentName: string) => {
|
||||||
onAgentSelect?.(agentName);
|
onAgentSelect?.(agentName);
|
||||||
@@ -309,7 +305,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = visibleAgents.length + files.length;
|
const total = visibleAgents.length + recentFiles.length + files.length;
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -333,13 +329,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
}
|
}
|
||||||
return;
|
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) {
|
if (selectedFile) {
|
||||||
handleFileSelect(selectedFile);
|
handleFileSelect(selectedFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}), [files, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||||
|
|
||||||
const getFileIcon = (file: FileInfo) => {
|
const getFileIcon = (file: FileInfo) => {
|
||||||
const ext = file.extension?.toLowerCase();
|
const ext = file.extension?.toLowerCase();
|
||||||
@@ -440,11 +439,68 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</div>
|
</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" />
|
<div className="my-1 border-t border-border/60" />
|
||||||
)}
|
)}
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
const rowIndex = visibleAgents.length + index;
|
const rowIndex = visibleAgents.length + recentFiles.length + index;
|
||||||
const relativePath = file.relativePath || file.name;
|
const relativePath = file.relativePath || file.name;
|
||||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||||
const isSelected = selectedIndex === rowIndex;
|
const isSelected = selectedIndex === rowIndex;
|
||||||
@@ -497,12 +553,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
|
{files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||||
<div className="px-3 py-1 typography-meta text-muted-foreground">
|
|
||||||
Type to search more agents
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{files.length === 0 && visibleAgents.length === 0 && (
|
|
||||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||||
No matches found
|
No matches found
|
||||||
</div>
|
</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 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 { 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 { useSessionStore } from "@/stores/useSessionStore";
|
||||||
import { useUIStore } from "@/stores/useUIStore";
|
import { useUIStore } from "@/stores/useUIStore";
|
||||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
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 {
|
interface TodoItemRowProps {
|
||||||
todo: TodoItem;
|
todo: TodoItem;
|
||||||
}
|
}
|
||||||
@@ -29,8 +49,18 @@ interface TodoItemRowProps {
|
|||||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
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 (
|
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 typography-ui-label",
|
"flex-1 typography-ui-label",
|
||||||
@@ -39,6 +69,15 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
|||||||
>
|
>
|
||||||
{todo.content}
|
{todo.content}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -89,18 +128,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
}
|
}
|
||||||
}, [currentSessionId, loadTodos]);
|
}, [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 visibleTodos = React.useMemo(() => {
|
||||||
const statusOrder: Record<TodoStatus, number> = {
|
return todos.filter((todo) => todo.status !== "cancelled");
|
||||||
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]);
|
|
||||||
}, [todos]);
|
}, [todos]);
|
||||||
|
|
||||||
// Find the current active todo (first in_progress, or first pending)
|
// Find the current active todo (first in_progress, or first pending)
|
||||||
@@ -119,6 +150,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
return { completed, total };
|
return { completed, total };
|
||||||
}, [todos]);
|
}, [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");
|
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
|
||||||
// Original logic from ChatInput
|
// Original logic from ChatInput
|
||||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
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-ui-label">Tasks</span>
|
||||||
)}
|
)}
|
||||||
<span className="typography-meta">
|
<span className="typography-meta">
|
||||||
{progress.completed}/{progress.total}
|
{statusSummary.active} active · {statusSummary.left} left
|
||||||
</span>
|
</span>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -514,7 +514,8 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
|
|||||||
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
||||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||||
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
|
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 structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
|
||||||
const [structuredMessages, setStructuredMessages] = React.useState<ChatMessageEntry[]>(messages);
|
const [structuredMessages, setStructuredMessages] = React.useState<ChatMessageEntry[]>(messages);
|
||||||
|
|
||||||
|
|||||||
@@ -423,8 +423,9 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
);
|
);
|
||||||
|
|
||||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||||
// Activity group is expanded for 'activity' and 'detailed', collapsed for 'collapsed'
|
// Activity group is expanded for 'activity', 'detailed', and 'changes'; collapsed for 'collapsed'
|
||||||
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
|
const defaultActivityExpanded =
|
||||||
|
toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
|
||||||
|
|
||||||
// Reset turn UI states when the expansion preference changes
|
// Reset turn UI states when the expansion preference changes
|
||||||
// This ensures the setting takes precedence over manual toggles
|
// This ensures the setting takes precedence over manual toggles
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
|
|||||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { toPng } from 'html-to-image';
|
import { toPng } from 'html-to-image';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { formatTimestampForDisplay } from './timeFormat';
|
import { formatTimestampForDisplay } from './timeFormat';
|
||||||
@@ -332,6 +333,7 @@ const UserMessageBody: React.FC<{
|
|||||||
const hasCopyableText = Boolean(hasTextContent);
|
const hasCopyableText = Boolean(hasTextContent);
|
||||||
const showUserContent = userActionsMode !== 'external-actions';
|
const showUserContent = userActionsMode !== 'external-actions';
|
||||||
const showUserActions = userActionsMode !== 'external-content';
|
const showUserActions = userActionsMode !== 'external-content';
|
||||||
|
const useStickyScrollableUserContent = stickyUserHeaderEnabled && userActionsMode === 'inline';
|
||||||
|
|
||||||
const clearCopyHintTimeout = React.useCallback(() => {
|
const clearCopyHintTimeout = React.useCallback(() => {
|
||||||
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
|
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
@@ -490,7 +492,15 @@ const UserMessageBody: React.FC<{
|
|||||||
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
||||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
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) => {
|
{userContentParts.map((part, index) => {
|
||||||
if (isSubtaskPart(part)) {
|
if (isSubtaskPart(part)) {
|
||||||
return (
|
return (
|
||||||
@@ -833,12 +843,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
try {
|
try {
|
||||||
const originalElement = messageContentRef.current;
|
const originalElement = messageContentRef.current;
|
||||||
const computedStyle = window.getComputedStyle(originalElement);
|
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;
|
const paddingSize = 24;
|
||||||
|
|
||||||
wrapper = document.createElement('div');
|
wrapper = document.createElement('div');
|
||||||
wrapper.style.cssText = `
|
wrapper.style.cssText = `
|
||||||
padding: ${paddingSize}px;
|
padding: ${paddingSize}px;
|
||||||
background-color: var(--surface-background);
|
background-color: ${resolvedBackgroundColor};
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -849,21 +864,69 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
contain: none;
|
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);
|
wrapper.appendChild(clone);
|
||||||
document.body.appendChild(wrapper);
|
document.body.appendChild(wrapper);
|
||||||
|
|
||||||
const dataUrl = await toPng(wrapper, {
|
const dataUrl = await toPng(wrapper, {
|
||||||
quality: 1,
|
quality: 1,
|
||||||
pixelRatio: 2,
|
pixelRatio: 2,
|
||||||
backgroundColor: 'var(--surface-background)',
|
backgroundColor: resolvedBackgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
const link = document.createElement('a');
|
||||||
link.download = `message-${messageId}.png`;
|
link.download = fileName;
|
||||||
link.href = dataUrl;
|
link.href = dataUrl;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success('Image saved');
|
toast.success('Image saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1289,7 +1352,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
{isSharing ? (
|
{isSharing ? (
|
||||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RiShare2Line className="h-4 w-4" />
|
<RiImageDownloadLine className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -1399,10 +1462,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{footerTimestamp ? (
|
{footerTimestamp ? (
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
<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" />
|
<RiTimeLine className="h-3.5 w-3.5" />
|
||||||
{footerTimestamp}
|
|
||||||
</span>
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={6}>{footerTimestamp}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1425,10 +1495,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{footerTimestamp ? (
|
{footerTimestamp ? (
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
<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" />
|
<RiTimeLine className="h-3.5 w-3.5" />
|
||||||
{footerTimestamp}
|
|
||||||
</span>
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={6}>{footerTimestamp}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,28 +16,29 @@ interface MenuPosition {
|
|||||||
show: boolean;
|
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 }) => {
|
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||||
const [selectedText, setSelectedText] = React.useState('');
|
const [selectedText, setSelectedText] = React.useState('');
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [isClosing, setIsClosing] = React.useState(false);
|
|
||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
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 pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
|
||||||
const hideTimeoutRef = React.useRef<number | null>(null);
|
|
||||||
const openRafRef = React.useRef<number | null>(null);
|
const openRafRef = React.useRef<number | null>(null);
|
||||||
|
const isMenuVisibleRef = React.useRef(false);
|
||||||
const createSession = useSessionStore((state) => state.createSession);
|
const createSession = useSessionStore((state) => state.createSession);
|
||||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
isMenuVisibleRef.current = position.show;
|
||||||
|
}, [position.show]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (hideTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(hideTimeoutRef.current);
|
|
||||||
hideTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (openRafRef.current !== null) {
|
if (openRafRef.current !== null) {
|
||||||
window.cancelAnimationFrame(openRafRef.current);
|
window.cancelAnimationFrame(openRafRef.current);
|
||||||
openRafRef.current = null;
|
openRafRef.current = null;
|
||||||
@@ -46,40 +47,51 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hideMenu = React.useCallback(() => {
|
const hideMenu = React.useCallback(() => {
|
||||||
if (hideTimeoutRef.current !== null) {
|
pendingSelectionRef.current = null;
|
||||||
window.clearTimeout(hideTimeoutRef.current);
|
|
||||||
hideTimeoutRef.current = null;
|
if (!isMenuVisibleRef.current) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (openRafRef.current !== null) {
|
if (openRafRef.current !== null) {
|
||||||
window.cancelAnimationFrame(openRafRef.current);
|
window.cancelAnimationFrame(openRafRef.current);
|
||||||
openRafRef.current = null;
|
openRafRef.current = null;
|
||||||
}
|
}
|
||||||
setIsOpening(false);
|
setIsOpening(false);
|
||||||
|
|
||||||
setIsClosing(true);
|
|
||||||
hideTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setPosition((prev) => ({ ...prev, show: false }));
|
setPosition((prev) => ({ ...prev, show: false }));
|
||||||
setSelectedText('');
|
setSelectedText('');
|
||||||
pendingSelectionRef.current = null;
|
isMenuVisibleRef.current = false;
|
||||||
setIsClosing(false);
|
}, []);
|
||||||
hideTimeoutRef.current = null;
|
|
||||||
}, MENU_TRANSITION_MS);
|
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(() => {
|
const showMenu = React.useCallback(() => {
|
||||||
if (!pendingSelectionRef.current) return;
|
if (!pendingSelectionRef.current) return;
|
||||||
|
|
||||||
if (hideTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(hideTimeoutRef.current);
|
|
||||||
hideTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
setIsClosing(false);
|
|
||||||
|
|
||||||
const { text, rect } = pendingSelectionRef.current;
|
const { text, rect } = pendingSelectionRef.current;
|
||||||
const shouldAnimateIn = !position.show;
|
const shouldAnimateIn = !position.show;
|
||||||
|
|
||||||
// Position menu above the selection
|
// 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;
|
const menuY = rect.top - 10;
|
||||||
|
|
||||||
setSelectedText(text);
|
setSelectedText(text);
|
||||||
@@ -88,6 +100,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
y: menuY,
|
y: menuY,
|
||||||
show: true,
|
show: true,
|
||||||
});
|
});
|
||||||
|
isMenuVisibleRef.current = true;
|
||||||
|
|
||||||
if (shouldAnimateIn) {
|
if (shouldAnimateIn) {
|
||||||
setIsOpening(true);
|
setIsOpening(true);
|
||||||
@@ -99,7 +112,42 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
openRafRef.current = null;
|
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 handleSelectionChange = React.useCallback(() => {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -247,11 +295,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
'px-3 py-2',
|
'px-3 py-2',
|
||||||
'safe-area-bottom',
|
'safe-area-bottom',
|
||||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
isClosing
|
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||||
? 'opacity-0 translate-y-[4px] pointer-events-none'
|
|
||||||
: isOpening
|
|
||||||
? 'opacity-0 translate-y-[4px]'
|
|
||||||
: 'opacity-100 translate-y-0'
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||||
@@ -319,16 +363,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1',
|
'flex items-center gap-1 whitespace-nowrap',
|
||||||
'rounded-lg border border-[var(--interactive-border)]',
|
'rounded-lg border border-[var(--interactive-border)]',
|
||||||
'bg-[var(--surface-elevated)] shadow-none',
|
'bg-[var(--surface-elevated)] shadow-none',
|
||||||
'px-1.5 py-1',
|
'px-1.5 py-1',
|
||||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
isClosing
|
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||||
? 'opacity-0 translate-y-[4px] pointer-events-none'
|
|
||||||
: isOpening
|
|
||||||
? 'opacity-0 translate-y-[4px]'
|
|
||||||
: 'opacity-100 translate-y-0'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -344,7 +384,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RiAddLine className="h-4 w-4" />
|
<RiAddLine className="h-4 w-4" />
|
||||||
<span>Add to chat</span>
|
<span className="whitespace-nowrap">Add to chat</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||||
@@ -362,7 +402,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RiChatNewLine className="h-4 w-4" />
|
<RiChatNewLine className="h-4 w-4" />
|
||||||
<span>New session</span>
|
<span className="whitespace-nowrap">New session</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const showDesktopHeaderContextUsage = !isVSCode && activeMainTab === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0;
|
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 () => {
|
const refreshCurrentInstanceLabel = React.useCallback(async () => {
|
||||||
if (typeof window === 'undefined' || !isDesktopApp) {
|
if (typeof window === 'undefined' || !isDesktopApp) {
|
||||||
@@ -1063,7 +1066,8 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
{showDesktopHeaderContextUsage && stableDesktopContextUsage && (
|
{showDesktopHeaderContextUsage && stableDesktopContextUsage && (
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
totalTokens={stableDesktopContextUsage.totalTokens}
|
totalTokens={stableDesktopContextUsage.totalTokens}
|
||||||
percentage={stableDesktopContextUsage.percentage}
|
percentage={desktopHeaderDisplayPercentage}
|
||||||
|
colorPercentage={stableDesktopContextUsage.percentage}
|
||||||
contextLimit={stableDesktopContextUsage.contextLimit}
|
contextLimit={stableDesktopContextUsage.contextLimit}
|
||||||
outputLimit={stableDesktopContextUsage.outputLimit ?? 0}
|
outputLimit={stableDesktopContextUsage.outputLimit ?? 0}
|
||||||
size="compact"
|
size="compact"
|
||||||
|
|||||||
@@ -144,13 +144,24 @@ export const MainLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isRightSidebarOpen, isMobile]);
|
}, [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);
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const initialDelayMs = 3000;
|
||||||
|
const periodicIntervalMs = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}, 3000);
|
}, initialDelayMs);
|
||||||
return () => clearTimeout(timer);
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
checkForUpdates();
|
||||||
|
}, periodicIntervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
}, [checkForUpdates]);
|
}, [checkForUpdates]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -60,79 +60,147 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
|
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
|
||||||
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
|
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
|
||||||
const [isDiscoveringIcon, setIsDiscoveringIcon] = 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 [previewImageFailed, setPreviewImageFailed] = React.useState(false);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const clearPendingUploadIcon = React.useCallback(() => {
|
||||||
|
setPendingUploadIconFile(null);
|
||||||
|
setPendingUploadIconPreviewUrl((previousUrl) => {
|
||||||
|
if (previousUrl) {
|
||||||
|
URL.revokeObjectURL(previousUrl);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setName(projectName);
|
setName(projectName);
|
||||||
setIcon(initialIcon);
|
setIcon(initialIcon);
|
||||||
setColor(initialColor);
|
setColor(initialColor);
|
||||||
setIconBackground(normalizeIconBackground(initialIconBackground));
|
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();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return;
|
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);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
|
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 hasCustomIcon = currentIconImage?.source === 'custom';
|
||||||
const iconPreviewUrl = hasImageIcon && !previewImageFailed
|
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
|
||||||
|
const hasRemovableImageIcon = effectiveHasImageIcon;
|
||||||
|
const iconPreviewUrl = !previewImageFailed
|
||||||
|
? (hasPendingUploadImageIcon
|
||||||
|
? pendingUploadIconPreviewUrl
|
||||||
|
: (hasStoredImageIcon && !pendingRemoveImageIcon
|
||||||
? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null })
|
? getProjectIconImageUrl({ id: projectId, iconImage: currentIconImage ?? null })
|
||||||
|
: null))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPreviewImageFailed(false);
|
setPreviewImageFailed(false);
|
||||||
}, [projectId, currentIconImage?.updatedAt]);
|
}, [projectId, currentIconImage?.updatedAt]);
|
||||||
|
|
||||||
const handleUploadIcon = React.useCallback(async (file: File | null) => {
|
const handleUploadIcon = React.useCallback((file: File | null) => {
|
||||||
if (!projectId || !file || isUploadingIcon) {
|
if (!projectId || !file || isUploadingIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploadingIcon(true);
|
setPendingRemoveImageIcon(false);
|
||||||
void uploadProjectIcon(projectId, file)
|
setPreviewImageFailed(false);
|
||||||
.then((result) => {
|
setPendingUploadIconFile(file);
|
||||||
if (!result.ok) {
|
setPendingUploadIconPreviewUrl((previousUrl) => {
|
||||||
toast.error(result.error || 'Failed to upload project icon');
|
if (previousUrl) {
|
||||||
return;
|
URL.revokeObjectURL(previousUrl);
|
||||||
}
|
}
|
||||||
toast.success('Project icon updated');
|
return URL.createObjectURL(file);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsUploadingIcon(false);
|
|
||||||
});
|
});
|
||||||
}, [isUploadingIcon, projectId, uploadProjectIcon]);
|
}, [isUploadingIcon, projectId]);
|
||||||
|
|
||||||
const handleRemoveCustomIcon = React.useCallback(async () => {
|
const handleRemoveImageIcon = React.useCallback(() => {
|
||||||
if (!projectId || !hasCustomIcon || isRemovingCustomIcon) {
|
if (!projectId || !hasRemovableImageIcon || isRemovingCustomIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRemovingCustomIcon(true);
|
if (hasPendingUploadImageIcon) {
|
||||||
void removeProjectIcon(projectId)
|
clearPendingUploadIcon();
|
||||||
.then((result) => {
|
|
||||||
if (!result.ok) {
|
|
||||||
toast.error(result.error || 'Failed to remove project icon');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
toast.success('Custom project icon removed');
|
if (hasStoredImageIcon) {
|
||||||
})
|
setPendingRemoveImageIcon(true);
|
||||||
.finally(() => {
|
} else {
|
||||||
setIsRemovingCustomIcon(false);
|
setPendingRemoveImageIcon(false);
|
||||||
});
|
}
|
||||||
}, [hasCustomIcon, isRemovingCustomIcon, projectId, removeProjectIcon]);
|
setPreviewImageFailed(false);
|
||||||
|
}, [
|
||||||
|
clearPendingUploadIcon,
|
||||||
|
hasPendingUploadImageIcon,
|
||||||
|
hasRemovableImageIcon,
|
||||||
|
hasStoredImageIcon,
|
||||||
|
isRemovingCustomIcon,
|
||||||
|
projectId,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDiscoverIcon = React.useCallback(async () => {
|
const handleDiscoverIcon = React.useCallback(async () => {
|
||||||
if (!projectId || isDiscoveringIcon) {
|
if (!projectId || isDiscoveringIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPendingUploadIcon();
|
||||||
|
setPendingRemoveImageIcon(false);
|
||||||
|
setPreviewImageFailed(false);
|
||||||
|
|
||||||
setIsDiscoveringIcon(true);
|
setIsDiscoveringIcon(true);
|
||||||
void discoverProjectIcon(projectId)
|
void discoverProjectIcon(projectId)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -149,7 +217,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsDiscoveringIcon(false);
|
setIsDiscoveringIcon(false);
|
||||||
});
|
});
|
||||||
}, [discoverProjectIcon, isDiscoveringIcon, projectId]);
|
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -273,7 +341,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{hasImageIcon && iconPreviewUrl && (
|
{effectiveHasImageIcon && iconPreviewUrl && (
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
<span className="typography-meta text-muted-foreground">Preview</span>
|
<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">
|
<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>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasCustomIcon && (
|
{hasRemovableImageIcon && (
|
||||||
<Button size="sm" variant="outline" onClick={() => void handleRemoveCustomIcon()} disabled={isRemovingCustomIcon}>
|
<Button size="sm" variant="outline" onClick={() => void handleRemoveImageIcon()} disabled={isRemovingCustomIcon}>
|
||||||
{isRemovingCustomIcon ? 'Removing...' : 'Remove Custom Icon'}
|
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{pendingRemoveImageIcon && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setPendingRemoveImageIcon(false)} disabled={isRemovingCustomIcon}>
|
||||||
|
Undo Remove
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasImageIcon && (
|
{effectiveHasImageIcon && (
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<label className="typography-ui-label font-medium text-foreground">
|
<label className="typography-ui-label font-medium text-foreground">
|
||||||
Icon Background
|
Icon Background
|
||||||
@@ -342,7 +415,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
|||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
<Button onClick={handleSave} disabled={!name.trim() || isUploadingIcon || isRemovingCustomIcon}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -446,50 +446,6 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
|
|
||||||
// --- Fuzzy search scoring (matching FilesView) ---
|
// --- 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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -504,34 +460,20 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedQueryLower = trimmedQuery.toLowerCase();
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
|
|
||||||
searchFiles(currentDirectory, trimmedQuery, 150, {
|
searchFiles(currentDirectory, trimmedQuery, 150, {
|
||||||
includeHidden: showHidden,
|
includeHidden: showHidden,
|
||||||
respectGitignore: !showGitignored,
|
respectGitignore: !showGitignored,
|
||||||
|
type: 'file',
|
||||||
})
|
})
|
||||||
.then((hits) => {
|
.then((hits) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
|
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
|
||||||
|
|
||||||
const ranked = filtered
|
const mapped: FileNode[] = filtered.map((hit) => ({
|
||||||
.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 }) => ({
|
|
||||||
name: hit.name,
|
name: hit.name,
|
||||||
path: normalizePath(hit.path),
|
path: normalizePath(hit.path),
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@@ -555,7 +497,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||||
|
|
||||||
// --- Git status helpers (matching FilesView) ---
|
// --- 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 }> = [
|
const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed' | 'changes'; label: string; description: string }> = [
|
||||||
{ value: 'collapsed', label: 'Collapsed', description: 'Activity and tools start collapsed' },
|
{ value: 'collapsed', label: 'Collapsed', description: 'Activity and tool calls stay collapsed by default.' },
|
||||||
{ value: 'activity', label: 'Summary', description: 'Activity expanded, tools collapsed' },
|
{ value: 'activity', label: 'Summary', description: 'Activity opens by default; tool calls stay collapsed.' },
|
||||||
{ value: 'detailed', label: 'Detailed', description: 'Activity expanded, key tools expanded' },
|
{ 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'>[] = [
|
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
|
||||||
@@ -242,6 +243,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
|| shouldShow('queueMode')
|
|| shouldShow('queueMode')
|
||||||
|| shouldShow('textJustificationActivity')
|
|| shouldShow('textJustificationActivity')
|
||||||
|| shouldShow('persistDraft');
|
|| shouldShow('persistDraft');
|
||||||
|
const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion);
|
||||||
|
|
||||||
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
|
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
|
||||||
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
const [pwaInstallName, setPwaInstallName] = React.useState('');
|
||||||
@@ -710,6 +712,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
return (
|
return (
|
||||||
<ButtonSmall
|
<ButtonSmall
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -725,6 +728,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{selectedToolExpansionOption && (
|
||||||
|
<p className="mt-2 typography-ui-label font-normal text-muted-foreground">
|
||||||
|
{selectedToolExpansionOption.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,9 +43,22 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
|
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
|
||||||
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
|
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
|
||||||
const [isDiscoveringIcon, setIsDiscoveringIcon] = 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 [previewImageFailed, setPreviewImageFailed] = React.useState(false);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
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(() => {
|
const selectedProjectRef = React.useMemo(() => {
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return null;
|
return null;
|
||||||
@@ -65,71 +78,139 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
setIcon(selectedProject.icon ?? null);
|
setIcon(selectedProject.icon ?? null);
|
||||||
setColor(selectedProject.color ?? null);
|
setColor(selectedProject.color ?? null);
|
||||||
setIconBackground(selectedProject.iconBackground ?? null);
|
setIconBackground(selectedProject.iconBackground ?? null);
|
||||||
|
setPendingRemoveImageIcon(false);
|
||||||
|
clearPendingUploadIcon();
|
||||||
setPreviewImageFailed(false);
|
setPreviewImageFailed(false);
|
||||||
}, [selectedProject]);
|
}, [selectedProject, clearPendingUploadIcon]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearPendingUploadIcon();
|
||||||
|
};
|
||||||
|
}, [clearPendingUploadIcon]);
|
||||||
|
|
||||||
const hasChanges = Boolean(selectedProject) && (
|
const hasChanges = Boolean(selectedProject) && (
|
||||||
name.trim() !== (selectedProject?.label ?? '').trim()
|
name.trim() !== (selectedProject?.label ?? '').trim()
|
||||||
|| icon !== (selectedProject?.icon ?? null)
|
|| icon !== (selectedProject?.icon ?? null)
|
||||||
|| color !== (selectedProject?.color ?? null)
|
|| color !== (selectedProject?.color ?? null)
|
||||||
|| iconBackground !== (selectedProject?.iconBackground ?? null)
|
|| iconBackground !== (selectedProject?.iconBackground ?? null)
|
||||||
|
|| pendingRemoveImageIcon
|
||||||
|
|| Boolean(pendingUploadIconFile)
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = React.useCallback(() => {
|
const handleSave = React.useCallback(async () => {
|
||||||
if (!selectedProject) return;
|
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 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 hasCustomIcon = selectedProject?.iconImage?.source === 'custom';
|
||||||
const iconPreviewUrl = selectedProject && hasImageIcon && !previewImageFailed
|
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
|
||||||
|
const hasRemovableImageIcon = effectiveHasImageIcon;
|
||||||
|
const iconPreviewUrl = !previewImageFailed
|
||||||
|
? (hasPendingUploadImageIcon
|
||||||
|
? pendingUploadIconPreviewUrl
|
||||||
|
: (selectedProject && hasStoredImageIcon && !pendingRemoveImageIcon
|
||||||
? getProjectIconImageUrl(selectedProject)
|
? getProjectIconImageUrl(selectedProject)
|
||||||
|
: null))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handleUploadIcon = React.useCallback(async (file: File | null) => {
|
const handleUploadIcon = React.useCallback((file: File | null) => {
|
||||||
if (!selectedProject || !file || isUploadingIcon) {
|
if (!selectedProject || !file || isUploadingIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUploadingIcon(true);
|
setPendingRemoveImageIcon(false);
|
||||||
void uploadProjectIcon(selectedProject.id, file)
|
setPreviewImageFailed(false);
|
||||||
.then((result) => {
|
setPendingUploadIconFile(file);
|
||||||
if (!result.ok) {
|
setPendingUploadIconPreviewUrl((previousUrl) => {
|
||||||
toast.error(result.error || 'Failed to upload project icon');
|
if (previousUrl) {
|
||||||
return;
|
URL.revokeObjectURL(previousUrl);
|
||||||
}
|
}
|
||||||
toast.success('Project icon updated');
|
return URL.createObjectURL(file);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsUploadingIcon(false);
|
|
||||||
});
|
});
|
||||||
}, [isUploadingIcon, selectedProject, uploadProjectIcon]);
|
}, [isUploadingIcon, selectedProject]);
|
||||||
|
|
||||||
const handleRemoveCustomIcon = React.useCallback(async () => {
|
const handleRemoveImageIcon = React.useCallback(() => {
|
||||||
if (!selectedProject || !hasCustomIcon || isRemovingCustomIcon) {
|
if (!selectedProject || !hasRemovableImageIcon || isRemovingCustomIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRemovingCustomIcon(true);
|
if (hasPendingUploadImageIcon) {
|
||||||
void removeProjectIcon(selectedProject.id)
|
clearPendingUploadIcon();
|
||||||
.then((result) => {
|
|
||||||
if (!result.ok) {
|
|
||||||
toast.error(result.error || 'Failed to remove project icon');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
toast.success('Custom project icon removed');
|
if (hasStoredImageIcon) {
|
||||||
})
|
setPendingRemoveImageIcon(true);
|
||||||
.finally(() => {
|
} else {
|
||||||
setIsRemovingCustomIcon(false);
|
setPendingRemoveImageIcon(false);
|
||||||
});
|
}
|
||||||
}, [hasCustomIcon, isRemovingCustomIcon, removeProjectIcon, selectedProject]);
|
setPreviewImageFailed(false);
|
||||||
|
}, [
|
||||||
|
clearPendingUploadIcon,
|
||||||
|
hasPendingUploadImageIcon,
|
||||||
|
hasRemovableImageIcon,
|
||||||
|
hasStoredImageIcon,
|
||||||
|
isRemovingCustomIcon,
|
||||||
|
selectedProject,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDiscoverIcon = React.useCallback(async () => {
|
const handleDiscoverIcon = React.useCallback(async () => {
|
||||||
if (!selectedProject || isDiscoveringIcon) {
|
if (!selectedProject || isDiscoveringIcon) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPendingUploadIcon();
|
||||||
|
setPendingRemoveImageIcon(false);
|
||||||
|
setPreviewImageFailed(false);
|
||||||
|
|
||||||
setIsDiscoveringIcon(true);
|
setIsDiscoveringIcon(true);
|
||||||
void discoverProjectIcon(selectedProject.id)
|
void discoverProjectIcon(selectedProject.id)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -146,7 +227,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsDiscoveringIcon(false);
|
setIsDiscoveringIcon(false);
|
||||||
});
|
});
|
||||||
}, [discoverProjectIcon, isDiscoveringIcon, selectedProject]);
|
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, selectedProject]);
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
@@ -279,7 +360,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{hasImageIcon && iconPreviewUrl && (
|
{effectiveHasImageIcon && iconPreviewUrl && (
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<span className="typography-meta text-muted-foreground">Preview</span>
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasImageIcon && (
|
{effectiveHasImageIcon && (
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -349,15 +430,26 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</ButtonSmall>
|
</ButtonSmall>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{hasCustomIcon && (
|
{hasRemovableImageIcon && (
|
||||||
<ButtonSmall
|
<ButtonSmall
|
||||||
size="xs"
|
size="xs"
|
||||||
className="!font-normal"
|
className="!font-normal"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void handleRemoveCustomIcon()}
|
onClick={() => void handleRemoveImageIcon()}
|
||||||
disabled={isRemovingCustomIcon}
|
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>
|
</ButtonSmall>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +460,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
<div className="mt-0.5 px-2 py-1">
|
<div className="mt-0.5 px-2 py-1">
|
||||||
<ButtonSmall
|
<ButtonSmall
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!hasChanges || name.trim().length === 0}
|
disabled={!hasChanges || name.trim().length === 0 || isUploadingIcon || isRemovingCustomIcon}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="!font-normal"
|
className="!font-normal"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
RiCheckboxBlankLine,
|
RiCheckboxBlankLine,
|
||||||
@@ -79,6 +80,7 @@ export function GitHubIssuePickerDialog({
|
|||||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||||
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||||
|
|
||||||
const projectDirectory = activeProject?.path ?? null;
|
const projectDirectory = activeProject?.path ?? null;
|
||||||
@@ -512,21 +514,13 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
|||||||
}
|
}
|
||||||
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
||||||
|
|
||||||
return (
|
const title = mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue';
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
const description = mode === 'select'
|
||||||
<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.'
|
? 'Select an issue to link to this session.'
|
||||||
: 'Seeds a new session with hidden issue context (title/body/labels/comments).'}
|
: 'Seeds a new session with hidden issue context (title/body/labels/comments).';
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -537,7 +531,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{!projectDirectory ? (
|
||||||
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
|
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -704,6 +698,44 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
|||||||
</div>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useGitBranches } from '@/stores/useGitStore';
|
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
|
||||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
@@ -195,7 +195,7 @@ export function NewWorktreeDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
}: NewWorktreeDialogProps) {
|
}: NewWorktreeDialogProps) {
|
||||||
const { github } = useRuntimeAPIs();
|
const { github, git } = useRuntimeAPIs();
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||||
@@ -230,6 +230,14 @@ export function NewWorktreeDialog({
|
|||||||
|
|
||||||
// Use cached branches from Git store (instant if already fetched)
|
// Use cached branches from Git store (instant if already fetched)
|
||||||
const branches = useGitBranches(projectDirectory);
|
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)
|
// Compute local and remote branch lists (same pattern as GitView)
|
||||||
const localBranches = React.useMemo(() => {
|
const localBranches = React.useMemo(() => {
|
||||||
@@ -1052,7 +1060,11 @@ Nice-to-have:
|
|||||||
onClose={() => setExistingBranchPickerOpen(false)}
|
onClose={() => setExistingBranchPickerOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||||
No branches found
|
No branches found
|
||||||
</div>
|
</div>
|
||||||
@@ -1267,7 +1279,11 @@ Nice-to-have:
|
|||||||
onClose={() => setSourceBranchPickerOpen(false)}
|
onClose={() => setSourceBranchPickerOpen(false)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||||||
No branches found
|
No branches found
|
||||||
</div>
|
</div>
|
||||||
@@ -1438,7 +1454,11 @@ Nice-to-have:
|
|||||||
<SelectValue placeholder="Choose a branch..." />
|
<SelectValue placeholder="Choose a branch..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-[280px] max-w-[320px]">
|
<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">
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||||
No branches found
|
No branches found
|
||||||
</div>
|
</div>
|
||||||
@@ -1598,7 +1618,11 @@ Nice-to-have:
|
|||||||
<SelectValue placeholder="Select source branch..." />
|
<SelectValue placeholder="Select source branch..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-[280px] max-w-[320px]">
|
<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">
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||||||
No branches found
|
No branches found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|||||||
interface ContextUsageDisplayProps {
|
interface ContextUsageDisplayProps {
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
colorPercentage?: number;
|
||||||
contextLimit: number;
|
contextLimit: number;
|
||||||
outputLimit?: number;
|
outputLimit?: number;
|
||||||
size?: 'default' | 'compact';
|
size?: 'default' | 'compact';
|
||||||
@@ -23,6 +24,7 @@ interface ContextUsageDisplayProps {
|
|||||||
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||||
totalTokens,
|
totalTokens,
|
||||||
percentage,
|
percentage,
|
||||||
|
colorPercentage,
|
||||||
contextLimit,
|
contextLimit,
|
||||||
outputLimit,
|
outputLimit,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
@@ -36,6 +38,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
pressed = false,
|
pressed = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
|
const [mobileTooltipOpen, setMobileTooltipOpen] = React.useState(false);
|
||||||
|
const colorPct = typeof colorPercentage === 'number' ? colorPercentage : percentage;
|
||||||
|
|
||||||
const formatTokens = (tokens: number) => {
|
const formatTokens = (tokens: number) => {
|
||||||
if (tokens >= 1_000_000) {
|
if (tokens >= 1_000_000) {
|
||||||
@@ -69,14 +72,14 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
{showPercentIcon ? (
|
{showPercentIcon ? (
|
||||||
<>
|
<>
|
||||||
<RiDonutChartFill
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span className="text-foreground">{Math.min(percentage, 999).toFixed(1)}%</span>
|
<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>
|
</span>
|
||||||
@@ -141,7 +144,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center pt-1 border-t border-border/40">
|
<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="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)}%
|
{Math.min(percentage, 999).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -131,7 +131,12 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
|
|||||||
|
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
resizeObserver?.observe(el);
|
resizeObserver?.observe(el);
|
||||||
mutationObserver?.observe(el, { childList: true, subtree: true });
|
mutationObserver?.observe(el, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ type InstallWebUpdateResult = {
|
|||||||
autoRestart?: boolean;
|
autoRestart?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WEB_UPDATE_POLL_INTERVAL_MS = 2000;
|
||||||
|
const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
|
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/openchamber/update-install', {
|
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++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/openchamber/update-check', {
|
const response = await fetch('/api/openchamber/update-check', {
|
||||||
@@ -268,7 +275,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
setWebUpdateState('error');
|
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]);
|
}, [info?.currentVersion]);
|
||||||
|
|
||||||
|
|||||||
@@ -1019,56 +1019,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
done();
|
done();
|
||||||
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
@@ -1083,13 +1033,13 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedQueryLower = trimmedQuery.toLowerCase();
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
|
|
||||||
searchFiles(currentDirectory, trimmedQuery, 150, {
|
searchFiles(currentDirectory, trimmedQuery, 150, {
|
||||||
includeHidden: showHidden,
|
includeHidden: showHidden,
|
||||||
respectGitignore: !showGitignored,
|
respectGitignore: !showGitignored,
|
||||||
|
type: 'file',
|
||||||
})
|
})
|
||||||
.then((hits) => {
|
.then((hits) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -1098,22 +1048,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
|
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
|
||||||
|
|
||||||
// Apply fuzzy scoring and sort by score
|
const mapped: FileNode[] = filtered.map((hit) => ({
|
||||||
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 }) => ({
|
|
||||||
name: hit.name,
|
name: hit.name,
|
||||||
path: normalizePath(hit.path),
|
path: normalizePath(hit.path),
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@@ -1137,7 +1072,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||||
|
|
||||||
const readFile = React.useCallback(async (path: string): Promise<string> => {
|
const readFile = React.useCallback(async (path: string): Promise<string> => {
|
||||||
if (files.readFile) {
|
if (files.readFile) {
|
||||||
|
|||||||
@@ -36,3 +36,6 @@ export const useDrawer = (): DrawerContextValue => {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export const useOptionalDrawer = (): DrawerContextValue | null => React.useContext(DrawerContext);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
export const useChatSearchDirectory = (): string | undefined => {
|
||||||
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
const sessions = useSessionStore((state) => state.sessions);
|
||||||
|
const worktreeMap = useSessionStore((state) => state.worktreeMetadata);
|
||||||
|
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
|
||||||
|
|
||||||
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
|
|
||||||
|
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
|
||||||
|
if (currentSessionId) {
|
||||||
|
const worktreeMetadata = worktreeMap.get(currentSessionId);
|
||||||
|
if (worktreeMetadata?.path) {
|
||||||
|
return worktreeMetadata.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionWithDirectory = Session & { directory?: string };
|
||||||
|
const currentSession = sessions.find((session) => session.id === currentSessionId) as SessionWithDirectory | undefined;
|
||||||
|
if (currentSession?.directory) {
|
||||||
|
return currentSession.directory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSessionDraft?.open && newSessionDraft.directoryOverride) {
|
||||||
|
return newSessionDraft.directoryOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeProjectId) {
|
||||||
|
const activeProject = projects.find((project) => project.id === activeProjectId);
|
||||||
|
if (activeProject?.path) {
|
||||||
|
return activeProject.path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackDirectory ?? undefined;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { animate } from 'motion/react';
|
import { animate } from 'motion/react';
|
||||||
import { useDrawer } from '@/contexts/DrawerContext';
|
import { useOptionalDrawer } from '@/contexts/DrawerContext';
|
||||||
|
|
||||||
type DrawerSwipeOptions = {
|
type DrawerSwipeOptions = {
|
||||||
edgeSide?: 'left' | 'right';
|
edgeSide?: 'left' | 'right';
|
||||||
@@ -11,7 +11,7 @@ type DrawerSwipeOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
|
export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
|
||||||
const drawer = useDrawer();
|
const drawer = useOptionalDrawer();
|
||||||
const {
|
const {
|
||||||
edgeSide,
|
edgeSide,
|
||||||
strictHorizontalIntent = false,
|
strictHorizontalIntent = false,
|
||||||
@@ -25,13 +25,15 @@ export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
|
|||||||
const isDraggingDrawerRef = React.useRef<'left' | 'right' | null>(null);
|
const isDraggingDrawerRef = React.useRef<'left' | 'right' | null>(null);
|
||||||
|
|
||||||
const handleTouchStart = React.useCallback((e: React.TouchEvent) => {
|
const handleTouchStart = React.useCallback((e: React.TouchEvent) => {
|
||||||
|
if (!drawer) return;
|
||||||
touchStartXRef.current = e.touches[0].clientX;
|
touchStartXRef.current = e.touches[0].clientX;
|
||||||
touchStartYRef.current = e.touches[0].clientY;
|
touchStartYRef.current = e.touches[0].clientY;
|
||||||
isHorizontalSwipeRef.current = null;
|
isHorizontalSwipeRef.current = null;
|
||||||
isDraggingDrawerRef.current = null;
|
isDraggingDrawerRef.current = null;
|
||||||
}, []);
|
}, [drawer]);
|
||||||
|
|
||||||
const handleTouchMove = React.useCallback((e: React.TouchEvent) => {
|
const handleTouchMove = React.useCallback((e: React.TouchEvent) => {
|
||||||
|
if (!drawer) return;
|
||||||
const currentX = e.touches[0].clientX;
|
const currentX = e.touches[0].clientX;
|
||||||
const currentY = e.touches[0].clientY;
|
const currentY = e.touches[0].clientY;
|
||||||
const deltaX = currentX - touchStartXRef.current;
|
const deltaX = currentX - touchStartXRef.current;
|
||||||
@@ -106,6 +108,7 @@ export function useDrawerSwipe(options: DrawerSwipeOptions = {}) {
|
|||||||
}, [activationDistance, drawer, edgeSide, horizontalIntentRatio, onlyWhenClosed, strictHorizontalIntent]);
|
}, [activationDistance, drawer, edgeSide, horizontalIntentRatio, onlyWhenClosed, strictHorizontalIntent]);
|
||||||
|
|
||||||
const handleTouchEnd = React.useCallback((e: React.TouchEvent) => {
|
const handleTouchEnd = React.useCallback((e: React.TouchEvent) => {
|
||||||
|
if (!drawer) return;
|
||||||
if (isHorizontalSwipeRef.current !== true) return;
|
if (isHorizontalSwipeRef.current !== true) return;
|
||||||
|
|
||||||
const endX = e.changedTouches[0].clientX;
|
const endX = e.changedTouches[0].clientX;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { toast } from '@/components/ui';
|
|||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { isTauriShell } from '@/lib/desktop';
|
import { isTauriShell } from '@/lib/desktop';
|
||||||
@@ -58,8 +59,35 @@ export const useMenuActions = (
|
|||||||
setAboutDialogOpen,
|
setAboutDialogOpen,
|
||||||
} = useUIStore();
|
} = useUIStore();
|
||||||
const { addProject } = useProjectsStore();
|
const { addProject } = useProjectsStore();
|
||||||
|
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
|
||||||
const { requestAccess, startAccessing } = useFileSystemAccess();
|
const { requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { setThemeMode } = useThemeSystem();
|
const { setThemeMode } = useThemeSystem();
|
||||||
|
const checkUpdatesInFlightRef = React.useRef(false);
|
||||||
|
|
||||||
|
const handleCheckForUpdates = React.useCallback(() => {
|
||||||
|
if (checkUpdatesInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkUpdatesInFlightRef.current = true;
|
||||||
|
|
||||||
|
void checkForUpdates()
|
||||||
|
.then(() => {
|
||||||
|
const { available, error } = useUpdateStore.getState();
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to check for updates', {
|
||||||
|
description: error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!available) {
|
||||||
|
toast.success('You are on the latest version');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
checkUpdatesInFlightRef.current = false;
|
||||||
|
});
|
||||||
|
}, [checkForUpdates]);
|
||||||
|
|
||||||
const handleChangeWorkspace = React.useCallback(() => {
|
const handleChangeWorkspace = React.useCallback(() => {
|
||||||
if (isTauriShell()) {
|
if (isTauriShell()) {
|
||||||
@@ -216,9 +244,17 @@ export const useMenuActions = (
|
|||||||
handleAction(action);
|
handleAction(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCheckForUpdatesEvent = () => {
|
||||||
|
handleCheckForUpdates();
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||||
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
|
||||||
}, [handleAction]);
|
return () => {
|
||||||
|
window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
|
||||||
|
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleCheckForUpdatesEvent);
|
||||||
|
};
|
||||||
|
}, [handleAction, handleCheckForUpdates]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ export interface SettingsPayload {
|
|||||||
autoDeleteAfterDays?: number;
|
autoDeleteAfterDays?: number;
|
||||||
queueModeEnabled?: boolean;
|
queueModeEnabled?: boolean;
|
||||||
gitmojiEnabled?: boolean;
|
gitmojiEnabled?: boolean;
|
||||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
terminalFontSize?: number;
|
terminalFontSize?: number;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type AppearanceSlice = {
|
|||||||
maxLastMessageLength: number;
|
maxLastMessageLength: number;
|
||||||
autoDeleteEnabled: boolean;
|
autoDeleteEnabled: boolean;
|
||||||
autoDeleteAfterDays: number;
|
autoDeleteAfterDays: number;
|
||||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
terminalFontSize: number;
|
terminalFontSize: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export type DesktopSettings = {
|
|||||||
gitProviderId?: string;
|
gitProviderId?: string;
|
||||||
gitModelId?: string;
|
gitModelId?: string;
|
||||||
pwaAppName?: string;
|
pwaAppName?: string;
|
||||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||||
userMessageRenderingMode?: 'markdown' | 'plain';
|
userMessageRenderingMode?: 'markdown' | 'plain';
|
||||||
stickyUserHeader?: boolean;
|
stickyUserHeader?: boolean;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
|||||||
@@ -2054,46 +2054,36 @@ class OpencodeService {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
includeHidden?: boolean;
|
includeHidden?: boolean;
|
||||||
respectGitignore?: boolean;
|
respectGitignore?: boolean;
|
||||||
|
dirs?: boolean;
|
||||||
|
type?: 'file' | 'directory';
|
||||||
}
|
}
|
||||||
): Promise<ProjectFileSearchHit[]> {
|
): Promise<ProjectFileSearchHit[]> {
|
||||||
const desktopFiles = getDesktopFilesApi();
|
|
||||||
const directory = typeof options?.directory === 'string' && options.directory.trim().length > 0
|
const directory = typeof options?.directory === 'string' && options.directory.trim().length > 0
|
||||||
? options.directory.trim()
|
? options.directory.trim()
|
||||||
: this.currentDirectory;
|
: this.currentDirectory;
|
||||||
const normalizedDirectory = directory ? normalizeFsPath(directory) : null;
|
const normalizedDirectory = directory ? normalizeFsPath(directory) : null;
|
||||||
|
const scopedClient = directory ? this.getScopedApiClient(directory) : this.client;
|
||||||
|
|
||||||
if (desktopFiles) {
|
|
||||||
try {
|
try {
|
||||||
const results = await desktopFiles.search({
|
const response = await scopedClient.find.files({
|
||||||
directory: directory || '',
|
|
||||||
query,
|
query,
|
||||||
maxResults: options?.limit,
|
limit: typeof options?.limit === 'number' && Number.isFinite(options.limit) ? options.limit : undefined,
|
||||||
includeHidden: options?.includeHidden,
|
dirs: options?.dirs === false || options?.type === 'file' ? 'false' : 'true',
|
||||||
respectGitignore: options?.respectGitignore,
|
type: options?.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!Array.isArray(results)) {
|
const items = Array.isArray(response?.data) ? response.data : [];
|
||||||
return [];
|
return items.map<ProjectFileSearchHit>((item) => {
|
||||||
}
|
const normalizedRelativePath = normalizeFsPath(item);
|
||||||
|
const name = normalizedRelativePath.split('/').filter(Boolean).pop() || normalizedRelativePath;
|
||||||
return results.map<ProjectFileSearchHit>((file) => {
|
const normalizedPath = normalizedDirectory
|
||||||
const normalizedPath = normalizeFsPath(file.path);
|
? normalizeFsPath(`${normalizedDirectory}/${normalizedRelativePath}`)
|
||||||
const name = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath;
|
: normalizeFsPath(normalizedRelativePath);
|
||||||
const relativePath = (() => {
|
|
||||||
if (file.preview && file.preview.length > 0 && typeof file.preview[0] === 'string') {
|
|
||||||
return normalizeFsPath(file.preview[0]);
|
|
||||||
}
|
|
||||||
if (normalizedDirectory && normalizedPath.startsWith(normalizedDirectory)) {
|
|
||||||
const suffix = normalizedPath.slice(normalizedDirectory.length).replace(/^\/+/, '');
|
|
||||||
return suffix || name;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
relativePath,
|
relativePath: normalizedRelativePath,
|
||||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2103,44 +2093,6 @@ class OpencodeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (directory && directory.length > 0) {
|
|
||||||
params.set('directory', directory);
|
|
||||||
}
|
|
||||||
if (typeof query === 'string') {
|
|
||||||
params.set('q', query);
|
|
||||||
}
|
|
||||||
if (typeof options?.limit === 'number' && Number.isFinite(options.limit)) {
|
|
||||||
params.set('limit', String(options.limit));
|
|
||||||
}
|
|
||||||
if (options?.includeHidden) {
|
|
||||||
params.set('includeHidden', 'true');
|
|
||||||
}
|
|
||||||
if (options?.respectGitignore === false) {
|
|
||||||
params.set('respectGitignore', 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchUrl = `${this.baseUrl}/fs/search${params.toString() ? `?${params.toString()}` : ''}`;
|
|
||||||
const response = await fetch(searchUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json().catch(() => ({}));
|
|
||||||
const message = typeof error.error === 'string' ? error.error : 'Failed to search files';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result || !Array.isArray(result.files)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return result.files as ProjectFileSearchHit[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFilesystemHome(): Promise<string | null> {
|
async getFilesystemHome(): Promise<string | null> {
|
||||||
// Optimization: Check for desktop runtime first to avoid unnecessary network calls
|
// Optimization: Check for desktop runtime first to avoid unnecessary network calls
|
||||||
// and fix the "SyntaxError" warning when the endpoint is missing
|
// and fix the "SyntaxError" warning when the endpoint is missing
|
||||||
|
|||||||
@@ -366,7 +366,10 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
|||||||
store.setMaxLastMessageLength(settings.maxLastMessageLength);
|
store.setMaxLastMessageLength(settings.maxLastMessageLength);
|
||||||
}
|
}
|
||||||
if (typeof settings.toolCallExpansion === 'string'
|
if (typeof settings.toolCallExpansion === 'string'
|
||||||
&& (settings.toolCallExpansion === 'collapsed' || settings.toolCallExpansion === 'activity' || settings.toolCallExpansion === 'detailed')) {
|
&& (settings.toolCallExpansion === 'collapsed'
|
||||||
|
|| settings.toolCallExpansion === 'activity'
|
||||||
|
|| settings.toolCallExpansion === 'detailed'
|
||||||
|
|| settings.toolCallExpansion === 'changes')) {
|
||||||
if (settings.toolCallExpansion !== store.toolCallExpansion) {
|
if (settings.toolCallExpansion !== store.toolCallExpansion) {
|
||||||
store.setToolCallExpansion(settings.toolCallExpansion);
|
store.setToolCallExpansion(settings.toolCallExpansion);
|
||||||
}
|
}
|
||||||
@@ -733,7 +736,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
typeof candidate.toolCallExpansion === 'string'
|
typeof candidate.toolCallExpansion === 'string'
|
||||||
&& (candidate.toolCallExpansion === 'collapsed'
|
&& (candidate.toolCallExpansion === 'collapsed'
|
||||||
|| candidate.toolCallExpansion === 'activity'
|
|| candidate.toolCallExpansion === 'activity'
|
||||||
|| candidate.toolCallExpansion === 'detailed')
|
|| candidate.toolCallExpansion === 'detailed'
|
||||||
|
|| candidate.toolCallExpansion === 'changes')
|
||||||
) {
|
) {
|
||||||
result.toolCallExpansion = candidate.toolCallExpansion;
|
result.toolCallExpansion = candidate.toolCallExpansion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface FileSearchStoreState {
|
|||||||
directory: string,
|
directory: string,
|
||||||
query: string,
|
query: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
options?: { includeHidden?: boolean; respectGitignore?: boolean }
|
options?: { includeHidden?: boolean; respectGitignore?: boolean; type?: 'file' | 'directory' }
|
||||||
) => Promise<ProjectFileSearchHit[]>;
|
) => Promise<ProjectFileSearchHit[]>;
|
||||||
invalidateDirectory: (directory?: string | null) => void;
|
invalidateDirectory: (directory?: string | null) => void;
|
||||||
}
|
}
|
||||||
@@ -29,11 +29,12 @@ const buildCacheKey = (
|
|||||||
query: string,
|
query: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
includeHidden: boolean,
|
includeHidden: boolean,
|
||||||
respectGitignore: boolean
|
respectGitignore: boolean,
|
||||||
|
type: 'file' | 'directory'
|
||||||
) => {
|
) => {
|
||||||
const normalizedDirectory = directory.trim();
|
const normalizedDirectory = directory.trim();
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
return `${normalizedDirectory}::${normalizedQuery}::${limit}::${includeHidden ? '1' : '0'}::${respectGitignore ? '1' : '0'}`;
|
return `${normalizedDirectory}::${normalizedQuery}::${limit}::${includeHidden ? '1' : '0'}::${respectGitignore ? '1' : '0'}::${type}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFileSearchStore = create<FileSearchStoreState>()(
|
export const useFileSearchStore = create<FileSearchStoreState>()(
|
||||||
@@ -51,7 +52,8 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
|
|||||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||||
const includeHidden = Boolean(options?.includeHidden);
|
const includeHidden = Boolean(options?.includeHidden);
|
||||||
const respectGitignore = options?.respectGitignore ?? true;
|
const respectGitignore = options?.respectGitignore ?? true;
|
||||||
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore);
|
const type = options?.type === 'directory' ? 'directory' : 'file';
|
||||||
|
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cached = get().cache[key];
|
const cached = get().cache[key];
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
|
|||||||
limit,
|
limit,
|
||||||
includeHidden,
|
includeHidden,
|
||||||
respectGitignore,
|
respectGitignore,
|
||||||
|
dirs: type !== 'file',
|
||||||
|
type,
|
||||||
})
|
})
|
||||||
.then((files) => {
|
.then((files) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const normalizeDirectory = (directory: string | null | undefined): string | null
|
|||||||
|
|
||||||
const toKey = (directory: string | null | undefined): string => normalizeDirectory(directory) ?? '__global__';
|
const toKey = (directory: string | null | undefined): string => normalizeDirectory(directory) ?? '__global__';
|
||||||
|
|
||||||
|
const getMcpApiClient = (directory: string | null | undefined) => {
|
||||||
|
const normalized = normalizeDirectory(directory);
|
||||||
|
if (!normalized) {
|
||||||
|
return opencodeClient.getApiClient();
|
||||||
|
}
|
||||||
|
return opencodeClient.getScopedApiClient(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
export const computeMcpHealth = (status: McpStatusMap | null | undefined): McpHealth => {
|
export const computeMcpHealth = (status: McpStatusMap | null | undefined): McpHealth => {
|
||||||
const entries = Object.entries(status ?? {});
|
const entries = Object.entries(status ?? {});
|
||||||
const connected = entries.filter(([, s]) => s?.status === 'connected').length;
|
const connected = entries.filter(([, s]) => s?.status === 'connected').length;
|
||||||
@@ -73,8 +81,8 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = opencodeClient.getApiClient();
|
const api = getMcpApiClient(directory);
|
||||||
const result = await api.mcp.status(directory ? { directory } : undefined);
|
const result = await api.mcp.status();
|
||||||
const data = (result.data ?? {}) as McpStatusMap;
|
const data = (result.data ?? {}) as McpStatusMap;
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -93,18 +101,17 @@ export const useMcpStore = create<McpStore>()(
|
|||||||
|
|
||||||
connect: async (name, directory) => {
|
connect: async (name, directory) => {
|
||||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||||
const api = opencodeClient.getApiClient();
|
const api = getMcpApiClient(normalized);
|
||||||
await api.mcp.connect({ name, ...(normalized ? { directory: normalized } : {}) }, { throwOnError: true });
|
await api.mcp.connect({ name }, { throwOnError: true });
|
||||||
await get().refresh({ directory: normalized, silent: true });
|
await get().refresh({ directory: normalized, silent: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
disconnect: async (name, directory) => {
|
disconnect: async (name, directory) => {
|
||||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||||
const api = opencodeClient.getApiClient();
|
const api = getMcpApiClient(normalized);
|
||||||
await api.mcp.disconnect({ name, ...(normalized ? { directory: normalized } : {}) }, { throwOnError: true });
|
await api.mcp.disconnect({ name }, { throwOnError: true });
|
||||||
await get().refresh({ directory: normalized, silent: true });
|
await get().refresh({ directory: normalized, silent: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ interface UIStore {
|
|||||||
autoDeleteLastRunAt: number | null;
|
autoDeleteLastRunAt: number | null;
|
||||||
messageLimit: number;
|
messageLimit: number;
|
||||||
|
|
||||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
toolCallExpansion: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
terminalFontSize: number;
|
terminalFontSize: number;
|
||||||
padding: number;
|
padding: number;
|
||||||
@@ -618,7 +618,7 @@ interface UIStore {
|
|||||||
setAutoDeleteAfterDays: (days: number) => void;
|
setAutoDeleteAfterDays: (days: number) => void;
|
||||||
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
|
setAutoDeleteLastRunAt: (timestamp: number | null) => void;
|
||||||
setMessageLimit: (value: number) => void;
|
setMessageLimit: (value: number) => void;
|
||||||
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed') => void;
|
setToolCallExpansion: (value: 'collapsed' | 'activity' | 'detailed' | 'changes') => void;
|
||||||
setFontSize: (size: number) => void;
|
setFontSize: (size: number) => void;
|
||||||
setTerminalFontSize: (size: number) => void;
|
setTerminalFontSize: (size: number) => void;
|
||||||
setPadding: (size: number) => void;
|
setPadding: (size: number) => void;
|
||||||
|
|||||||
@@ -1794,6 +1794,42 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
|||||||
return { id, type, success: true, data: { files, skipped } };
|
return { id, type, success: true, data: { files, skipped } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'api:files/save-image': {
|
||||||
|
const rawFileName = (payload as { fileName?: unknown })?.fileName;
|
||||||
|
const rawDataUrl = (payload as { dataUrl?: unknown })?.dataUrl;
|
||||||
|
const dataUrl = typeof rawDataUrl === 'string' ? rawDataUrl.trim() : '';
|
||||||
|
if (!dataUrl.startsWith('data:image/')) {
|
||||||
|
return { id, type, success: false, error: 'Invalid image payload' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFileName = typeof rawFileName === 'string' && rawFileName.trim().length > 0
|
||||||
|
? rawFileName.trim()
|
||||||
|
: `message-${Date.now()}.png`;
|
||||||
|
|
||||||
|
const saveUri = await vscode.window.showSaveDialog({
|
||||||
|
saveLabel: 'Save image',
|
||||||
|
defaultUri: vscode.workspace.workspaceFolders?.[0]
|
||||||
|
? vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, defaultFileName)
|
||||||
|
: undefined,
|
||||||
|
filters: { Images: ['png'] },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!saveUri) {
|
||||||
|
return { id, type, success: true, data: { saved: false, canceled: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const commaIndex = dataUrl.indexOf(',');
|
||||||
|
if (commaIndex === -1) {
|
||||||
|
return { id, type, success: false, error: 'Invalid image data URL' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = dataUrl.slice(commaIndex + 1);
|
||||||
|
const bytes = Buffer.from(base64, 'base64');
|
||||||
|
await vscode.workspace.fs.writeFile(saveUri, bytes);
|
||||||
|
|
||||||
|
return { id, type, success: true, data: { saved: true, path: saveUri.fsPath || saveUri.toString() } };
|
||||||
|
}
|
||||||
|
|
||||||
case 'api:config/settings:get': {
|
case 'api:config/settings:get': {
|
||||||
const settings = readSettings(ctx);
|
const settings = readSettings(ctx);
|
||||||
return { id, type, success: true, data: settings };
|
return { id, type, success: true, data: settings };
|
||||||
|
|||||||
@@ -35,20 +35,30 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||||
const data = await sendBridgeMessage<{ files: Array<{ path: string; relativePath?: string }> }>('api:fs:search', {
|
const directory = normalizePath(payload.directory);
|
||||||
directory: normalizePath(payload.directory),
|
const params = new URLSearchParams();
|
||||||
query: payload.query,
|
if (directory) {
|
||||||
limit: payload.maxResults,
|
params.set('directory', directory);
|
||||||
includeHidden: payload.includeHidden,
|
}
|
||||||
respectGitignore: payload.respectGitignore,
|
params.set('query', payload.query);
|
||||||
});
|
params.set('dirs', 'false');
|
||||||
|
params.set('type', 'file');
|
||||||
|
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
|
||||||
|
params.set('limit', String(payload.maxResults));
|
||||||
|
}
|
||||||
|
|
||||||
const files = Array.isArray(data?.files) ? data.files : [];
|
const response = await fetch(`/api/find/file?${params.toString()}`);
|
||||||
return files
|
if (!response.ok) {
|
||||||
.filter((file) => file && typeof file.path === 'string')
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
.map((file) => ({
|
throw new Error((error as { error?: string }).error || 'Failed to search files');
|
||||||
path: normalizePath(file.path),
|
}
|
||||||
preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined,
|
|
||||||
|
const result = (await response.json()) as string[];
|
||||||
|
const files = Array.isArray(result) ? result : [];
|
||||||
|
|
||||||
|
return files.map((relativePath) => ({
|
||||||
|
path: normalizePath(`${directory}/${relativePath}`),
|
||||||
|
preview: [normalizePath(relativePath)],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -452,24 +452,6 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/api/fs/search')) {
|
|
||||||
const directory = url.searchParams.get('directory') || '';
|
|
||||||
const query = url.searchParams.get('q') || '';
|
|
||||||
const limitParam = url.searchParams.get('limit');
|
|
||||||
const limit = limitParam ? Number(limitParam) : undefined;
|
|
||||||
const resolvedLimit = Number.isFinite(limit) ? limit : undefined;
|
|
||||||
const includeHidden = url.searchParams.get('includeHidden') === 'true';
|
|
||||||
const respectGitignore = url.searchParams.get('respectGitignore') !== 'false';
|
|
||||||
const data = await sendBridgeMessage('api:fs:search', {
|
|
||||||
directory,
|
|
||||||
query,
|
|
||||||
limit: resolvedLimit,
|
|
||||||
includeHidden,
|
|
||||||
respectGitignore,
|
|
||||||
});
|
|
||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith('/api/fs/mkdir')) {
|
if (pathname.startsWith('/api/fs/mkdir')) {
|
||||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||||
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
|
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
|
||||||
@@ -495,6 +477,18 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|||||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') {
|
||||||
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||||
|
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
|
||||||
|
? (body as { fileName: string }).fileName
|
||||||
|
: undefined;
|
||||||
|
const dataUrl = typeof (body as { dataUrl?: unknown }).dataUrl === 'string'
|
||||||
|
? (body as { dataUrl: string }).dataUrl
|
||||||
|
: undefined;
|
||||||
|
const data = await sendBridgeMessage('api:files/save-image', { fileName, dataUrl });
|
||||||
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/api/config/agents/')) {
|
if (pathname.startsWith('/api/config/agents/')) {
|
||||||
const encodedName = pathname.slice('/api/config/agents/'.length);
|
const encodedName = pathname.slice('/api/config/agents/'.length);
|
||||||
const name = decodeURIComponent(encodedName);
|
const name = decodeURIComponent(encodedName);
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ const OPENCHAMBER_VERSION = (() => {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
})();
|
})();
|
||||||
const fsPromises = fs.promises;
|
const fsPromises = fs.promises;
|
||||||
const DEFAULT_FILE_SEARCH_LIMIT = 60;
|
|
||||||
const MAX_FILE_SEARCH_LIMIT = 400;
|
|
||||||
const FILE_SEARCH_MAX_CONCURRENCY = 5;
|
const FILE_SEARCH_MAX_CONCURRENCY = 5;
|
||||||
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
const FILE_SEARCH_EXCLUDED_DIRS = new Set([
|
||||||
'node_modules',
|
'node_modules',
|
||||||
@@ -1997,7 +1995,7 @@ const sanitizeSettingsUpdate = (payload) => {
|
|||||||
}
|
}
|
||||||
if (typeof candidate.toolCallExpansion === 'string') {
|
if (typeof candidate.toolCallExpansion === 'string') {
|
||||||
const mode = candidate.toolCallExpansion.trim();
|
const mode = candidate.toolCallExpansion.trim();
|
||||||
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed') {
|
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
|
||||||
result.toolCallExpansion = mode;
|
result.toolCallExpansion = mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12364,53 +12362,6 @@ async function main(options = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/fs/search', async (req, res) => {
|
|
||||||
const rawRoot = typeof req.query.root === 'string' && req.query.root.trim().length > 0
|
|
||||||
? req.query.root.trim()
|
|
||||||
: typeof req.query.directory === 'string' && req.query.directory.trim().length > 0
|
|
||||||
? req.query.directory.trim()
|
|
||||||
: os.homedir();
|
|
||||||
const rawQuery = typeof req.query.q === 'string' ? req.query.q : '';
|
|
||||||
const includeHidden = req.query.includeHidden === 'true';
|
|
||||||
const respectGitignore = req.query.respectGitignore !== 'false';
|
|
||||||
const limitParam = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : undefined;
|
|
||||||
const parsedLimit = Number.isFinite(limitParam) ? Number(limitParam) : DEFAULT_FILE_SEARCH_LIMIT;
|
|
||||||
const limit = Math.max(1, Math.min(parsedLimit, MAX_FILE_SEARCH_LIMIT));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolvedRoot = path.resolve(normalizeDirectoryPath(rawRoot));
|
|
||||||
const stats = await fsPromises.stat(resolvedRoot);
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
return res.status(400).json({ error: 'Specified root is not a directory' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await searchFilesystemFiles(resolvedRoot, {
|
|
||||||
limit,
|
|
||||||
query: rawQuery || '',
|
|
||||||
includeHidden,
|
|
||||||
respectGitignore,
|
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
root: resolvedRoot,
|
|
||||||
count: files.length,
|
|
||||||
files
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to search filesystem:', error);
|
|
||||||
const err = error;
|
|
||||||
if (err && typeof err === 'object' && 'code' in err) {
|
|
||||||
const code = err.code;
|
|
||||||
if (code === 'ENOENT') {
|
|
||||||
return res.status(404).json({ error: 'Directory not found' });
|
|
||||||
}
|
|
||||||
if (code === 'EACCES') {
|
|
||||||
return res.status(403).json({ error: 'Access to directory denied' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: (error && error.message) || 'Failed to search files' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let ptyProviderPromise = null;
|
let ptyProviderPromise = null;
|
||||||
const getPtyProvider = async () => {
|
const getPtyProvider = async () => {
|
||||||
if (ptyProviderPromise) {
|
if (ptyProviderPromise) {
|
||||||
|
|||||||
@@ -21,18 +21,6 @@ type WebDirectoryListResponse = {
|
|||||||
entries?: WebDirectoryEntry[];
|
entries?: WebDirectoryEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type WebFileSearchResponse = {
|
|
||||||
root?: string;
|
|
||||||
directory?: string;
|
|
||||||
count?: number;
|
|
||||||
files?: Array<{
|
|
||||||
name?: string;
|
|
||||||
path?: string;
|
|
||||||
relativePath?: string;
|
|
||||||
extension?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
|
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
|
||||||
const directory = normalizePath(payload?.directory || payload?.path || fallbackDirectory);
|
const directory = normalizePath(payload?.directory || payload?.path || fallbackDirectory);
|
||||||
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
||||||
@@ -78,34 +66,27 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
|||||||
params.set('directory', directory);
|
params.set('directory', directory);
|
||||||
}
|
}
|
||||||
|
|
||||||
params.set('q', payload.query);
|
params.set('query', payload.query);
|
||||||
|
params.set('dirs', 'false');
|
||||||
|
params.set('type', 'file');
|
||||||
|
|
||||||
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
|
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
|
||||||
params.set('limit', String(payload.maxResults));
|
params.set('limit', String(payload.maxResults));
|
||||||
}
|
}
|
||||||
if (payload.includeHidden) {
|
|
||||||
params.set('includeHidden', 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`/api/fs/search?${params.toString()}`);
|
const response = await fetch(`/api/find/file?${params.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
throw new Error((error as { error?: string }).error || 'Failed to search files');
|
throw new Error((error as { error?: string }).error || 'Failed to search files');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (await response.json()) as WebFileSearchResponse;
|
const result = (await response.json()) as string[];
|
||||||
const files = Array.isArray(result?.files) ? result.files : [];
|
const files = Array.isArray(result) ? result : [];
|
||||||
|
|
||||||
return files
|
return files.map((relativePath) => ({
|
||||||
.filter((file): file is { path: string; relativePath?: string } =>
|
path: normalizePath(`${directory}/${relativePath}`),
|
||||||
Boolean(file && typeof file.path === 'string')
|
preview: [normalizePath(relativePath)],
|
||||||
)
|
|
||||||
.map((file) => ({
|
|
||||||
path: normalizePath(file.path),
|
|
||||||
preview: typeof file.relativePath === 'string' && file.relativePath.length > 0
|
|
||||||
? [normalizePath(file.relativePath)]
|
|
||||||
: undefined,
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user