From d6b95213c5580d63d57280010204f4e7ec922d45 Mon Sep 17 00:00:00 2001 From: Anaya Date: Sat, 22 Aug 2026 18:31:43 -0700 Subject: [PATCH 1/4] Add unit tests for shared quick-open and tabs utilities The fuzzy scorer and file filtering used by quick open, global search, AI mentions, and the file explorer had no direct tests, nor did the tab display name calculation that disambiguates buffers sharing a filename. Adds focused unit tests for fuzzyScore scoring tiers, density rejection, file ignoring rules, recent-file workspace filtering, and display name disambiguation including virtual buffers. Writing the path shortener tests surfaced a small bug: absolute paths produced an empty first segment after splitting on "/", so fallback display names rendered with a double slash like "..//workspace/x.ts". Empty segments are now dropped when collecting path segments. --- .../quick-open/tests/file-filtering.test.ts | 93 +++++++++++++++++++ .../quick-open/tests/fuzzy-search.test.ts | 42 +++++++++ .../tabs/tests/path-shortener.test.ts | 88 ++++++++++++++++++ src/features/tabs/utils/path-shortener.ts | 4 +- 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/features/quick-open/tests/file-filtering.test.ts create mode 100644 src/features/quick-open/tests/fuzzy-search.test.ts create mode 100644 src/features/tabs/tests/path-shortener.test.ts diff --git a/src/features/quick-open/tests/file-filtering.test.ts b/src/features/quick-open/tests/file-filtering.test.ts new file mode 100644 index 000000000..8fd7370e2 --- /dev/null +++ b/src/features/quick-open/tests/file-filtering.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { RecentFile } from "@/features/file-system/types/recent-files.types"; +import { filterQuickOpenRecentFiles, shouldIgnoreFile } from "../utils/file-filtering"; + +function makeRecentFile(path: string, overrides: Partial = {}): RecentFile { + return { + path, + name: path.split("/").pop() ?? path, + lastAccessed: "2026-01-01T00:00:00.000Z", + accessCount: 1, + frecencyScore: 1, + workspacePath: "/workspace", + ...overrides, + }; +} + +describe("shouldIgnoreFile", () => { + it("ignores files inside dependency and build directories", () => { + expect(shouldIgnoreFile("/workspace/node_modules/lib/index.js")).toBe(true); + expect(shouldIgnoreFile("/workspace/dist/bundle.min.js")).toBe(true); + expect(shouldIgnoreFile("src/target/debug/app.rs")).toBe(true); + }); + + it("ignores lockfiles and OS metadata files", () => { + expect(shouldIgnoreFile("/workspace/package-lock.json")).toBe(true); + expect(shouldIgnoreFile("/workspace/Cargo.lock")).toBe(true); + expect(shouldIgnoreFile("/workspace/.DS_Store")).toBe(true); + }); + + it("keeps regular source files", () => { + expect(shouldIgnoreFile("/workspace/src/main.ts")).toBe(false); + expect(shouldIgnoreFile("/workspace/README.md")).toBe(false); + }); +}); + +describe("filterQuickOpenRecentFiles", () => { + const indexedPaths = new Set(["/workspace/src/a.ts", "/workspace/src/b.ts"]); + + it("drops recent files from other workspaces", () => { + const filtered = filterQuickOpenRecentFiles( + [ + makeRecentFile("/workspace/src/a.ts"), + makeRecentFile("/other/src/c.ts", { workspacePath: "/other" }), + ], + "/workspace", + indexedPaths, + true, + ); + + expect(filtered.map((file) => file.path)).toEqual(["/workspace/src/a.ts"]); + }); + + it("keeps files that exist in the workspace index", () => { + const filtered = filterQuickOpenRecentFiles( + [makeRecentFile("/workspace/src/a.ts"), makeRecentFile("/workspace/src/deleted.ts")], + "/workspace", + indexedPaths, + true, + ); + + expect(filtered.map((file) => file.path)).toEqual(["/workspace/src/a.ts"]); + }); + + it("keeps external and unindexed files while the file tree has not loaded", () => { + const filtered = filterQuickOpenRecentFiles( + [ + makeRecentFile("/outside/d.ts", { external: true }), + makeRecentFile("/workspace/src/unindexed.ts"), + ], + "/workspace", + indexedPaths, + false, + ); + + expect(filtered).toHaveLength(2); + }); + + it("keeps indexed files when no root folder is open", () => { + const filtered = filterQuickOpenRecentFiles( + [ + makeRecentFile("/anywhere/x.ts", { workspacePath: null }), + makeRecentFile("/workspace/src/a.ts"), + ], + null, + indexedPaths, + true, + ); + + // Without a root folder everything belongs to the workspace, + // but loaded indexes still gate which files exist. + expect(filtered.map((file) => file.path)).toEqual(["/workspace/src/a.ts"]); + }); +}); diff --git a/src/features/quick-open/tests/fuzzy-search.test.ts b/src/features/quick-open/tests/fuzzy-search.test.ts new file mode 100644 index 000000000..f672ce666 --- /dev/null +++ b/src/features/quick-open/tests/fuzzy-search.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; +import { fuzzyScore } from "../utils/fuzzy-search"; + +describe("fuzzyScore", () => { + it("scores exact matches highest, ignoring case", () => { + expect(fuzzyScore("Button", "button")).toBe(1000); + expect(fuzzyScore("README.md", "readme.md")).toBe(1000); + }); + + it("scores prefix matches above substring matches", () => { + const prefix = fuzzyScore("use-file-search", "use-"); + const substring = fuzzyScore("hooks/use-file-search.ts", "file"); + + expect(prefix).toBe(800); + expect(substring).toBe(600); + }); + + it("returns 0 for empty queries and short queries without substring matches", () => { + expect(fuzzyScore("anything", "")).toBe(0); + // Queries of two characters or less only match substrings + expect(fuzzyScore("abcdef", "xb")).toBe(0); + }); + + it("returns 0 when the text does not contain the query characters in order", () => { + expect(fuzzyScore("settings-dialog", "dialog-settings")).toBe(0); + expect(fuzzyScore("abc", "xyz")).toBe(0); + }); + + it("scores subsequence fuzzy matches positively and rewards consecutive runs", () => { + const spreadOut = fuzzyScore("use-file-search", "usr"); + const consecutive = fuzzyScore("user-profile", "usr"); + + expect(spreadOut).toBeGreaterThan(0); + expect(consecutive).toBeGreaterThan(spreadOut); + }); + + it("rejects sparse subsequences that fall below the density threshold", () => { + // Matching every character would need too many gaps across the text, + // so the scorer returns 0 to avoid garbage results. + expect(fuzzyScore("a-very-long-component-name-in-a-deep-folder", "avldnmt")).toBe(0); + }); +}); diff --git a/src/features/tabs/tests/path-shortener.test.ts b/src/features/tabs/tests/path-shortener.test.ts new file mode 100644 index 000000000..fd541ff34 --- /dev/null +++ b/src/features/tabs/tests/path-shortener.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EditorContent } from "@/features/panes/types/pane-content.types"; +import { calculateDisplayNames } from "../utils/path-shortener"; + +function makeEditorBuffer(id: string, path: string, isVirtual = false): EditorContent { + return { + id, + type: "editor", + path, + name: path.split("/").pop() ?? path, + content: "", + savedContent: "", + isDirty: false, + isVirtual, + isPinned: false, + isPreview: false, + isActive: false, + language: "typescript", + tokens: [], + }; +} + +describe("calculateDisplayNames", () => { + it("shows the bare filename when it is unique across buffers", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/src/app.ts"), + makeEditorBuffer("b", "/workspace/lib/util.ts"), + ], + "/workspace", + ); + + expect(names.get("a")).toBe("app.ts"); + expect(names.get("b")).toBe("util.ts"); + }); + + it("disambiguates duplicate filenames with their parent directory", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/client/index.ts"), + makeEditorBuffer("b", "/workspace/server/index.ts"), + ], + "/workspace", + ); + + expect(names.get("a")).toBe("../client/index.ts"); + expect(names.get("b")).toBe("../server/index.ts"); + }); + + it("walks further up the path until duplicates become distinct", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/packages/web/src/index.ts"), + makeEditorBuffer("b", "/workspace/packages/api/src/index.ts"), + makeEditorBuffer("c", "/workspace/docs/src/index.ts"), + ], + "/workspace", + ); + + // One parent segment is not enough (two "src" folders), two are. + expect(names.get("a")).toBe("../web/src/index.ts"); + expect(names.get("b")).toBe("../api/src/index.ts"); + expect(names.get("c")).toBe("../docs/src/index.ts"); + }); + + it("falls back to the full relative path for identical paths", () => { + const names = calculateDisplayNames( + [ + makeEditorBuffer("a", "/workspace/dup/index.ts"), + makeEditorBuffer("b", "/workspace/dup/index.ts"), + ], + "/workspace", + ); + + expect(names.get("a")).toBe("../workspace/dup/index.ts"); + expect(names.get("b")).toBe("../workspace/dup/index.ts"); + }); + + it("uses the buffer name for virtual buffers", () => { + const names = calculateDisplayNames( + [makeEditorBuffer("term", "", true), makeEditorBuffer("file", "/workspace/a.ts")], + "/workspace", + ); + + expect(names.has("term")).toBe(true); + expect(names.get("file")).toBe("a.ts"); + }); +}); diff --git a/src/features/tabs/utils/path-shortener.ts b/src/features/tabs/utils/path-shortener.ts index f04bcb9f1..c441f157a 100644 --- a/src/features/tabs/utils/path-shortener.ts +++ b/src/features/tabs/utils/path-shortener.ts @@ -8,8 +8,10 @@ function getPathSegments(filePath: string): string[] { // Normalize path separators to forward slash const normalized = filePath.replace(/\\/g, "/"); const parts = normalized.split("/"); + // Drop empty segments caused by absolute-path leading slashes + const nonEmptyParts = parts.filter((part) => part.length > 0); // Return all parts except the last one (filename) - return parts.slice(0, -1); + return nonEmptyParts.slice(0, -1); } /** From 866a25671fc84159eb3ea15516c78408516b1fb0 Mon Sep 17 00:00:00 2001 From: Ryan Bahadori Date: Sun, 20 Sep 2026 11:14:46 -0700 Subject: [PATCH 2/4] feat: add file-based formatting, chat image support, and parser manifest versioning - File formatting: implement file-based output method for generic formatters (read formatted content from file when output_method != stdout) - Chat images: support pasting and sending images in AI chat (convert pasted images to ImageContent, preview before sending) - Parser cache: fetch version from manifest JSON and compute SHA-256 checksum (auto-populate version/checksum when caching parsers to IndexedDB) --- src-tauri/src/commands/editor/format.rs | 6 +- src/features/ai/components/chat/ai-chat.tsx | 2445 ++++++++-------- .../ai/components/input/chat-input-bar.tsx | 2593 +++++++++-------- src/features/ai/types/ai-chat.types.ts | 156 +- src/features/editor/lib/wasm-parser/loader.ts | 1751 ++++++----- 5 files changed, 3692 insertions(+), 3259 deletions(-) diff --git a/src-tauri/src/commands/editor/format.rs b/src-tauri/src/commands/editor/format.rs index 7c9ed5343..60d54b408 100644 --- a/src-tauri/src/commands/editor/format.rs +++ b/src-tauri/src/commands/editor/format.rs @@ -133,8 +133,10 @@ async fn format_with_generic( let formatted = if output_method == "stdout" { String::from_utf8_lossy(&output.stdout).to_string() } else { - // For file output, read the file (TODO: implement file-based formatting) - content.to_string() + // For file output, read the formatted file + let file_path = + file_path.ok_or("file_path required for file output method")?; + std::fs::read_to_string(file_path).unwrap_or_else(|_| content.to_string()) }; Ok(FormatResponse { diff --git a/src/features/ai/components/chat/ai-chat.tsx b/src/features/ai/components/chat/ai-chat.tsx index 9af1b1888..4147dfd04 100644 --- a/src/features/ai/components/chat/ai-chat.tsx +++ b/src/features/ai/components/chat/ai-chat.tsx @@ -1,38 +1,48 @@ import { listen } from "@tauri-apps/api/event"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { appendChatAcpEvent, type ChatAcpEventInput } from "@/features/ai/lib/acp-event-timeline"; import { - isAcpAuthenticationError, - isAcpConfigurationError, + appendChatAcpEvent, + type ChatAcpEventInput, +} from "@/features/ai/lib/acp-event-timeline"; +import { + isAcpAuthenticationError, + isAcpConfigurationError, } from "@/features/ai/lib/acp-authentication"; import { getChatTitleFromSessionInfo } from "@/features/ai/lib/acp-session-info"; import { parseDirectAcpUiAction } from "@/features/ai/lib/acp-ui-intents"; import { - appendReferencedFiles, - loadFilesByPaths, - parseMentionsAndLoadFiles, + appendReferencedFiles, + loadFilesByPaths, + parseMentionsAndLoadFiles, } from "@/features/ai/lib/file-mentions"; import { extractFollowUpActions } from "@/features/ai/lib/follow-up-actions"; import { buildConversationHistory } from "@/features/ai/lib/conversation-history"; import { openAgentHistoryChat } from "@/features/ai/lib/open-agent-history"; import { - createToolCall, - markToolCallComplete, - updateToolCall, + createToolCall, + markToolCallComplete, + updateToolCall, } from "@/features/ai/lib/tool-call-state"; import { requestInlineEdit } from "@/features/editor/services/editor-inline-edit-service"; import { AcpStreamHandler } from "@/features/ai/services/acp-stream-handler"; import { CodexIntegrationService } from "@/features/ai/integrations/codex/codex-integration-service"; import { CODEX_INTEGRATION_ID } from "@/features/ai/integrations/integration-registry"; -import { getChatCompletionStream, isAcpAgent } from "@/features/ai/services/ai-chat-service"; +import { + getChatCompletionStream, + isAcpAgent, +} from "@/features/ai/services/ai-chat-service"; import { useAIChatStore } from "@/features/ai/stores/ai-chat.store"; import type { AcpEvent } from "@/features/ai/types/acp.types"; import type { ContextInfo } from "@/features/ai/types/ai-context.types"; -import type { AIChatProps, Message } from "@/features/ai/types/ai-chat.types"; +import type { + AIChatProps, + Message, + ImageContent, +} from "@/features/ai/types/ai-chat.types"; import type { ChatAcpEvent } from "@/features/ai/types/chat-ui.types"; import { - getFallbackAgentSessionTitle, - normalizeAgentSessionTitle, + getFallbackAgentSessionTitle, + normalizeAgentSessionTitle, } from "@/features/ai/utils/chat-session-title"; import { getMessageSearchMatches } from "@/features/ai/utils/message-search"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; @@ -43,1162 +53,1305 @@ import { hasProductCapability } from "@/features/window/lib/product-capabilities import { useProjectStore } from "@/features/window/stores/project.store"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/ui/empty"; import { - MessageScroller, - MessageScrollerButton, - MessageScrollerProvider, - MessageScrollerViewport, + MessageScroller, + MessageScrollerButton, + MessageScrollerProvider, + MessageScrollerViewport, } from "@/ui/message-scroller"; import { cn } from "@/utils/cn"; import { AgentStartView } from "../agent-start-view"; import { useChatActions, useChatState } from "../../hooks/use-chat-store"; import AIChatInputBar from "../input/chat-input-bar"; -import { AcpPermissionPrompt, type AcpPermissionRequest } from "./acp-permission-prompt"; +import { + AcpPermissionPrompt, + type AcpPermissionRequest, +} from "./acp-permission-prompt"; import { ChatHeader } from "./chat-header"; import { ChatMessages } from "./chat-messages"; const createMessageId = () => - globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const AIChat = memo(function AIChat({ - className, - surfaceId, - chatId, - isActiveSurface = true, - activeBuffer, - buffers = [], - selectedFiles = [], - allProjectFiles = [], - onApplyCode, + className, + surfaceId, + chatId, + isActiveSurface = true, + activeBuffer, + buffers = [], + selectedFiles = [], + allProjectFiles = [], + onApplyCode, }: AIChatProps) { - const rootFolderPath = useProjectStore((state) => state.rootFolderPath); - const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); - const subscription = useAuthStore((state) => state.subscription); - const enterprisePolicy = subscription?.enterprise?.policy; - const isAiChatBlockedByPolicy = Boolean( - enterprisePolicy?.managedMode && !enterprisePolicy.aiChatEnabled, - ); - - const chatState = useChatState(); - const chatActions = useChatActions(); - const { showToast } = useToast(); - - const abortControllerRef = useRef(null); - const [permissionQueue, setPermissionQueue] = useState([]); - const [acpEvents, setAcpEvents] = useState([]); - const [isMessageSearchOpen, setIsMessageSearchOpen] = useState(false); - const [messageSearchQuery, setMessageSearchQuery] = useState(""); - const [activeMessageSearchIndex, setActiveMessageSearchIndex] = useState(0); - const [selectedBufferIds, setSelectedBufferIds] = useState>(new Set()); - const [selectedFilesPaths, setSelectedFilesPaths] = useState>(new Set()); - const [isSurfaceTyping, setIsSurfaceTyping] = useState(false); - const [surfaceStreamingMessageId, setSurfaceStreamingMessageId] = useState(null); - const [queueCount, setQueueCount] = useState(0); - const messageQueueRef = useRef([]); - const effectiveChatId = chatId ?? chatState.currentChatId; - const currentChat = useMemo( - () => chatState.chats.find((chat) => chat.id === effectiveChatId), - [chatState.chats, effectiveChatId], - ); - const currentAgentId = currentChat?.agentId ?? useAIChatStore.getState().selectedAgentId; - const messageSearchMatches = useMemo( - () => getMessageSearchMatches(currentChat?.messages ?? [], messageSearchQuery), - [currentChat?.messages, messageSearchQuery], - ); - const activeMessageSearchMatch = messageSearchMatches[activeMessageSearchIndex] ?? null; - - const closeMessageSearch = useCallback(() => { - setIsMessageSearchOpen(false); - setMessageSearchQuery(""); - setActiveMessageSearchIndex(0); - }, []); - - const goToPreviousMessageSearchMatch = useCallback(() => { - if (messageSearchMatches.length === 0) return; - setActiveMessageSearchIndex((index) => - index === 0 ? messageSearchMatches.length - 1 : index - 1, - ); - }, [messageSearchMatches.length]); - - const goToNextMessageSearchMatch = useCallback(() => { - if (messageSearchMatches.length === 0) return; - setActiveMessageSearchIndex((index) => (index + 1) % messageSearchMatches.length); - }, [messageSearchMatches.length]); - - useEffect(() => { - chatActions.checkApiKey(aiProviderId); - chatActions.checkAllProviderApiKeys(); - }, [aiProviderId, chatActions.checkApiKey, chatActions.checkAllProviderApiKeys]); - - // Clear ACP events when switching chats - useEffect(() => { - setAcpEvents([]); - closeMessageSearch(); - setSelectedBufferIds(new Set()); - setSelectedFilesPaths(new Set()); - }, [closeMessageSearch, effectiveChatId]); - - useEffect(() => { - setActiveMessageSearchIndex(0); - }, [messageSearchQuery]); - - useEffect(() => { - if (messageSearchMatches.length === 0) { - setActiveMessageSearchIndex(0); - return; - } - - setActiveMessageSearchIndex((index) => Math.min(index, messageSearchMatches.length - 1)); - }, [messageSearchMatches.length]); - - useEffect(() => { - if (!isActiveSurface || isAiChatBlockedByPolicy) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { - event.preventDefault(); - setIsMessageSearchOpen(true); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isActiveSurface, isAiChatBlockedByPolicy]); - - useEffect(() => { - let unlisten: (() => void) | undefined; - let disposed = false; - - const setupAcpStateSync = async () => { - unlisten = await listen("acp-event", ({ payload }) => { - const store = useAIChatStore.getState(); - const { actions } = store; - - switch (payload.type) { - case "slash_commands_update": - actions.setAvailableSlashCommands(payload.commands); - break; - case "session_mode_update": - actions.setSessionModeState( - payload.modeState.currentModeId, - payload.modeState.availableModes, - ); - break; - case "current_mode_update": - actions.setCurrentModeId(payload.currentModeId); - break; - case "config_options_update": - actions.setSessionConfigOptions(payload.configOptions); - break; - case "session_info_update": { - const chat = - store.chats.find((item) => item.acpSessionId === payload.sessionId) ?? - (store.acpStatus?.sessionId === payload.sessionId ? actions.getCurrentChat() : null); - const nextTitle = chat ? getChatTitleFromSessionInfo(chat.title, payload.title) : null; - if (chat && nextTitle) { - actions.updateChatTitle(chat.id, nextTitle); - } - break; - } - case "status_changed": - actions.setAcpStatus(payload.status); - if (!payload.status.running) { - actions.setAvailableSlashCommands([]); - actions.setSessionModeState(null, []); - actions.setSessionConfigOptions([]); - } - break; - default: - break; - } - }); - }; - - setupAcpStateSync().catch((error) => { - if (!disposed) { - console.error("Failed to initialize ACP state sync listener:", error); - } - }); - - return () => { - disposed = true; - if (unlisten) { - unlisten(); - } - }; - }, []); - - const appendAcpEvent = useCallback((event: ChatAcpEventInput) => { - setAcpEvents((prev) => appendChatAcpEvent(prev, event)); - }, []); - - // Agent availability is handled dynamically by the agent selector. - - const handleDeleteChat = (chatId: string) => { - chatActions.deleteChat(chatId); - }; - - const updateInitialAgentSessionTitle = useCallback( - async (chatId: string, userMessage: string) => { - const fallbackTitle = getFallbackAgentSessionTitle(userMessage); - chatActions.updateChatTitle(chatId, fallbackTitle); - - const authState = useAuthStore.getState(); - const enterprisePolicy = authState.subscription?.enterprise?.policy; - const managedPolicy = enterprisePolicy?.managedMode ? enterprisePolicy : null; - const isPro = hasProductCapability(authState.subscription, "hostedAi"); - - if (!isPro || (managedPolicy && !managedPolicy.aiCompletionEnabled)) { - return; - } - - const model = useSettingsStore.getState().settings.aiAutocompleteModelId; - if (!model) return; - - try { - const { editedText } = await requestInlineEdit( - { - model, - beforeSelection: "", - selectedText: userMessage, - afterSelection: "", - instruction: - "Name the software feature or task being worked on. Return exactly one or two words, no punctuation, no quotes, no explanation. Prefer a concrete product feature label over a generic verb.", - filePath: "agent-session-title", - languageId: "text", - }, - { useByok: false }, - ); - - const generatedTitle = normalizeAgentSessionTitle(editedText); - if (!generatedTitle) return; - - const currentChat = useAIChatStore.getState().actions.getChatById(chatId); - if (!currentChat) return; - - if (currentChat.title === fallbackTitle || currentChat.title === "New Session") { - chatActions.updateChatTitle(chatId, generatedTitle); - } - } catch (error) { - console.debug("Failed to generate agent session title:", error); - } - }, - [chatActions], - ); - - const buildContext = async (agentId: string, providerId: string): Promise => { - const selectedBuffers = buffers.filter( - (buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id), - ); - const selectedActiveBuffer = - activeBuffer && activeBuffer.type !== "agent" && selectedBufferIds.has(activeBuffer.id) - ? activeBuffer - : undefined; - - let activeBufferContext: (typeof activeBuffer & { webViewerContent?: string }) | undefined = - selectedActiveBuffer; - if (selectedActiveBuffer?.type === "webViewer" && selectedActiveBuffer.url) { - const { fetchWebPageContent } = await import("@/features/ai/services/web-content-service"); - const webContent = await fetchWebPageContent(selectedActiveBuffer.url); - activeBufferContext = { - ...selectedActiveBuffer, - webViewerContent: webContent, - }; - } - - const context: ContextInfo = { - activeBuffer: activeBufferContext, - openBuffers: selectedBuffers, - selectedFiles, - selectedProjectFiles: Array.from(selectedFilesPaths), - projectRoot: rootFolderPath, - providerId, - agentId, - }; - - if (selectedActiveBuffer && selectedActiveBuffer.type !== "webViewer") { - const extension = selectedActiveBuffer.path.split(".").pop()?.toLowerCase() || ""; - const languageMap: Record = { - js: "JavaScript", - jsx: "JavaScript (React)", - ts: "TypeScript", - tsx: "TypeScript (React)", - py: "Python", - rs: "Rust", - go: "Go", - java: "Java", - cpp: "C++", - c: "C", - css: "CSS", - html: "HTML", - json: "JSON", - md: "Markdown", - sql: "SQL", - sh: "Shell Script", - yml: "YAML", - yaml: "YAML", - }; - - context.language = languageMap[extension] || "Text"; - } - - return context; - }; - - const stopStreaming = async () => { - const pendingPermissions = permissionQueue; - setPermissionQueue([]); - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - - if (currentAgentId === CODEX_INTEGRATION_ID) { - try { - await CodexIntegrationService.cancel(); - await Promise.all( - pendingPermissions.map((item) => CodexIntegrationService.respond(item.requestId, false)), - ); - } catch (error) { - console.error("Failed to cancel Codex turn:", error); - } - } else if (isAcpAgent(currentAgentId)) { - try { - await AcpStreamHandler.cancelPrompt(); - if (pendingPermissions.length > 0) { - await Promise.all( - pendingPermissions.map((item) => - AcpStreamHandler.respondToPermission(item.requestId, false, true), - ), - ); - } - } catch (error) { - console.error("Failed to cancel ACP prompt:", error); - } - } - }; - - const updateStreamingAssistantMessage = useCallback( - ( - chatId: string, - messageId: string, - mutate: (currentMessage: Message | undefined) => Partial, - ) => { - const currentMessages = useAIChatStore.getState().actions.getMessagesForChat(chatId); - const currentMessage = currentMessages.find((message) => message.id === messageId); - chatActions.updateMessage(chatId, messageId, mutate(currentMessage)); - }, - [chatActions.updateMessage], - ); - - const processMessage = async ( - messageContent: string, - options: { editedUserMessageId?: string } = {}, - ) => { - const store = useAIChatStore.getState(); - const targetChat = effectiveChatId - ? store.chats.find((chat) => chat.id === effectiveChatId) - : null; - const currentAgentId = targetChat?.agentId ?? store.actions.getCurrentAgentId(); - const isAcp = isAcpAgent(currentAgentId); - const trimmedMessageContent = messageContent.trim(); - // For ACP agents, we don't need an API key. - // For Custom API, we need an API key to be set - if (!trimmedMessageContent || (!isAcp && !store.hasApiKey)) return; - if (options.editedUserMessageId && currentAgentId !== "custom") return; - - // Agents are started automatically by AcpStreamHandler when needed - - let targetChatId = effectiveChatId ?? store.currentChatId; - if (!targetChatId) { - targetChatId = chatActions.createNewChat(currentAgentId); - } else { - targetChatId = chatActions.ensureChatSession(targetChatId, currentAgentId, { - activate: !chatId, - }); - } - - const existingMessages = useAIChatStore.getState().actions.getMessagesForChat(targetChatId); - const editedUserMessageIndex = options.editedUserMessageId - ? existingMessages.findIndex( - (message) => message.id === options.editedUserMessageId && message.role === "user", - ) - : -1; - if (options.editedUserMessageId && editedUserMessageIndex === -1) return; - - const conversationContext = buildConversationHistory( - editedUserMessageIndex >= 0 - ? existingMessages.slice(0, editedUserMessageIndex) - : existingMessages, - ); - const userMessage: Message = - editedUserMessageIndex >= 0 - ? { - ...existingMessages[editedUserMessageIndex], - content: trimmedMessageContent, - timestamp: new Date(), - } - : { - id: createMessageId(), - content: trimmedMessageContent, - role: "user", - timestamp: new Date(), - }; - - const assistantMessageId = createMessageId(); - const assistantMessage: Message = { - id: assistantMessageId, - content: "", - role: "assistant", - timestamp: new Date(), - isStreaming: true, - responsePhase: "waiting", - }; - - if (options.editedUserMessageId) { - const didReplace = chatActions.replaceUserMessage( - targetChatId, - options.editedUserMessageId, - trimmedMessageContent, - ); - if (!didReplace) return; - } else { - chatActions.addMessage(targetChatId, userMessage); - } - chatActions.addMessage(targetChatId, assistantMessage); - - const currentMessages = useAIChatStore.getState().actions.getMessagesForChat(targetChatId); - if (currentMessages.length === 2) { - void updateInitialAgentSessionTitle(targetChatId, userMessage.content); - } - - setIsSurfaceTyping(true); - setSurfaceStreamingMessageId(assistantMessageId); - - abortControllerRef.current = new AbortController(); - let currentAssistantMessageId = assistantMessageId; - let currentAssistantRawContent = ""; - let acpProducedStateOnlyUpdate = false; - let acpCommandResultLabel: string | null = null; - - try { - const { mentionedFiles } = await parseMentionsAndLoadFiles( - trimmedMessageContent, - allProjectFiles, - ); - const mentionedPaths = new Set(mentionedFiles.map((file) => file.path)); - const attachedFiles = isAcp - ? [] - : await loadFilesByPaths( - Array.from(selectedFilesPaths).filter((path) => !mentionedPaths.has(path)), - ); - const latestSettings = useSettingsStore.getState().settings; - const context = await buildContext(currentAgentId, latestSettings.aiProviderId); - context.mentionedFiles = [...mentionedFiles, ...attachedFiles]; - - // Handle direct ACP UI intents locally so they are always reliable. - if (isAcp) { - const directAction = parseDirectAcpUiAction(trimmedMessageContent); - if (directAction) { - const bufferActions = useBufferStore.getState().actions; - if (directAction.kind === "open_web_viewer" && directAction.url) { - if (!useSettingsStore.getState().settings.coreFeatures.webViewer) { - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - content: "Web Viewer is disabled. Enable it in Settings > Features to open URLs.", - isStreaming: false, - }); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - return; - } - - bufferActions.openWebViewerBuffer(directAction.url); - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - content: `Opened ${directAction.url} in Athas web viewer.`, - isStreaming: false, - }); - } else if (directAction.kind === "open_terminal" && directAction.command) { - bufferActions.openTerminalBuffer({ - command: directAction.command, - name: directAction.command, - }); - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - content: `Opened terminal and ran \`${directAction.command}\`.`, - isStreaming: false, - }); - } - - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - return; - } - } - - const enhancedMessage = isAcp - ? trimmedMessageContent - : appendReferencedFiles(trimmedMessageContent, [...mentionedFiles, ...attachedFiles]); - if (isAcp) { - setAcpEvents([]); - } - - await getChatCompletionStream( - currentAgentId, - latestSettings.aiProviderId, - latestSettings.aiModelId, - enhancedMessage, - context, - (chunk: string) => { - currentAssistantRawContent += chunk; - const extracted = extractFollowUpActions(currentAssistantRawContent); - updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ - content: extracted.content, - followUpActions: extracted.actions, - responsePhase: undefined, - })); - }, - () => { - const currentMessage = chatActions - .getMessagesForChat(targetChatId) - .find((message) => message.id === currentAssistantMessageId); - const hasVisibleResponse = Boolean( - currentMessage?.content?.trim() || - currentMessage?.toolCalls?.length || - currentMessage?.images?.length || - currentMessage?.resources?.length, - ); - - if (!hasVisibleResponse) { - if (isAcpAgent(currentAgentId) && acpProducedStateOnlyUpdate) { - const slashCommand = trimmedMessageContent.match(/^\/([^\s]+)/)?.[1]; - const fallbackContent = - acpCommandResultLabel || - (slashCommand ? `Applied \`/${slashCommand}\`.` : "Session updated."); - - updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ - content: fallbackContent, - isStreaming: false, - })); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - return; - } - - const isAcp = isAcpAgent(currentAgentId); - const fallbackMessage = isAcp - ? "The selected agent did not return a visible response. Try sending the message again." - : "The selected provider did not return a visible response. Try another model or send the message again."; - const emptyResponseSource = isAcp ? "agent session" : "provider request"; - updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ - content: `[ERROR_BLOCK] + const rootFolderPath = useProjectStore((state) => state.rootFolderPath); + const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); + const subscription = useAuthStore((state) => state.subscription); + const enterprisePolicy = subscription?.enterprise?.policy; + const isAiChatBlockedByPolicy = Boolean( + enterprisePolicy?.managedMode && !enterprisePolicy.aiChatEnabled, + ); + + const chatState = useChatState(); + const chatActions = useChatActions(); + const { showToast } = useToast(); + + const abortControllerRef = useRef(null); + const [permissionQueue, setPermissionQueue] = useState< + AcpPermissionRequest[] + >([]); + const [acpEvents, setAcpEvents] = useState([]); + const [isMessageSearchOpen, setIsMessageSearchOpen] = useState(false); + const [messageSearchQuery, setMessageSearchQuery] = useState(""); + const [activeMessageSearchIndex, setActiveMessageSearchIndex] = useState(0); + const [selectedBufferIds, setSelectedBufferIds] = useState>( + new Set(), + ); + const [selectedFilesPaths, setSelectedFilesPaths] = useState>( + new Set(), + ); + const [isSurfaceTyping, setIsSurfaceTyping] = useState(false); + const [surfaceStreamingMessageId, setSurfaceStreamingMessageId] = useState< + string | null + >(null); + const [queueCount, setQueueCount] = useState(0); + const messageQueueRef = useRef([]); + const effectiveChatId = chatId ?? chatState.currentChatId; + const currentChat = useMemo( + () => chatState.chats.find((chat) => chat.id === effectiveChatId), + [chatState.chats, effectiveChatId], + ); + const currentAgentId = + currentChat?.agentId ?? useAIChatStore.getState().selectedAgentId; + const messageSearchMatches = useMemo( + () => + getMessageSearchMatches(currentChat?.messages ?? [], messageSearchQuery), + [currentChat?.messages, messageSearchQuery], + ); + const activeMessageSearchMatch = + messageSearchMatches[activeMessageSearchIndex] ?? null; + + const closeMessageSearch = useCallback(() => { + setIsMessageSearchOpen(false); + setMessageSearchQuery(""); + setActiveMessageSearchIndex(0); + }, []); + + const goToPreviousMessageSearchMatch = useCallback(() => { + if (messageSearchMatches.length === 0) return; + setActiveMessageSearchIndex((index) => + index === 0 ? messageSearchMatches.length - 1 : index - 1, + ); + }, [messageSearchMatches.length]); + + const goToNextMessageSearchMatch = useCallback(() => { + if (messageSearchMatches.length === 0) return; + setActiveMessageSearchIndex( + (index) => (index + 1) % messageSearchMatches.length, + ); + }, [messageSearchMatches.length]); + + useEffect(() => { + chatActions.checkApiKey(aiProviderId); + chatActions.checkAllProviderApiKeys(); + }, [ + aiProviderId, + chatActions.checkApiKey, + chatActions.checkAllProviderApiKeys, + ]); + + // Clear ACP events when switching chats + useEffect(() => { + setAcpEvents([]); + closeMessageSearch(); + setSelectedBufferIds(new Set()); + setSelectedFilesPaths(new Set()); + }, [closeMessageSearch, effectiveChatId]); + + useEffect(() => { + setActiveMessageSearchIndex(0); + }, [messageSearchQuery]); + + useEffect(() => { + if (messageSearchMatches.length === 0) { + setActiveMessageSearchIndex(0); + return; + } + + setActiveMessageSearchIndex((index) => + Math.min(index, messageSearchMatches.length - 1), + ); + }, [messageSearchMatches.length]); + + useEffect(() => { + if (!isActiveSurface || isAiChatBlockedByPolicy) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + setIsMessageSearchOpen(true); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isActiveSurface, isAiChatBlockedByPolicy]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let disposed = false; + + const setupAcpStateSync = async () => { + unlisten = await listen("acp-event", ({ payload }) => { + const store = useAIChatStore.getState(); + const { actions } = store; + + switch (payload.type) { + case "slash_commands_update": + actions.setAvailableSlashCommands(payload.commands); + break; + case "session_mode_update": + actions.setSessionModeState( + payload.modeState.currentModeId, + payload.modeState.availableModes, + ); + break; + case "current_mode_update": + actions.setCurrentModeId(payload.currentModeId); + break; + case "config_options_update": + actions.setSessionConfigOptions(payload.configOptions); + break; + case "session_info_update": { + const chat = + store.chats.find( + (item) => item.acpSessionId === payload.sessionId, + ) ?? + (store.acpStatus?.sessionId === payload.sessionId + ? actions.getCurrentChat() + : null); + const nextTitle = chat + ? getChatTitleFromSessionInfo(chat.title, payload.title) + : null; + if (chat && nextTitle) { + actions.updateChatTitle(chat.id, nextTitle); + } + break; + } + case "status_changed": + actions.setAcpStatus(payload.status); + if (!payload.status.running) { + actions.setAvailableSlashCommands([]); + actions.setSessionModeState(null, []); + actions.setSessionConfigOptions([]); + } + break; + default: + break; + } + }); + }; + + setupAcpStateSync().catch((error) => { + if (!disposed) { + console.error("Failed to initialize ACP state sync listener:", error); + } + }); + + return () => { + disposed = true; + if (unlisten) { + unlisten(); + } + }; + }, []); + + const appendAcpEvent = useCallback((event: ChatAcpEventInput) => { + setAcpEvents((prev) => appendChatAcpEvent(prev, event)); + }, []); + + // Agent availability is handled dynamically by the agent selector. + + const handleDeleteChat = (chatId: string) => { + chatActions.deleteChat(chatId); + }; + + const updateInitialAgentSessionTitle = useCallback( + async (chatId: string, userMessage: string) => { + const fallbackTitle = getFallbackAgentSessionTitle(userMessage); + chatActions.updateChatTitle(chatId, fallbackTitle); + + const authState = useAuthStore.getState(); + const enterprisePolicy = authState.subscription?.enterprise?.policy; + const managedPolicy = enterprisePolicy?.managedMode + ? enterprisePolicy + : null; + const isPro = hasProductCapability(authState.subscription, "hostedAi"); + + if (!isPro || (managedPolicy && !managedPolicy.aiCompletionEnabled)) { + return; + } + + const model = useSettingsStore.getState().settings.aiAutocompleteModelId; + if (!model) return; + + try { + const { editedText } = await requestInlineEdit( + { + model, + beforeSelection: "", + selectedText: userMessage, + afterSelection: "", + instruction: + "Name the software feature or task being worked on. Return exactly one or two words, no punctuation, no quotes, no explanation. Prefer a concrete product feature label over a generic verb.", + filePath: "agent-session-title", + languageId: "text", + }, + { useByok: false }, + ); + + const generatedTitle = normalizeAgentSessionTitle(editedText); + if (!generatedTitle) return; + + const currentChat = useAIChatStore + .getState() + .actions.getChatById(chatId); + if (!currentChat) return; + + if ( + currentChat.title === fallbackTitle || + currentChat.title === "New Session" + ) { + chatActions.updateChatTitle(chatId, generatedTitle); + } + } catch (error) { + console.debug("Failed to generate agent session title:", error); + } + }, + [chatActions], + ); + + const buildContext = async ( + agentId: string, + providerId: string, + ): Promise => { + const selectedBuffers = buffers.filter( + (buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id), + ); + const selectedActiveBuffer = + activeBuffer && + activeBuffer.type !== "agent" && + selectedBufferIds.has(activeBuffer.id) + ? activeBuffer + : undefined; + + let activeBufferContext: + | (typeof activeBuffer & { webViewerContent?: string }) + | undefined = selectedActiveBuffer; + if ( + selectedActiveBuffer?.type === "webViewer" && + selectedActiveBuffer.url + ) { + const { fetchWebPageContent } = await import( + "@/features/ai/services/web-content-service" + ); + const webContent = await fetchWebPageContent(selectedActiveBuffer.url); + activeBufferContext = { + ...selectedActiveBuffer, + webViewerContent: webContent, + }; + } + + const context: ContextInfo = { + activeBuffer: activeBufferContext, + openBuffers: selectedBuffers, + selectedFiles, + selectedProjectFiles: Array.from(selectedFilesPaths), + projectRoot: rootFolderPath, + providerId, + agentId, + }; + + if (selectedActiveBuffer && selectedActiveBuffer.type !== "webViewer") { + const extension = + selectedActiveBuffer.path.split(".").pop()?.toLowerCase() || ""; + const languageMap: Record = { + js: "JavaScript", + jsx: "JavaScript (React)", + ts: "TypeScript", + tsx: "TypeScript (React)", + py: "Python", + rs: "Rust", + go: "Go", + java: "Java", + cpp: "C++", + c: "C", + css: "CSS", + html: "HTML", + json: "JSON", + md: "Markdown", + sql: "SQL", + sh: "Shell Script", + yml: "YAML", + yaml: "YAML", + }; + + context.language = languageMap[extension] || "Text"; + } + + return context; + }; + + const stopStreaming = async () => { + const pendingPermissions = permissionQueue; + setPermissionQueue([]); + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + + if (currentAgentId === CODEX_INTEGRATION_ID) { + try { + await CodexIntegrationService.cancel(); + await Promise.all( + pendingPermissions.map((item) => + CodexIntegrationService.respond(item.requestId, false), + ), + ); + } catch (error) { + console.error("Failed to cancel Codex turn:", error); + } + } else if (isAcpAgent(currentAgentId)) { + try { + await AcpStreamHandler.cancelPrompt(); + if (pendingPermissions.length > 0) { + await Promise.all( + pendingPermissions.map((item) => + AcpStreamHandler.respondToPermission(item.requestId, false, true), + ), + ); + } + } catch (error) { + console.error("Failed to cancel ACP prompt:", error); + } + } + }; + + const updateStreamingAssistantMessage = useCallback( + ( + chatId: string, + messageId: string, + mutate: (currentMessage: Message | undefined) => Partial, + ) => { + const currentMessages = useAIChatStore + .getState() + .actions.getMessagesForChat(chatId); + const currentMessage = currentMessages.find( + (message) => message.id === messageId, + ); + chatActions.updateMessage(chatId, messageId, mutate(currentMessage)); + }, + [chatActions.updateMessage], + ); + + const processMessage = async ( + messageContent: string, + images: ImageContent[] = [], + options: { editedUserMessageId?: string } = {}, + ) => { + const store = useAIChatStore.getState(); + const targetChat = effectiveChatId + ? store.chats.find((chat) => chat.id === effectiveChatId) + : null; + const currentAgentId = + targetChat?.agentId ?? store.actions.getCurrentAgentId(); + const isAcp = isAcpAgent(currentAgentId); + const trimmedMessageContent = messageContent.trim(); + // For ACP agents, we don't need an API key. + // For Custom API, we need an API key to be set + if (!trimmedMessageContent || (!isAcp && !store.hasApiKey)) return; + if (options.editedUserMessageId && currentAgentId !== "custom") return; + + // Agents are started automatically by AcpStreamHandler when needed + + let targetChatId = effectiveChatId ?? store.currentChatId; + if (!targetChatId) { + targetChatId = chatActions.createNewChat(currentAgentId); + } else { + targetChatId = chatActions.ensureChatSession( + targetChatId, + currentAgentId, + { + activate: !chatId, + }, + ); + } + + const existingMessages = useAIChatStore + .getState() + .actions.getMessagesForChat(targetChatId); + const editedUserMessageIndex = options.editedUserMessageId + ? existingMessages.findIndex( + (message) => + message.id === options.editedUserMessageId && + message.role === "user", + ) + : -1; + if (options.editedUserMessageId && editedUserMessageIndex === -1) return; + + const conversationContext = buildConversationHistory( + editedUserMessageIndex >= 0 + ? existingMessages.slice(0, editedUserMessageIndex) + : existingMessages, + ); + const userMessage: Message = + editedUserMessageIndex >= 0 + ? { + ...existingMessages[editedUserMessageIndex], + content: trimmedMessageContent, + timestamp: new Date(), + images: + images.length > 0 + ? images + : existingMessages[editedUserMessageIndex].images, + } + : { + id: createMessageId(), + content: trimmedMessageContent, + role: "user", + timestamp: new Date(), + images: images.length > 0 ? images : undefined, + }; + + const assistantMessageId = createMessageId(); + const assistantMessage: Message = { + id: assistantMessageId, + content: "", + role: "assistant", + timestamp: new Date(), + isStreaming: true, + responsePhase: "waiting", + }; + + if (options.editedUserMessageId) { + const didReplace = chatActions.replaceUserMessage( + targetChatId, + options.editedUserMessageId, + trimmedMessageContent, + ); + if (!didReplace) return; + } else { + chatActions.addMessage(targetChatId, userMessage); + } + chatActions.addMessage(targetChatId, assistantMessage); + + const currentMessages = useAIChatStore + .getState() + .actions.getMessagesForChat(targetChatId); + if (currentMessages.length === 2) { + void updateInitialAgentSessionTitle(targetChatId, userMessage.content); + } + + setIsSurfaceTyping(true); + setSurfaceStreamingMessageId(assistantMessageId); + + abortControllerRef.current = new AbortController(); + let currentAssistantMessageId = assistantMessageId; + let currentAssistantRawContent = ""; + let acpProducedStateOnlyUpdate = false; + let acpCommandResultLabel: string | null = null; + + try { + const { mentionedFiles } = await parseMentionsAndLoadFiles( + trimmedMessageContent, + allProjectFiles, + ); + const mentionedPaths = new Set(mentionedFiles.map((file) => file.path)); + const attachedFiles = isAcp + ? [] + : await loadFilesByPaths( + Array.from(selectedFilesPaths).filter( + (path) => !mentionedPaths.has(path), + ), + ); + const latestSettings = useSettingsStore.getState().settings; + const context = await buildContext( + currentAgentId, + latestSettings.aiProviderId, + ); + context.mentionedFiles = [...mentionedFiles, ...attachedFiles]; + + // Handle direct ACP UI intents locally so they are always reliable. + if (isAcp) { + const directAction = parseDirectAcpUiAction(trimmedMessageContent); + if (directAction) { + const bufferActions = useBufferStore.getState().actions; + if (directAction.kind === "open_web_viewer" && directAction.url) { + if (!useSettingsStore.getState().settings.coreFeatures.webViewer) { + chatActions.updateMessage( + targetChatId, + currentAssistantMessageId, + { + content: + "Web Viewer is disabled. Enable it in Settings > Features to open URLs.", + isStreaming: false, + }, + ); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + return; + } + + bufferActions.openWebViewerBuffer(directAction.url); + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + content: `Opened ${directAction.url} in Athas web viewer.`, + isStreaming: false, + }); + } else if ( + directAction.kind === "open_terminal" && + directAction.command + ) { + bufferActions.openTerminalBuffer({ + command: directAction.command, + name: directAction.command, + }); + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + content: `Opened terminal and ran \`${directAction.command}\`.`, + isStreaming: false, + }); + } + + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + return; + } + } + + const enhancedMessage = isAcp + ? trimmedMessageContent + : appendReferencedFiles(trimmedMessageContent, [ + ...mentionedFiles, + ...attachedFiles, + ]); + if (isAcp) { + setAcpEvents([]); + } + + await getChatCompletionStream( + currentAgentId, + latestSettings.aiProviderId, + latestSettings.aiModelId, + enhancedMessage, + context, + (chunk: string) => { + currentAssistantRawContent += chunk; + const extracted = extractFollowUpActions(currentAssistantRawContent); + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + () => ({ + content: extracted.content, + followUpActions: extracted.actions, + responsePhase: undefined, + }), + ); + }, + () => { + const currentMessage = chatActions + .getMessagesForChat(targetChatId) + .find((message) => message.id === currentAssistantMessageId); + const hasVisibleResponse = Boolean( + currentMessage?.content?.trim() || + currentMessage?.toolCalls?.length || + currentMessage?.images?.length || + currentMessage?.resources?.length, + ); + + if (!hasVisibleResponse) { + if (isAcpAgent(currentAgentId) && acpProducedStateOnlyUpdate) { + const slashCommand = + trimmedMessageContent.match(/^\/([^\s]+)/)?.[1]; + const fallbackContent = + acpCommandResultLabel || + (slashCommand + ? `Applied \`/${slashCommand}\`.` + : "Session updated."); + + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + () => ({ + content: fallbackContent, + isStreaming: false, + }), + ); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + return; + } + + const isAcp = isAcpAgent(currentAgentId); + const fallbackMessage = isAcp + ? "The selected agent did not return a visible response. Try sending the message again." + : "The selected provider did not return a visible response. Try another model or send the message again."; + const emptyResponseSource = isAcp + ? "agent session" + : "provider request"; + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + () => ({ + content: `[ERROR_BLOCK] title: No Response code: EMPTY_RESPONSE message: ${fallbackMessage} details: The ${emptyResponseSource} completed, but no content, tool output, or resource was returned. [/ERROR_BLOCK]`, - isStreaming: false, - })); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - return; - } - - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - isStreaming: false, - }); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - }, - (error: string, canReconnect?: boolean) => { - console.error("Streaming error:", error); - - let errorTitle = "API Error"; - let errorMessage = error; - let errorCode = ""; - let errorDetails = ""; - - const parts = error.split("|||"); - const mainError = parts[0]; - if (parts.length > 1) { - errorDetails = parts[1]; - } - - const codeMatch = mainError.match(/error:\s*(\d+)/i); - if (codeMatch) { - errorCode = codeMatch[1]; - if (errorCode === "429") { - errorTitle = "Rate Limit Exceeded"; - errorMessage = - "The API is temporarily rate-limited. Please wait a moment and try again."; - } else if (errorCode === "401") { - errorTitle = "Authentication Error"; - errorMessage = "Invalid API key. Please check your API settings."; - } else if (errorCode === "403") { - errorTitle = "Access Denied"; - errorMessage = "You don't have permission to access this resource."; - } else if (errorCode === "500") { - errorTitle = "Server Error"; - errorMessage = "The API server encountered an error. Please try again later."; - } else if (errorCode === "400") { - errorTitle = "Bad Request"; - if (errorDetails) { - try { - const parsed = JSON.parse(errorDetails); - if (parsed.error?.message) { - errorMessage = parsed.error.message; - } - } catch { - errorMessage = mainError; - } - } - } - } - - const isAcpConfigError = - isAcpAgent(currentAgentId) && isAcpConfigurationError(mainError, errorDetails); - const isAcpAuthError = - !isAcpConfigError && - isAcpAgent(currentAgentId) && - isAcpAuthenticationError(mainError, errorDetails); - - if (isAcpConfigError) { - errorTitle = "Agent Configuration Required"; - errorCode = "CONFIG_REQUIRED"; - errorMessage = - "The selected agent is authenticated, but its account configuration is incomplete."; - } else if (isAcpAuthError) { - errorTitle = "Authentication Required"; - errorCode = "AUTH_REQUIRED"; - errorMessage = - "The selected agent needs external authentication before it can accept prompts."; - - if ( - mainError.includes("Method not implemented") || - errorDetails.includes("Method not implemented") - ) { - errorDetails = - "This ACP adapter does not implement the protocol authenticate flow. Complete login in the underlying CLI/adapter, then try again."; - } else if (!errorDetails) { - errorDetails = - "Complete authentication in the underlying CLI/adapter, then try again."; - } - } - - if (canReconnect) { - errorTitle = "Connection Lost"; - errorCode = "RECONNECT"; - } - - const shouldSuppressToast = - isAcpAgent(currentAgentId) && - (mainError.includes("did not return any response") || errorCode === "RECONNECT"); - - const formattedError = `[ERROR_BLOCK] + isStreaming: false, + }), + ); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + return; + } + + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + isStreaming: false, + }); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + }, + (error: string, canReconnect?: boolean) => { + console.error("Streaming error:", error); + + let errorTitle = "API Error"; + let errorMessage = error; + let errorCode = ""; + let errorDetails = ""; + + const parts = error.split("|||"); + const mainError = parts[0]; + if (parts.length > 1) { + errorDetails = parts[1]; + } + + const codeMatch = mainError.match(/error:\s*(\d+)/i); + if (codeMatch) { + errorCode = codeMatch[1]; + if (errorCode === "429") { + errorTitle = "Rate Limit Exceeded"; + errorMessage = + "The API is temporarily rate-limited. Please wait a moment and try again."; + } else if (errorCode === "401") { + errorTitle = "Authentication Error"; + errorMessage = "Invalid API key. Please check your API settings."; + } else if (errorCode === "403") { + errorTitle = "Access Denied"; + errorMessage = + "You don't have permission to access this resource."; + } else if (errorCode === "500") { + errorTitle = "Server Error"; + errorMessage = + "The API server encountered an error. Please try again later."; + } else if (errorCode === "400") { + errorTitle = "Bad Request"; + if (errorDetails) { + try { + const parsed = JSON.parse(errorDetails); + if (parsed.error?.message) { + errorMessage = parsed.error.message; + } + } catch { + errorMessage = mainError; + } + } + } + } + + const isAcpConfigError = + isAcpAgent(currentAgentId) && + isAcpConfigurationError(mainError, errorDetails); + const isAcpAuthError = + !isAcpConfigError && + isAcpAgent(currentAgentId) && + isAcpAuthenticationError(mainError, errorDetails); + + if (isAcpConfigError) { + errorTitle = "Agent Configuration Required"; + errorCode = "CONFIG_REQUIRED"; + errorMessage = + "The selected agent is authenticated, but its account configuration is incomplete."; + } else if (isAcpAuthError) { + errorTitle = "Authentication Required"; + errorCode = "AUTH_REQUIRED"; + errorMessage = + "The selected agent needs external authentication before it can accept prompts."; + + if ( + mainError.includes("Method not implemented") || + errorDetails.includes("Method not implemented") + ) { + errorDetails = + "This ACP adapter does not implement the protocol authenticate flow. Complete login in the underlying CLI/adapter, then try again."; + } else if (!errorDetails) { + errorDetails = + "Complete authentication in the underlying CLI/adapter, then try again."; + } + } + + if (canReconnect) { + errorTitle = "Connection Lost"; + errorCode = "RECONNECT"; + } + + const shouldSuppressToast = + isAcpAgent(currentAgentId) && + (mainError.includes("did not return any response") || + errorCode === "RECONNECT"); + + const formattedError = `[ERROR_BLOCK] title: ${errorTitle} code: ${errorCode} message: ${errorMessage} details: ${errorDetails || mainError} [/ERROR_BLOCK]`; - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - content: currentMessage?.content || formattedError, - isStreaming: false, - }), - ); - if (!shouldSuppressToast) { - showToast({ - message: errorMessage, - type: "error", - }); - } - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - }, - conversationContext, - () => { - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - isStreaming: false, - responsePhase: undefined, - }); - - const newMessageId = createMessageId(); - currentAssistantRawContent = ""; - const newAssistantMessage: Message = { - id: newMessageId, - content: "", - role: "assistant", - timestamp: new Date(), - isStreaming: true, - responsePhase: "waiting", - }; - - chatActions.addMessage(targetChatId, newAssistantMessage); - currentAssistantMessageId = newMessageId; - setSurfaceStreamingMessageId(newMessageId); - }, - (event) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - isToolUse: true, - toolName: event.toolName, - toolCalls: [ - ...(currentMessage?.toolCalls || []), - createToolCall( - event.toolName, - event.input, - event.toolId, - event.kind, - event.status, - event.locations, - ), - ], - }), - ); - }, - (event) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - toolCalls: updateToolCall(currentMessage?.toolCalls || [], { - id: event.toolId, - name: event.toolName, - input: event.input, - output: event.output, - error: event.error, - kind: event.kind, - status: event.status, - locations: event.locations, - }), - }), - ); - }, - (toolName: string, toolId?: string, output?: unknown, error?: string) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - toolCalls: markToolCallComplete( - currentMessage?.toolCalls || [], - toolName, - toolId, - output, - error, - ), - }), - ); - }, - (event) => { - appendAcpEvent({ - id: `permission-request-${event.requestId}`, - category: "permission", - label: "Permission requested", - detail: event.description || `${event.permissionType} ${event.resource}`.trim(), - state: "info", - }); - setPermissionQueue((prev) => [ - ...prev, - { - requestId: event.requestId, - description: event.description, - permissionType: event.permissionType, - resource: event.resource, - options: event.options, - }, - ]); - }, - (event) => { - if (!isAcpAgent(currentAgentId) && currentAgentId !== CODEX_INTEGRATION_ID) return; - // Only show meaningful events, skip noisy ones - if ( - event.type === "content_chunk" || - event.type === "user_message_chunk" || - event.type === "session_complete" - ) { - return; - } - switch (event.type) { - case "thought_chunk": - updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ - responsePhase: "thinking", - })); - break; - case "tool_start": - case "tool_update": - break; - case "tool_complete": - break; - case "permission_request": - break; // Handled separately with permission UI - case "prompt_complete": - break; // Not useful to show - case "session_mode_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = event.modeState.currentModeId - ? `Mode set to \`${event.modeState.currentModeId}\`.` - : "Session mode updated."; - break; - case "config_options_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = - event.configOptions.length === 1 - ? "Session option updated." - : "Session options updated."; - break; - case "session_info_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = event.title - ? `Session title updated to "${event.title}".` - : "Session metadata updated."; - if (event.title) { - appendAcpEvent({ - category: "status", - label: "Session title updated", - detail: event.title, - state: "info", - }); - } - break; - case "current_mode_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = `Mode set to \`${event.currentModeId}\`.`; - break; - case "slash_commands_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = "Slash commands refreshed."; - break; // Not useful to show - case "plan_update": { - const summary = - event.entries.length > 0 - ? event.entries.map((entry) => entry.content).join(" | ") - : "No plan steps"; - appendAcpEvent({ - category: "plan", - label: `Plan updated (${event.entries.length} steps)`, - detail: summary, - state: "info", - }); - break; - } - case "usage_update": { - break; - } - case "status_changed": - useAIChatStore.getState().actions.setAcpStatus(event.status); - break; // internal state sync - case "error": - appendAcpEvent({ - category: "error", - label: "Agent error", - detail: event.error, - state: "error", - }); - break; - case "ui_action": - break; // Handled by acp-handler - } - }, - chatState.mode, - chatState.outputStyle, - (data: string, mediaType: string) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - images: [...(currentMessage?.images || []), { data, mediaType }], - }), - ); - }, - (uri: string, name: string | null) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - resources: [...(currentMessage?.resources || []), { uri, name }], - }), - ); - }, - targetChatId, - ); - } catch (error) { - console.error("Failed to start streaming:", error); - chatActions.updateMessage(targetChatId, assistantMessageId, { - content: - "Error: Failed to connect to Agent service. Please check your API key and try again.", - isStreaming: false, - }); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - } - }; - - const processQueuedMessages = useCallback(async () => { - if (isSurfaceTyping || surfaceStreamingMessageId) { - return; - } - - const nextMessage = messageQueueRef.current.shift(); - setQueueCount(messageQueueRef.current.length); - if (nextMessage) { - console.log("Processing next queued message:", nextMessage); - await new Promise((resolve) => setTimeout(resolve, 500)); - await processMessage(nextMessage); - } - }, [isSurfaceTyping, surfaceStreamingMessageId]); - - const sendMessage = useCallback( - async (messageContent: string) => { - const isAcp = isAcpAgent(currentAgentId); - // For ACP agents, we don't need an API key. - if (!messageContent.trim() || (!isAcp && !chatState.hasApiKey)) return; - - if (isSurfaceTyping || surfaceStreamingMessageId) { - messageQueueRef.current.push(messageContent); - setQueueCount(messageQueueRef.current.length); - return; - } - - await processMessage(messageContent); - }, - [chatState.hasApiKey, currentAgentId, isSurfaceTyping, surfaceStreamingMessageId], - ); - - const handleSendMessage = useCallback( - async (messageContent: string) => { - await sendMessage(messageContent); - }, - [sendMessage], - ); - - const handleEditUserMessage = async (messageId: string, content: string) => { - if (isSurfaceTyping || surfaceStreamingMessageId || currentChat?.agentId !== "custom") { - return; - } - - await processMessage(content, { editedUserMessageId: messageId }); - }; - - useEffect(() => { - const pendingLaunch = chatState.pendingAgentLaunchRequest; - if (!pendingLaunch) return; - if (pendingLaunch.chatId !== effectiveChatId) return; - if (activeBuffer?.type !== "agent") return; - if (activeBuffer.sessionId !== pendingLaunch.chatId) return; - if (isSurfaceTyping || surfaceStreamingMessageId) return; - if (!isAcpAgent(pendingLaunch.agentId) && !chatState.hasApiKey) return; - - setSelectedBufferIds(new Set(pendingLaunch.selectedBufferIds)); - setSelectedFilesPaths(new Set(pendingLaunch.selectedFilesPaths)); - chatActions.setPendingAgentLaunchRequest(null); - void sendMessage(pendingLaunch.prompt); - }, [ - chatActions, - effectiveChatId, - chatState.hasApiKey, - isSurfaceTyping, - chatState.pendingAgentLaunchRequest, - surfaceStreamingMessageId, - activeBuffer, - sendMessage, - ]); - - const currentPermission = permissionQueue[0]; - const isNewSession = (currentChat?.messages.length ?? 0) === 0 && acpEvents.length === 0; - const useInitialComposer = isNewSession && !currentPermission; - const handlePermission = async (approved: boolean, optionId?: string) => { - if (!currentPermission) return; - try { - const option = currentPermission.options.find((item) => item.id === optionId); - appendAcpEvent({ - id: `permission-response-${currentPermission.requestId}`, - category: "permission", - label: "Permission response", - detail: option?.name || (approved ? "allow" : "deny"), - state: approved ? "success" : "info", - }); - if (currentAgentId === CODEX_INTEGRATION_ID) { - await CodexIntegrationService.respond(currentPermission.requestId, approved); - } else { - await AcpStreamHandler.respondToPermission( - currentPermission.requestId, - approved, - false, - optionId, - ); - } - } finally { - setPermissionQueue((prev) => prev.slice(1)); - } - }; - - return ( -
- { - if (isMessageSearchOpen) { - closeMessageSearch(); - return; - } - - setIsMessageSearchOpen(true); - }} - onCloseMessageSearch={closeMessageSearch} - onMessageSearchQueryChange={setMessageSearchQuery} - messageSearchMatchCount={messageSearchMatches.length} - activeMessageSearchIndex={activeMessageSearchIndex} - onPreviousMessageSearchMatch={goToPreviousMessageSearchMatch} - onNextMessageSearchMatch={goToNextMessageSearchMatch} - /> - {isAiChatBlockedByPolicy ? ( - - - Agent is disabled - - Your organization policy has disabled Agent for this workspace. - - - - ) : ( - <> - {useInitialComposer ? ( - - - setSelectedBufferIds((current) => { - const next = new Set(current); - if (next.has(bufferId)) next.delete(bufferId); - else next.add(bufferId); - return next; - }) - } - onToggleFileSelection={(filePath) => - setSelectedFilesPaths((current) => { - const next = new Set(current); - if (next.has(filePath)) next.delete(filePath); - else next.add(filePath); - return next; - }) - } - onSetSelectedBufferIds={setSelectedBufferIds} - onSetSelectedFilesPaths={setSelectedFilesPaths} - isActiveSurface={isActiveSurface} - presentation="initial" - onSendMessage={handleSendMessage} - onStopStreaming={stopStreaming} - /> - - ) : ( - - - - - - - - - )} - - {currentPermission ? ( - - ) : null} - - {!useInitialComposer ? ( - - setSelectedBufferIds((current) => { - const next = new Set(current); - if (next.has(bufferId)) next.delete(bufferId); - else next.add(bufferId); - return next; - }) - } - onToggleFileSelection={(filePath) => - setSelectedFilesPaths((current) => { - const next = new Set(current); - if (next.has(filePath)) next.delete(filePath); - else next.add(filePath); - return next; - }) - } - onSetSelectedBufferIds={setSelectedBufferIds} - onSetSelectedFilesPaths={setSelectedFilesPaths} - isActiveSurface={isActiveSurface} - onSendMessage={handleSendMessage} - onStopStreaming={stopStreaming} - /> - ) : null} - - )} -
- ); + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + content: currentMessage?.content || formattedError, + isStreaming: false, + }), + ); + if (!shouldSuppressToast) { + showToast({ + message: errorMessage, + type: "error", + }); + } + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + }, + conversationContext, + () => { + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + isStreaming: false, + responsePhase: undefined, + }); + + const newMessageId = createMessageId(); + currentAssistantRawContent = ""; + const newAssistantMessage: Message = { + id: newMessageId, + content: "", + role: "assistant", + timestamp: new Date(), + isStreaming: true, + responsePhase: "waiting", + }; + + chatActions.addMessage(targetChatId, newAssistantMessage); + currentAssistantMessageId = newMessageId; + setSurfaceStreamingMessageId(newMessageId); + }, + (event) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + isToolUse: true, + toolName: event.toolName, + toolCalls: [ + ...(currentMessage?.toolCalls || []), + createToolCall( + event.toolName, + event.input, + event.toolId, + event.kind, + event.status, + event.locations, + ), + ], + }), + ); + }, + (event) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + toolCalls: updateToolCall(currentMessage?.toolCalls || [], { + id: event.toolId, + name: event.toolName, + input: event.input, + output: event.output, + error: event.error, + kind: event.kind, + status: event.status, + locations: event.locations, + }), + }), + ); + }, + ( + toolName: string, + toolId?: string, + output?: unknown, + error?: string, + ) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + toolCalls: markToolCallComplete( + currentMessage?.toolCalls || [], + toolName, + toolId, + output, + error, + ), + }), + ); + }, + (event) => { + appendAcpEvent({ + id: `permission-request-${event.requestId}`, + category: "permission", + label: "Permission requested", + detail: + event.description || + `${event.permissionType} ${event.resource}`.trim(), + state: "info", + }); + setPermissionQueue((prev) => [ + ...prev, + { + requestId: event.requestId, + description: event.description, + permissionType: event.permissionType, + resource: event.resource, + options: event.options, + }, + ]); + }, + (event) => { + if ( + !isAcpAgent(currentAgentId) && + currentAgentId !== CODEX_INTEGRATION_ID + ) + return; + // Only show meaningful events, skip noisy ones + if ( + event.type === "content_chunk" || + event.type === "user_message_chunk" || + event.type === "session_complete" + ) { + return; + } + switch (event.type) { + case "thought_chunk": + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + () => ({ + responsePhase: "thinking", + }), + ); + break; + case "tool_start": + case "tool_update": + break; + case "tool_complete": + break; + case "permission_request": + break; // Handled separately with permission UI + case "prompt_complete": + break; // Not useful to show + case "session_mode_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = event.modeState.currentModeId + ? `Mode set to \`${event.modeState.currentModeId}\`.` + : "Session mode updated."; + break; + case "config_options_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = + event.configOptions.length === 1 + ? "Session option updated." + : "Session options updated."; + break; + case "session_info_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = event.title + ? `Session title updated to "${event.title}".` + : "Session metadata updated."; + if (event.title) { + appendAcpEvent({ + category: "status", + label: "Session title updated", + detail: event.title, + state: "info", + }); + } + break; + case "current_mode_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = `Mode set to \`${event.currentModeId}\`.`; + break; + case "slash_commands_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = "Slash commands refreshed."; + break; // Not useful to show + case "plan_update": { + const summary = + event.entries.length > 0 + ? event.entries.map((entry) => entry.content).join(" | ") + : "No plan steps"; + appendAcpEvent({ + category: "plan", + label: `Plan updated (${event.entries.length} steps)`, + detail: summary, + state: "info", + }); + break; + } + case "usage_update": { + break; + } + case "status_changed": + useAIChatStore.getState().actions.setAcpStatus(event.status); + break; // internal state sync + case "error": + appendAcpEvent({ + category: "error", + label: "Agent error", + detail: event.error, + state: "error", + }); + break; + case "ui_action": + break; // Handled by acp-handler + } + }, + chatState.mode, + chatState.outputStyle, + (data: string, mediaType: string) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + images: [...(currentMessage?.images || []), { data, mediaType }], + }), + ); + }, + (uri: string, name: string | null) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + resources: [...(currentMessage?.resources || []), { uri, name }], + }), + ); + }, + targetChatId, + ); + } catch (error) { + console.error("Failed to start streaming:", error); + chatActions.updateMessage(targetChatId, assistantMessageId, { + content: + "Error: Failed to connect to Agent service. Please check your API key and try again.", + isStreaming: false, + }); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + } + }; + + const processQueuedMessages = useCallback(async () => { + if (isSurfaceTyping || surfaceStreamingMessageId) { + return; + } + + const nextMessage = messageQueueRef.current.shift(); + setQueueCount(messageQueueRef.current.length); + if (nextMessage) { + console.log("Processing next queued message:", nextMessage); + await new Promise((resolve) => setTimeout(resolve, 500)); + await processMessage(nextMessage); + } + }, [isSurfaceTyping, surfaceStreamingMessageId]); + + const sendMessage = useCallback( + async (messageContent: string, images?: ImageContent[]) => { + const isAcp = isAcpAgent(currentAgentId); + // For ACP agents, we don't need an API key. + if (!messageContent.trim() || (!isAcp && !chatState.hasApiKey)) return; + + if (isSurfaceTyping || surfaceStreamingMessageId) { + messageQueueRef.current.push(messageContent); + setQueueCount(messageQueueRef.current.length); + return; + } + + await processMessage(messageContent, images); + }, + [ + chatState.hasApiKey, + currentAgentId, + isSurfaceTyping, + surfaceStreamingMessageId, + ], + ); + + const handleSendMessage = useCallback( + async (messageContent: string, images?: ImageContent[]) => { + await sendMessage(messageContent, images); + }, + [sendMessage], + ); + + const handleEditUserMessage = async (messageId: string, content: string) => { + if ( + isSurfaceTyping || + surfaceStreamingMessageId || + currentChat?.agentId !== "custom" + ) { + return; + } + + await processMessage(content, { editedUserMessageId: messageId }); + }; + + useEffect(() => { + const pendingLaunch = chatState.pendingAgentLaunchRequest; + if (!pendingLaunch) return; + if (pendingLaunch.chatId !== effectiveChatId) return; + if (activeBuffer?.type !== "agent") return; + if (activeBuffer.sessionId !== pendingLaunch.chatId) return; + if (isSurfaceTyping || surfaceStreamingMessageId) return; + if (!isAcpAgent(pendingLaunch.agentId) && !chatState.hasApiKey) return; + + setSelectedBufferIds(new Set(pendingLaunch.selectedBufferIds)); + setSelectedFilesPaths(new Set(pendingLaunch.selectedFilesPaths)); + chatActions.setPendingAgentLaunchRequest(null); + void sendMessage(pendingLaunch.prompt); + }, [ + chatActions, + effectiveChatId, + chatState.hasApiKey, + isSurfaceTyping, + chatState.pendingAgentLaunchRequest, + surfaceStreamingMessageId, + activeBuffer, + sendMessage, + ]); + + const currentPermission = permissionQueue[0]; + const isNewSession = + (currentChat?.messages.length ?? 0) === 0 && acpEvents.length === 0; + const useInitialComposer = isNewSession && !currentPermission; + const handlePermission = async (approved: boolean, optionId?: string) => { + if (!currentPermission) return; + try { + const option = currentPermission.options.find( + (item) => item.id === optionId, + ); + appendAcpEvent({ + id: `permission-response-${currentPermission.requestId}`, + category: "permission", + label: "Permission response", + detail: option?.name || (approved ? "allow" : "deny"), + state: approved ? "success" : "info", + }); + if (currentAgentId === CODEX_INTEGRATION_ID) { + await CodexIntegrationService.respond( + currentPermission.requestId, + approved, + ); + } else { + await AcpStreamHandler.respondToPermission( + currentPermission.requestId, + approved, + false, + optionId, + ); + } + } finally { + setPermissionQueue((prev) => prev.slice(1)); + } + }; + + return ( +
+ { + if (isMessageSearchOpen) { + closeMessageSearch(); + return; + } + + setIsMessageSearchOpen(true); + }} + onCloseMessageSearch={closeMessageSearch} + onMessageSearchQueryChange={setMessageSearchQuery} + messageSearchMatchCount={messageSearchMatches.length} + activeMessageSearchIndex={activeMessageSearchIndex} + onPreviousMessageSearchMatch={goToPreviousMessageSearchMatch} + onNextMessageSearchMatch={goToNextMessageSearchMatch} + /> + {isAiChatBlockedByPolicy ? ( + + + Agent is disabled + + Your organization policy has disabled Agent for this workspace. + + + + ) : ( + <> + {useInitialComposer ? ( + + + setSelectedBufferIds((current) => { + const next = new Set(current); + if (next.has(bufferId)) next.delete(bufferId); + else next.add(bufferId); + return next; + }) + } + onToggleFileSelection={(filePath) => + setSelectedFilesPaths((current) => { + const next = new Set(current); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }) + } + onSetSelectedBufferIds={setSelectedBufferIds} + onSetSelectedFilesPaths={setSelectedFilesPaths} + isActiveSurface={isActiveSurface} + presentation="initial" + onSendMessage={handleSendMessage} + onStopStreaming={stopStreaming} + /> + + ) : ( + + + + + + + + + )} + + {currentPermission ? ( + + ) : null} + + {!useInitialComposer ? ( + + setSelectedBufferIds((current) => { + const next = new Set(current); + if (next.has(bufferId)) next.delete(bufferId); + else next.add(bufferId); + return next; + }) + } + onToggleFileSelection={(filePath) => + setSelectedFilesPaths((current) => { + const next = new Set(current); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }) + } + onSetSelectedBufferIds={setSelectedBufferIds} + onSetSelectedFilesPaths={setSelectedFilesPaths} + isActiveSurface={isActiveSurface} + onSendMessage={handleSendMessage} + onStopStreaming={stopStreaming} + /> + ) : null} + + )} +
+ ); }); export default AIChat; diff --git a/src/features/ai/components/input/chat-input-bar.tsx b/src/features/ai/components/input/chat-input-bar.tsx index d3f1cf70e..ec47ea7b5 100644 --- a/src/features/ai/components/input/chat-input-bar.tsx +++ b/src/features/ai/components/input/chat-input-bar.tsx @@ -1,59 +1,65 @@ import { - CommandIcon, - ArrowUpIcon as ArrowUp, - DatabaseIcon as Database, - FileTextIcon as FileText, - MicrophoneIcon as Mic, - StopIcon as Stop, - XIcon as X, + CommandIcon, + ArrowUpIcon as ArrowUp, + DatabaseIcon as Database, + FileTextIcon as FileText, + MicrophoneIcon as Mic, + StopIcon as Stop, + XIcon as X, } from "@/ui/icons"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { shouldIgnoreFile } from "@/features/quick-open/utils/file-filtering"; import { - AI_CHAT_INSERT_SKILL_EVENT, - type AIChatSkillInsertDetail, + AI_CHAT_INSERT_SKILL_EVENT, + type AIChatSkillInsertDetail, } from "@/features/ai/lib/skill-events"; import { useAIChatStore } from "@/features/ai/stores/ai-chat.store"; import { useVoiceInput } from "@/features/ai/hooks/use-voice-input"; import { - getComposerDropdownPosition, - getComposerText, - getComposerTextBeforeCaret, - getComposerTextRange, - isComposerTokenElement, + getComposerDropdownPosition, + getComposerText, + getComposerTextBeforeCaret, + getComposerTextRange, + isComposerTokenElement, } from "@/features/ai/utils/chat-composer-dom"; -import type { InlineDropdownPosition, PastedImage } from "@/features/ai/types/chat-composer.types"; +import type { + InlineDropdownPosition, + PastedImage, +} from "@/features/ai/types/chat-composer.types"; import type { AIChatSkill } from "@/features/ai/types/skills.types"; import type { SlashCommand } from "@/features/ai/types/acp.types"; -import type { AIChatInputBarProps } from "@/features/ai/types/ai-chat.types"; +import type { + AIChatInputBarProps, + ImageContent, +} from "@/features/ai/types/ai-chat.types"; import type { FileEntry } from "@/features/file-system/types/app.types"; import { getProviderById } from "@/features/ai/types/providers.types"; import { openSidebarResourceBuffer } from "@/features/sidebar/utils/open-sidebar-resource"; import { - hasSidebarResourceDragData, - readSidebarResourceDragData, - SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, - type SidebarDragResource, + hasSidebarResourceDragData, + readSidebarResourceDragData, + SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, + type SidebarDragResource, } from "@/features/sidebar/utils/sidebar-resource-drag"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { - Attachment, - AttachmentAction, - AttachmentActions, - AttachmentContent, - AttachmentGroup, - AttachmentMedia, - AttachmentTitle, + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, } from "@/ui/attachment"; import Badge from "@/ui/badge"; import { Button } from "@/ui/button"; import { Toggle } from "@/ui/toggle"; import { cn } from "@/utils/cn"; import { - ChatComposer, - ChatComposerBody, - ChatComposerEditable, - ChatComposerToolbar, + ChatComposer, + ChatComposerBody, + ChatComposerEditable, + ChatComposerToolbar, } from "./chat-composer"; import { ChatPreferencesMenu } from "./chat-preferences-menu"; import { FileMentionDropdown } from "../mentions/file-mention-dropdown"; @@ -61,1210 +67,1329 @@ import { SlashCommandDropdown } from "../mentions/slash-command-dropdown"; import { ContextSelector } from "../selectors/context-selector"; const AIChatInputBar = memo(function AIChatInputBar({ - buffers, - allProjectFiles, - surfaceId, - currentAgentId, - isTyping, - streamingMessageId, - queueCount, - selectedBufferIds, - selectedFilesPaths, - onToggleBufferSelection, - onToggleFileSelection, - onSetSelectedBufferIds, - onSetSelectedFilesPaths, - isActiveSurface = true, - presentation = "default", - autoFocus = false, - onAgentChange, - onSendMessage, - onStopStreaming, + buffers, + allProjectFiles, + surfaceId, + currentAgentId, + isTyping, + streamingMessageId, + queueCount, + selectedBufferIds, + selectedFilesPaths, + onToggleBufferSelection, + onToggleFileSelection, + onSetSelectedBufferIds, + onSetSelectedFilesPaths, + isActiveSurface = true, + presentation = "default", + autoFocus = false, + onAgentChange, + onSendMessage, + onStopStreaming, }: AIChatInputBarProps) { - const inputRef = useRef(null); - const contextTriggerRef = useRef(null); - const aiChatContainerRef = useRef(null); - const isUpdatingContentRef = useRef(false); - const visibleMentionFilesRef = useRef([]); - const performanceTimer = useRef(null); - - // Local state for input emptiness check (to avoid subscribing to full input text) - const [hasInputText, setHasInputText] = useState(false); - const [isContextDragOver, setIsContextDragOver] = useState(false); - const [isComposerFocused, setIsComposerFocused] = useState(false); - const inputValueRef = useRef(""); - const [pastedImages, setPastedImages] = useState([]); - const [isContextDropdownOpen, setIsContextDropdownOpen] = useState(false); - const [mentionState, setMentionState] = useState({ - active: false, - position: { top: 0, bottom: 0, left: 0, width: 0 }, - search: "", - startIndex: 0, - selectedIndex: 0, - }); - const [slashCommandState, setSlashCommandState] = useState({ - active: false, - position: { top: 0, bottom: 0, left: 0, width: 0 }, - search: "", - selectedIndex: 0, - }); - const slashCommandRangeRef = useRef({ startIndex: 0, endIndex: 0 }); - - const hasApiKey = useAIChatStore((state) => state.hasApiKey); - const sessionConfigOptions = useAIChatStore((state) => state.sessionConfigOptions); - const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); - const aiModelId = useSettingsStore((state) => state.settings.aiModelId); - const aiCustomModelId = useSettingsStore((state) => state.settings.aiCustomModelId); - const aiAutocompleteCustomModelId = useSettingsStore( - (state) => state.settings.aiAutocompleteCustomModelId, - ); - const updateSetting = useSettingsStore((state) => state.actions.updateSetting); - - // Check if current agent is "custom" (only show model selector for custom agent) - const isCustomAgent = currentAgentId === "custom"; - - // ACP agents don't need API key (they handle their own auth) - const isInputEnabled = isCustomAgent ? hasApiKey : true; - const isStreaming = isTyping && !!streamingMessageId; - const changeSessionConfigOption = useAIChatStore( - (state) => state.actions.changeSessionConfigOption, - ); - - const handleAthasProviderChange = useCallback( - (nextProviderId: string) => { - const provider = getProviderById(nextProviderId); - void updateSetting("aiProviderId", nextProviderId); - if (nextProviderId === "custom") { - void updateSetting("aiModelId", aiCustomModelId || aiAutocompleteCustomModelId); - return; - } - if (provider && provider.models.length > 0) { - void updateSetting("aiModelId", provider.models[0].id); - } - }, - [aiAutocompleteCustomModelId, aiCustomModelId, updateSetting], - ); - - const handleAthasModelChange = useCallback( - (nextModelId: string) => { - if (aiProviderId === "custom") { - void updateSetting("aiCustomModelId", nextModelId); - } - void updateSetting("aiModelId", nextModelId); - }, - [aiProviderId, updateSetting], - ); - - const availableSlashCommands = useAIChatStore((state) => state.availableSlashCommands); - const filteredSlashCommands = useMemo(() => { - const search = slashCommandState.search.trim().toLowerCase(); - if (!search) return availableSlashCommands; - return availableSlashCommands.filter( - (command) => - command.name.toLowerCase().includes(search) || - command.description?.toLowerCase().includes(search), - ); - }, [availableSlashCommands, slashCommandState.search]); - - const setInput = useCallback((input: string) => { - inputValueRef.current = input; - }, []); - const addPastedImage = useCallback((image: PastedImage) => { - setPastedImages((current) => [...current, image]); - }, []); - const removePastedImage = useCallback((imageId: string) => { - setPastedImages((current) => current.filter((image) => image.id !== imageId)); - }, []); - const clearPastedImages = useCallback(() => setPastedImages([]), []); - const toggleBufferSelection = onToggleBufferSelection; - const toggleFileSelection = onToggleFileSelection; - const setSelectedBufferIds = onSetSelectedBufferIds; - const setSelectedFilesPaths = onSetSelectedFilesPaths; - const showMention = useCallback( - (position: InlineDropdownPosition, search: string, startIndex: number) => { - setMentionState({ active: true, position, search, startIndex, selectedIndex: 0 }); - }, - [], - ); - const hideMention = useCallback(() => { - setMentionState((current) => ({ ...current, active: false })); - }, []); - const updatePosition = useCallback((position: InlineDropdownPosition) => { - setMentionState((current) => ({ ...current, position })); - }, []); - const setSelectedIndex = useCallback((selectedIndex: number) => { - setMentionState((current) => ({ ...current, selectedIndex })); - }, []); - const showSlashCommands = useCallback((position: InlineDropdownPosition, search: string) => { - setSlashCommandState({ active: true, position, search, selectedIndex: 0 }); - }, []); - const hideSlashCommands = useCallback(() => { - setSlashCommandState((current) => ({ ...current, active: false })); - }, []); - const selectNextSlashCommand = useCallback(() => { - setSlashCommandState((current) => ({ - ...current, - selectedIndex: Math.min( - current.selectedIndex + 1, - Math.max(filteredSlashCommands.length - 1, 0), - ), - })); - }, [filteredSlashCommands.length]); - const selectPreviousSlashCommand = useCallback(() => { - setSlashCommandState((current) => ({ - ...current, - selectedIndex: Math.max(current.selectedIndex - 1, 0), - })); - }, []); - const setSlashCommandSelectedIndex = useCallback((selectedIndex: number) => { - setSlashCommandState((current) => ({ ...current, selectedIndex })); - }, []); - - const closeComposerPopovers = useCallback(() => { - if (slashCommandState.active) { - hideSlashCommands(); - } - if (isContextDropdownOpen) { - setIsContextDropdownOpen(false); - } - if (mentionState.active) { - hideMention(); - } - }, [ - slashCommandState.active, - hideSlashCommands, - isContextDropdownOpen, - setIsContextDropdownOpen, - mentionState.active, - hideMention, - ]); - - const closeInlineMenus = useCallback(() => { - closeComposerPopovers(); - }, [closeComposerPopovers]); - - const addBufferToContext = useCallback( - (bufferId: string) => { - if (selectedBufferIds.has(bufferId)) return; - const nextSelectedBufferIds = new Set(selectedBufferIds); - nextSelectedBufferIds.add(bufferId); - setSelectedBufferIds(nextSelectedBufferIds); - }, - [selectedBufferIds, setSelectedBufferIds], - ); - - const addPathToContext = useCallback( - (filePath: string) => { - if (selectedFilesPaths.has(filePath)) return; - const nextSelectedFilesPaths = new Set(selectedFilesPaths); - nextSelectedFilesPaths.add(filePath); - setSelectedFilesPaths(nextSelectedFilesPaths); - }, - [selectedFilesPaths, setSelectedFilesPaths], - ); - - const addSidebarResourceToContext = useCallback( - async (resource: SidebarDragResource) => { - if (resource.type === "file") { - const matchingBuffer = !resource.isDir - ? buffers.find((buffer) => buffer.path === resource.path) - : null; - if (matchingBuffer) { - addBufferToContext(matchingBuffer.id); - } else { - addPathToContext(resource.path); - } - return; - } - - if (resource.type === "git-worktree") { - addPathToContext(resource.path); - return; - } - - const bufferId = await openSidebarResourceBuffer(resource); - if (bufferId) { - addBufferToContext(bufferId); - } - }, - [addBufferToContext, addPathToContext, buffers], - ); - - useEffect(() => { - const handleSidebarResourceDropOnAI = (event: Event) => { - if (!isActiveSurface || surfaceId !== "activity-sidebar") return; - const resource = (event as CustomEvent<{ resource?: SidebarDragResource }>).detail?.resource; - if (!resource) return; - void addSidebarResourceToContext(resource); - }; - - window.addEventListener(SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, handleSidebarResourceDropOnAI); - return () => - window.removeEventListener(SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, handleSidebarResourceDropOnAI); - }, [addSidebarResourceToContext, isActiveSurface, surfaceId]); - - const handleContextDragOver = useCallback((event: React.DragEvent) => { - if (!hasSidebarResourceDragData(event.dataTransfer)) return; - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = "copy"; - setIsContextDragOver(true); - }, []); - - const handleContextDragLeave = useCallback((event: React.DragEvent) => { - const relatedTarget = event.relatedTarget as HTMLElement | null; - if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) { - setIsContextDragOver(false); - } - }, []); - - const handleContextDrop = useCallback( - async (event: React.DragEvent) => { - const resource = readSidebarResourceDragData(event.dataTransfer); - if (!resource) return; - - event.preventDefault(); - event.stopPropagation(); - setIsContextDragOver(false); - await addSidebarResourceToContext(resource); - }, - [addSidebarResourceToContext], - ); - - // Computed state for send button - const hasImages = pastedImages.length > 0; - const isSendDisabled = isStreaming ? false : (!hasInputText && !hasImages) || !isInputEnabled; - const getPlainTextFromDiv = useCallback(() => getComposerText(inputRef.current), []); - const getTextBeforeCaret = useCallback(() => getComposerTextBeforeCaret(inputRef.current), []); - const getCaretDropdownPosition = useCallback( - () => getComposerDropdownPosition(inputRef.current), - [], - ); - - const getMentionDropdownPosition = useCallback(() => { - const position = getCaretDropdownPosition(); - if (!inputRef.current) return position; - - const inputRect = inputRef.current.getBoundingClientRect(); - return { - ...position, - width: Math.min(360, Math.max(220, inputRect.width - 24)), - }; - }, [getCaretDropdownPosition]); - const getSlashDropdownPosition = useCallback(() => { - const position = getCaretDropdownPosition(); - if (!inputRef.current) return position; - - const inputRect = inputRef.current.getBoundingClientRect(); - return { - ...position, - width: Math.min(320, Math.max(180, inputRect.width - 24)), - }; - }, [getCaretDropdownPosition]); - - const syncInputFromEditable = useCallback(() => { - const newPlainText = getPlainTextFromDiv(); - setInput(newPlainText); - setHasInputText(newPlainText.trim().length > 0); - return newPlainText; - }, [getPlainTextFromDiv, setInput]); - - const removeComposerToken = useCallback( - (token: Element) => { - const parent = token.parentNode; - const nextSibling = token.nextSibling; - token.remove(); - if ( - nextSibling?.nodeType === Node.TEXT_NODE && - (nextSibling.textContent === "\u200B" || nextSibling.textContent === " ") - ) { - nextSibling.remove(); - } - - syncInputFromEditable(); - - if (!parent) return; - - const selection = window.getSelection(); - if (!selection) return; - - const range = document.createRange(); - if (nextSibling?.parentNode === parent) { - range.setStartBefore(nextSibling); - } else { - range.selectNodeContents(parent); - range.collapse(false); - } - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - }, - [syncInputFromEditable], - ); - - // Function to recalculate mention dropdown position - const recalculateMentionPosition = useCallback(() => { - if (!mentionState.active) return; - updatePosition(getMentionDropdownPosition()); - }, [mentionState.active, updatePosition, getMentionDropdownPosition]); - - const mentionableFiles = useMemo( - () => allProjectFiles.filter((file) => !file.isDir && !shouldIgnoreFile(file.path)), - [allProjectFiles], - ); - - const selectedContextItems = useMemo(() => { - const bufferSelections = buffers - .filter((buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id)) - .map((buffer) => ({ - type: "buffer" as const, - id: buffer.id, - name: buffer.name, - databaseType: buffer.type === "database" ? buffer.databaseType : undefined, - isDirty: buffer.type === "editor" && buffer.isDirty, - })); - - const fileSelections = Array.from(selectedFilesPaths).map((filePath) => ({ - type: "file" as const, - id: filePath, - name: filePath.split("/").pop() || "Unknown", - path: filePath, - })); - - return [...bufferSelections, ...fileSelections]; - }, [buffers, selectedBufferIds, selectedFilesPaths]); - - // ResizeObserver to track container size changes - useEffect(() => { - if (!aiChatContainerRef.current) return; - - const resizeObserver = new ResizeObserver(() => { - recalculateMentionPosition(); - if (slashCommandState.active) { - showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); - } - }); - - resizeObserver.observe(aiChatContainerRef.current); - - // Also observe the window resize - const handleWindowResize = () => { - recalculateMentionPosition(); - if (slashCommandState.active) { - showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); - } - }; - - window.addEventListener("resize", handleWindowResize); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", handleWindowResize); - // Cleanup timers - if (performanceTimer.current) { - clearTimeout(performanceTimer.current); - } - }; - }, [ - recalculateMentionPosition, - slashCommandState.active, - slashCommandState.search, - showSlashCommands, - getSlashDropdownPosition, - ]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle slash command navigation - if (slashCommandState.active) { - if (e.key === "ArrowDown") { - e.preventDefault(); - selectNextSlashCommand(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - selectPreviousSlashCommand(); - } else if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - if (filteredSlashCommands[slashCommandState.selectedIndex]) { - handleSlashCommandSelect(filteredSlashCommands[slashCommandState.selectedIndex]); - } - } else if (e.key === "Escape") { - e.preventDefault(); - hideSlashCommands(); - } - } else if (mentionState.active) { - if (e.key === "ArrowDown") { - e.preventDefault(); - const lastIndex = visibleMentionFilesRef.current.length - 1; - setSelectedIndex(lastIndex < 0 ? 0 : Math.min(mentionState.selectedIndex + 1, lastIndex)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex(Math.max(mentionState.selectedIndex - 1, 0)); - } else if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - const visibleFiles = visibleMentionFilesRef.current; - if (visibleFiles[mentionState.selectedIndex]) { - handleFileMentionSelect(visibleFiles[mentionState.selectedIndex]); - } - } else if (e.key === "Escape") { - e.preventDefault(); - hideMention(); - } - } else if (e.key === "Backspace" || e.key === "Delete") { - // Handle composer token deletion - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0 && inputRef.current) { - const range = selection.getRangeAt(0); - if (!range.collapsed) return; - - const container = range.startContainer; - const offset = range.startOffset; - let tokenToRemove: Element | null = null; - const isBackwardDelete = e.key === "Backspace"; - - if (container === inputRef.current) { - const candidateIndex = isBackwardDelete ? offset - 1 : offset; - const candidateNode = inputRef.current.childNodes[candidateIndex] ?? null; - if (isComposerTokenElement(candidateNode)) { - tokenToRemove = candidateNode; - } - } - - // Check if cursor is at the beginning of a text node that follows a composer token - if (!tokenToRemove && container.nodeType === Node.TEXT_NODE) { - const textContent = container.textContent || ""; - const candidateSibling = - isBackwardDelete && offset === 0 - ? container.previousSibling - : !isBackwardDelete && offset === textContent.length - ? container.nextSibling - : null; - - if (isComposerTokenElement(candidateSibling)) { - tokenToRemove = candidateSibling; - } - } - - // Check if cursor is right after a composer token (in separator text node) - if ( - isBackwardDelete && - !tokenToRemove && - container.nodeType === Node.TEXT_NODE && - container.textContent === "\u200B" && - offset === 1 - ) { - const previousSibling = container.previousSibling?.previousSibling ?? null; // Skip the space node - - if (isComposerTokenElement(previousSibling)) { - tokenToRemove = previousSibling; - } - } - - if (tokenToRemove) { - e.preventDefault(); - removeComposerToken(tokenToRemove); - return; - } - } - } else if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - // Debounced mention detection - increased delay for better performance - const debouncedMentionDetection = useCallback(() => { - if (performanceTimer.current) { - clearTimeout(performanceTimer.current); - } - - performanceTimer.current = window.setTimeout(() => { - if (!inputRef.current) return; - - const textBeforeCaret = getTextBeforeCaret(); - const lastAtIndex = textBeforeCaret.lastIndexOf("@"); - - if (lastAtIndex !== -1) { - const afterAt = textBeforeCaret.slice(lastAtIndex + 1); - // Check if there's no space between @ and end, and it's not part of a mention badge - if (!afterAt.includes(" ") && !afterAt.includes("]") && afterAt.length < 50) { - const position = getMentionDropdownPosition(); - showMention(position, afterAt, lastAtIndex); - } else { - hideMention(); - } - } else { - hideMention(); - } - }, 150); // Increased to 150ms for better performance - }, [showMention, hideMention, getMentionDropdownPosition, getTextBeforeCaret]); - - // Optimized input change handler - no throttle for immediate response - const handleInputChange = useCallback(() => { - if (!inputRef.current || isUpdatingContentRef.current) return; - - const plainTextFromDiv = getPlainTextFromDiv(); - - // Keep keystrokes local to this composer so sibling surfaces cannot mirror them. - const currentInput = inputValueRef.current; - - // Only update if content actually changed - if (plainTextFromDiv !== currentInput) { - setInput(plainTextFromDiv); - - // Update local state for button enabled/disabled - setHasInputText(plainTextFromDiv.trim().length > 0); - - const textBeforeCaret = getTextBeforeCaret(); - const slashMatch = textBeforeCaret.match(/(?:^|\s)\/([^\s/]*)$/); - if (slashMatch && slashMatch[1].length < 50) { - const search = slashMatch[1]; - const startIndex = textBeforeCaret.length - search.length - 1; - slashCommandRangeRef.current = { - startIndex, - endIndex: textBeforeCaret.length, - }; - if (isContextDropdownOpen) { - setIsContextDropdownOpen(false); - } - showSlashCommands(getSlashDropdownPosition(), search); - } else if (slashCommandState.active) { - hideSlashCommands(); - } - - // Only do mention detection if text contains @ and is reasonably short - if (plainTextFromDiv.includes("@") && plainTextFromDiv.length < 500) { - debouncedMentionDetection(); - } else if (mentionState.active) { - hideMention(); - } - } - }, [ - setInput, - getPlainTextFromDiv, - getTextBeforeCaret, - debouncedMentionDetection, - hideMention, - mentionState.active, - showSlashCommands, - hideSlashCommands, - slashCommandState.active, - getSlashDropdownPosition, - isContextDropdownOpen, - setIsContextDropdownOpen, - ]); - - const handleEditableMouseDown = useCallback((event: React.MouseEvent) => { - if (!inputRef.current) return; - - const target = event.target as HTMLElement | null; - const token = target?.closest("[data-mention],[data-slash-command]"); - if (!token || !inputRef.current.contains(token)) return; - - event.preventDefault(); - inputRef.current.focus(); - - const selection = window.getSelection(); - if (!selection) return; - - const range = document.createRange(); - range.setStartAfter(token); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - }, []); - - const insertTextAtCursor = useCallback( - (text: string) => { - if (!inputRef.current || !text) return; - - const normalizedText = text.replace(/\s+/g, " ").trim(); - if (!normalizedText) return; - - const selection = window.getSelection(); - const range = document.createRange(); - const currentText = getPlainTextFromDiv(); - const prefix = currentText.trim().length > 0 && !/\s$/.test(currentText) ? " " : ""; - const textNode = document.createTextNode(`${prefix}${normalizedText} `); - - inputRef.current.focus(); - - const selectionInsideInput = - !!selection && selection.rangeCount > 0 && inputRef.current.contains(selection.anchorNode); - - if (selectionInsideInput && selection) { - const selectedRange = selection.getRangeAt(0); - selectedRange.deleteContents(); - selectedRange.insertNode(textNode); - range.setStartAfter(textNode); - } else { - range.selectNodeContents(inputRef.current); - range.collapse(false); - range.insertNode(textNode); - range.setStartAfter(textNode); - } - - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - handleInputChange(); - }, - [getPlainTextFromDiv, handleInputChange], - ); - - const insertSkillAtCursor = useCallback( - (skill: AIChatSkill) => { - if (!inputRef.current || !skill.content.trim()) return; - - const selection = window.getSelection(); - const range = document.createRange(); - const currentText = getPlainTextFromDiv(); - const prefix = currentText.trim().length > 0 && !/\s$/.test(currentText) ? "\n\n" : ""; - const textNode = document.createTextNode(`${prefix}${skill.content.trim()} `); - - inputRef.current.focus(); - - const selectionInsideInput = - !!selection && selection.rangeCount > 0 && inputRef.current.contains(selection.anchorNode); - - if (selectionInsideInput && selection) { - const selectedRange = selection.getRangeAt(0); - selectedRange.deleteContents(); - selectedRange.insertNode(textNode); - range.setStartAfter(textNode); - } else { - range.selectNodeContents(inputRef.current); - range.collapse(false); - range.insertNode(textNode); - range.setStartAfter(textNode); - } - - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - handleInputChange(); - setHasInputText(true); - }, - [getPlainTextFromDiv, handleInputChange], - ); - - useEffect(() => { - const handleInsertSkill = (event: Event) => { - const detail = (event as CustomEvent).detail; - if (!isActiveSurface || detail?.surfaceId !== surfaceId) return; - insertSkillAtCursor(detail.skill); - }; - - window.addEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); - return () => window.removeEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); - }, [insertSkillAtCursor, isActiveSurface]); - - // Handle paste - strip HTML formatting, keep only plain text. Images are added to preview. - const handlePaste = useCallback( - (e: React.ClipboardEvent) => { - const clipboardData = e.clipboardData; - if (!clipboardData) return; - - // Check for images first - const items = clipboardData.items; - let hasImage = false; - - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith("image/")) { - hasImage = true; - e.preventDefault(); - - const file = items[i].getAsFile(); - if (file) { - const reader = new FileReader(); - reader.onload = (event) => { - const dataUrl = event.target?.result as string; - if (dataUrl) { - addPastedImage({ - id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - dataUrl, - name: file.name || `image-${Date.now()}.png`, - size: file.size, - }); - } - }; - reader.readAsDataURL(file); - } - } - } - - // If there was an image, don't process text - if (hasImage) return; - - // For text content, prevent default and insert plain text only - e.preventDefault(); - - // Get plain text from clipboard - const plainText = clipboardData.getData("text/plain"); - if (!plainText) return; - - // Insert plain text at cursor position - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - - const range = selection.getRangeAt(0); - range.deleteContents(); - - const textNode = document.createTextNode(plainText); - range.insertNode(textNode); - - // Move cursor to end of inserted text - range.setStartAfter(textNode); - range.setEndAfter(textNode); - selection.removeAllRanges(); - selection.addRange(range); - - // Trigger input change handler to update state - handleInputChange(); - }, - [handleInputChange, addPastedImage], - ); - - // Handle file mention selection - const handleFileMentionSelect = useCallback( - (file: FileEntry) => { - if (!inputRef.current) return; - - isUpdatingContentRef.current = true; - hideMention(); - const mentionRange = getComposerTextRange( - inputRef.current, - mentionState.startIndex, - mentionState.startIndex + mentionState.search.length + 1, - ); - mentionRange.deleteContents(); - - const mentionSpan = document.createElement("span"); - mentionSpan.setAttribute("data-mention", "true"); - mentionSpan.setAttribute("data-mention-name", file.name); - mentionSpan.setAttribute("data-mention-path", file.path); - mentionSpan.setAttribute("contenteditable", "false"); - mentionSpan.title = file.path; - mentionSpan.className = - "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-primary/10 px-1.5 py-0.5 leading-row text-primary align-baseline select-none"; - mentionSpan.textContent = file.name; - - const trailingSpace = document.createTextNode(" "); - const fragment = document.createDocumentFragment(); - fragment.append(mentionSpan, trailingSpace); - mentionRange.insertNode(fragment); - - const selection = window.getSelection(); - if (selection) { - const caretRange = document.createRange(); - caretRange.setStart(trailingSpace, trailingSpace.length); - caretRange.collapse(true); - selection.removeAllRanges(); - selection.addRange(caretRange); - } - - inputRef.current.focus(); - syncInputFromEditable(); - isUpdatingContentRef.current = false; - }, - [hideMention, mentionState.search.length, mentionState.startIndex, syncInputFromEditable], - ); - - // Handle slash command selection - const handleSlashCommandSelect = useCallback( - (command: SlashCommand) => { - if (!inputRef.current) return; - - isUpdatingContentRef.current = true; - const { startIndex, endIndex } = slashCommandRangeRef.current; - hideSlashCommands(); - const commandRange = getComposerTextRange(inputRef.current, startIndex, endIndex); - commandRange.deleteContents(); - - const commandSpan = document.createElement("span"); - commandSpan.setAttribute("data-slash-command", "true"); - commandSpan.setAttribute("data-slash-command-name", command.name); - commandSpan.setAttribute("contenteditable", "false"); - commandSpan.title = command.description || `/${command.name}`; - commandSpan.className = - "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-accent/70 px-1.5 py-0.5 leading-row text-foreground align-baseline select-none"; - commandSpan.textContent = `/${command.name}`; - - const trailingSpace = document.createTextNode(" "); - const fragment = document.createDocumentFragment(); - fragment.append(commandSpan, trailingSpace); - commandRange.insertNode(fragment); - - const selection = window.getSelection(); - if (selection) { - const caretRange = document.createRange(); - caretRange.setStart(trailingSpace, trailingSpace.length); - caretRange.collapse(true); - selection.removeAllRanges(); - selection.addRange(caretRange); - } - - inputRef.current.focus(); - syncInputFromEditable(); - isUpdatingContentRef.current = false; - }, - [hideSlashCommands, syncInputFromEditable], - ); - - const handleSendMessage = async () => { - const currentInput = inputValueRef.current; - const currentImages = pastedImages; - const hasContent = currentInput.trim() || currentImages.length > 0; - if (!hasContent || !isInputEnabled) return; - - // Clear input and images immediately after send is triggered - setInput(""); - setHasInputText(false); - clearPastedImages(); - if (inputRef.current) { - inputRef.current.innerHTML = ""; - } - - // Send the captured message (TODO: include images in message) - await onSendMessage(currentInput); - }; - - const focusInput = useCallback(() => inputRef.current?.focus(), []); - const { - interimTranscript, - isListening, - isMacDevBlocked: isMacDevSpeechRecognitionBlocked, - isSupported: isSpeechRecognitionSupported, - toggle: toggleVoiceInput, - } = useVoiceInput({ - enabled: isInputEnabled, - insertText: insertTextAtCursor, - focusInput, - }); - - const hasSlashCommands = availableSlashCommands.length > 0; - const isInitialPresentation = presentation === "initial"; - const inputPlaceholder = isInputEnabled - ? isInitialPresentation - ? "What do you want to create?" - : hasSlashCommands - ? "Ask anything... (@ files, / commands)" - : "Ask anything... (@ to mention files)" - : "Configure API key to enable Agent..."; - - useEffect(() => { - if (!autoFocus || !isActiveSurface) return; - - const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); - return () => window.cancelAnimationFrame(frame); - }, [autoFocus, isActiveSurface]); - - return ( - - - {pastedImages.length > 0 && ( - - {pastedImages.map((image) => ( - - - {image.name} - - - {image.name} - - - removePastedImage(image.id)} - aria-label={`Remove ${image.name}`} - > - - - - - ))} - - )} - - setIsComposerFocused(true)} - onBlur={() => setIsComposerFocused(false)} - onPaste={handlePaste} - data-placeholder={inputPlaceholder} - className={cn( - isInitialPresentation && "max-h-48 min-h-28 overflow-y-auto px-4 py-4 ui-text-base", - )} - role="textbox" - aria-multiline={!isInitialPresentation} - aria-label="Message input" - tabIndex={isInputEnabled ? 0 : -1} - /> - - -
- { - if (open) { - closeInlineMenus(); - } - setIsContextDropdownOpen(open); - }} - /> -
- - {queueCount > 0 && ( - - - {queueCount} - - )} - -
- {hasSlashCommands && ( - - )} - - - void changeSessionConfigOption(optionId, value) - } - onSelectSkill={insertSkillAtCursor} - onBeforeOpen={closeInlineMenus} - /> - - - - - - -
-
- - {selectedContextItems.length > 0 ? ( - - {selectedContextItems.map((item) => ( - { - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - event.preventDefault(); - const chips = Array.from( - event.currentTarget.parentElement?.querySelectorAll( - "[data-context-chip]", - ) || [], - ); - const currentIndex = chips.indexOf(event.currentTarget); - const nextIndex = - event.key === "ArrowLeft" - ? Math.max(currentIndex - 1, 0) - : Math.min(currentIndex + 1, chips.length - 1); - chips[nextIndex]?.focus(); - return; - } - - if (event.key === "Backspace" || event.key === "Delete") { - event.preventDefault(); - const chipContainer = event.currentTarget.parentElement; - const chips = Array.from( - chipContainer?.querySelectorAll("[data-context-chip]") || [], - ); - const currentIndex = chips.indexOf(event.currentTarget); - const nextFocusIndex = Math.max(0, Math.min(currentIndex, chips.length - 2)); - if (item.type === "buffer") { - toggleBufferSelection(item.id); - } else { - toggleFileSelection(item.id); - } - requestAnimationFrame(() => { - const nextChips = Array.from( - chipContainer?.querySelectorAll("[data-context-chip]") || [], - ); - const nextChip = nextChips[nextFocusIndex]; - if (nextChip) { - nextChip.focus(); - return; - } - contextTriggerRef.current?.focus(); - }); - } - }} - > - - {item.type === "buffer" ? ( - item.databaseType ? ( - - ) : ( - - ) - ) : ( - - )} - - - - {item.name} - {item.type === "buffer" && item.isDirty ? ( - - ) : null} - - - - { - if (item.type === "buffer") { - toggleBufferSelection(item.id); - } else { - toggleFileSelection(item.id); - } - }} - aria-label={`Remove ${item.name} from context`} - tabIndex={0} - > - - - - - ))} - - ) : null} -
- - {(isActiveSurface || isComposerFocused) && mentionState.active && ( - { - visibleMentionFilesRef.current = files; - }} - /> - )} - - {slashCommandState.active && ( - { - handleSlashCommandSelect(command); - }} - onClose={hideSlashCommands} - /> - )} -
- ); + const inputRef = useRef(null); + const contextTriggerRef = useRef(null); + const aiChatContainerRef = useRef(null); + const isUpdatingContentRef = useRef(false); + const visibleMentionFilesRef = useRef([]); + const performanceTimer = useRef(null); + + // Local state for input emptiness check (to avoid subscribing to full input text) + const [hasInputText, setHasInputText] = useState(false); + const [isContextDragOver, setIsContextDragOver] = useState(false); + const [isComposerFocused, setIsComposerFocused] = useState(false); + const inputValueRef = useRef(""); + const [pastedImages, setPastedImages] = useState([]); + const [isContextDropdownOpen, setIsContextDropdownOpen] = useState(false); + const [mentionState, setMentionState] = useState({ + active: false, + position: { top: 0, bottom: 0, left: 0, width: 0 }, + search: "", + startIndex: 0, + selectedIndex: 0, + }); + const [slashCommandState, setSlashCommandState] = useState({ + active: false, + position: { top: 0, bottom: 0, left: 0, width: 0 }, + search: "", + selectedIndex: 0, + }); + const slashCommandRangeRef = useRef({ startIndex: 0, endIndex: 0 }); + + const hasApiKey = useAIChatStore((state) => state.hasApiKey); + const sessionConfigOptions = useAIChatStore( + (state) => state.sessionConfigOptions, + ); + const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); + const aiModelId = useSettingsStore((state) => state.settings.aiModelId); + const aiCustomModelId = useSettingsStore( + (state) => state.settings.aiCustomModelId, + ); + const aiAutocompleteCustomModelId = useSettingsStore( + (state) => state.settings.aiAutocompleteCustomModelId, + ); + const updateSetting = useSettingsStore( + (state) => state.actions.updateSetting, + ); + + // Check if current agent is "custom" (only show model selector for custom agent) + const isCustomAgent = currentAgentId === "custom"; + + // ACP agents don't need API key (they handle their own auth) + const isInputEnabled = isCustomAgent ? hasApiKey : true; + const isStreaming = isTyping && !!streamingMessageId; + const changeSessionConfigOption = useAIChatStore( + (state) => state.actions.changeSessionConfigOption, + ); + + const handleAthasProviderChange = useCallback( + (nextProviderId: string) => { + const provider = getProviderById(nextProviderId); + void updateSetting("aiProviderId", nextProviderId); + if (nextProviderId === "custom") { + void updateSetting( + "aiModelId", + aiCustomModelId || aiAutocompleteCustomModelId, + ); + return; + } + if (provider && provider.models.length > 0) { + void updateSetting("aiModelId", provider.models[0].id); + } + }, + [aiAutocompleteCustomModelId, aiCustomModelId, updateSetting], + ); + + const handleAthasModelChange = useCallback( + (nextModelId: string) => { + if (aiProviderId === "custom") { + void updateSetting("aiCustomModelId", nextModelId); + } + void updateSetting("aiModelId", nextModelId); + }, + [aiProviderId, updateSetting], + ); + + const availableSlashCommands = useAIChatStore( + (state) => state.availableSlashCommands, + ); + const filteredSlashCommands = useMemo(() => { + const search = slashCommandState.search.trim().toLowerCase(); + if (!search) return availableSlashCommands; + return availableSlashCommands.filter( + (command) => + command.name.toLowerCase().includes(search) || + command.description?.toLowerCase().includes(search), + ); + }, [availableSlashCommands, slashCommandState.search]); + + const setInput = useCallback((input: string) => { + inputValueRef.current = input; + }, []); + const addPastedImage = useCallback((image: PastedImage) => { + setPastedImages((current) => [...current, image]); + }, []); + const removePastedImage = useCallback((imageId: string) => { + setPastedImages((current) => + current.filter((image) => image.id !== imageId), + ); + }, []); + const clearPastedImages = useCallback(() => setPastedImages([]), []); + const toggleBufferSelection = onToggleBufferSelection; + const toggleFileSelection = onToggleFileSelection; + const setSelectedBufferIds = onSetSelectedBufferIds; + const setSelectedFilesPaths = onSetSelectedFilesPaths; + const showMention = useCallback( + (position: InlineDropdownPosition, search: string, startIndex: number) => { + setMentionState({ + active: true, + position, + search, + startIndex, + selectedIndex: 0, + }); + }, + [], + ); + const hideMention = useCallback(() => { + setMentionState((current) => ({ ...current, active: false })); + }, []); + const updatePosition = useCallback((position: InlineDropdownPosition) => { + setMentionState((current) => ({ ...current, position })); + }, []); + const setSelectedIndex = useCallback((selectedIndex: number) => { + setMentionState((current) => ({ ...current, selectedIndex })); + }, []); + const showSlashCommands = useCallback( + (position: InlineDropdownPosition, search: string) => { + setSlashCommandState({ + active: true, + position, + search, + selectedIndex: 0, + }); + }, + [], + ); + const hideSlashCommands = useCallback(() => { + setSlashCommandState((current) => ({ ...current, active: false })); + }, []); + const selectNextSlashCommand = useCallback(() => { + setSlashCommandState((current) => ({ + ...current, + selectedIndex: Math.min( + current.selectedIndex + 1, + Math.max(filteredSlashCommands.length - 1, 0), + ), + })); + }, [filteredSlashCommands.length]); + const selectPreviousSlashCommand = useCallback(() => { + setSlashCommandState((current) => ({ + ...current, + selectedIndex: Math.max(current.selectedIndex - 1, 0), + })); + }, []); + const setSlashCommandSelectedIndex = useCallback((selectedIndex: number) => { + setSlashCommandState((current) => ({ ...current, selectedIndex })); + }, []); + + const closeComposerPopovers = useCallback(() => { + if (slashCommandState.active) { + hideSlashCommands(); + } + if (isContextDropdownOpen) { + setIsContextDropdownOpen(false); + } + if (mentionState.active) { + hideMention(); + } + }, [ + slashCommandState.active, + hideSlashCommands, + isContextDropdownOpen, + setIsContextDropdownOpen, + mentionState.active, + hideMention, + ]); + + const closeInlineMenus = useCallback(() => { + closeComposerPopovers(); + }, [closeComposerPopovers]); + + const addBufferToContext = useCallback( + (bufferId: string) => { + if (selectedBufferIds.has(bufferId)) return; + const nextSelectedBufferIds = new Set(selectedBufferIds); + nextSelectedBufferIds.add(bufferId); + setSelectedBufferIds(nextSelectedBufferIds); + }, + [selectedBufferIds, setSelectedBufferIds], + ); + + const addPathToContext = useCallback( + (filePath: string) => { + if (selectedFilesPaths.has(filePath)) return; + const nextSelectedFilesPaths = new Set(selectedFilesPaths); + nextSelectedFilesPaths.add(filePath); + setSelectedFilesPaths(nextSelectedFilesPaths); + }, + [selectedFilesPaths, setSelectedFilesPaths], + ); + + const addSidebarResourceToContext = useCallback( + async (resource: SidebarDragResource) => { + if (resource.type === "file") { + const matchingBuffer = !resource.isDir + ? buffers.find((buffer) => buffer.path === resource.path) + : null; + if (matchingBuffer) { + addBufferToContext(matchingBuffer.id); + } else { + addPathToContext(resource.path); + } + return; + } + + if (resource.type === "git-worktree") { + addPathToContext(resource.path); + return; + } + + const bufferId = await openSidebarResourceBuffer(resource); + if (bufferId) { + addBufferToContext(bufferId); + } + }, + [addBufferToContext, addPathToContext, buffers], + ); + + useEffect(() => { + const handleSidebarResourceDropOnAI = (event: Event) => { + if (!isActiveSurface || surfaceId !== "activity-sidebar") return; + const resource = ( + event as CustomEvent<{ resource?: SidebarDragResource }> + ).detail?.resource; + if (!resource) return; + void addSidebarResourceToContext(resource); + }; + + window.addEventListener( + SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, + handleSidebarResourceDropOnAI, + ); + return () => + window.removeEventListener( + SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, + handleSidebarResourceDropOnAI, + ); + }, [addSidebarResourceToContext, isActiveSurface, surfaceId]); + + const handleContextDragOver = useCallback( + (event: React.DragEvent) => { + if (!hasSidebarResourceDragData(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setIsContextDragOver(true); + }, + [], + ); + + const handleContextDragLeave = useCallback( + (event: React.DragEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement | null; + if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) { + setIsContextDragOver(false); + } + }, + [], + ); + + const handleContextDrop = useCallback( + async (event: React.DragEvent) => { + const resource = readSidebarResourceDragData(event.dataTransfer); + if (!resource) return; + + event.preventDefault(); + event.stopPropagation(); + setIsContextDragOver(false); + await addSidebarResourceToContext(resource); + }, + [addSidebarResourceToContext], + ); + + // Computed state for send button + const hasImages = pastedImages.length > 0; + const isSendDisabled = isStreaming + ? false + : (!hasInputText && !hasImages) || !isInputEnabled; + const getPlainTextFromDiv = useCallback( + () => getComposerText(inputRef.current), + [], + ); + const getTextBeforeCaret = useCallback( + () => getComposerTextBeforeCaret(inputRef.current), + [], + ); + const getCaretDropdownPosition = useCallback( + () => getComposerDropdownPosition(inputRef.current), + [], + ); + + const getMentionDropdownPosition = useCallback(() => { + const position = getCaretDropdownPosition(); + if (!inputRef.current) return position; + + const inputRect = inputRef.current.getBoundingClientRect(); + return { + ...position, + width: Math.min(360, Math.max(220, inputRect.width - 24)), + }; + }, [getCaretDropdownPosition]); + const getSlashDropdownPosition = useCallback(() => { + const position = getCaretDropdownPosition(); + if (!inputRef.current) return position; + + const inputRect = inputRef.current.getBoundingClientRect(); + return { + ...position, + width: Math.min(320, Math.max(180, inputRect.width - 24)), + }; + }, [getCaretDropdownPosition]); + + const syncInputFromEditable = useCallback(() => { + const newPlainText = getPlainTextFromDiv(); + setInput(newPlainText); + setHasInputText(newPlainText.trim().length > 0); + return newPlainText; + }, [getPlainTextFromDiv, setInput]); + + const removeComposerToken = useCallback( + (token: Element) => { + const parent = token.parentNode; + const nextSibling = token.nextSibling; + token.remove(); + if ( + nextSibling?.nodeType === Node.TEXT_NODE && + (nextSibling.textContent === "\u200B" || + nextSibling.textContent === " ") + ) { + nextSibling.remove(); + } + + syncInputFromEditable(); + + if (!parent) return; + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + if (nextSibling?.parentNode === parent) { + range.setStartBefore(nextSibling); + } else { + range.selectNodeContents(parent); + range.collapse(false); + } + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }, + [syncInputFromEditable], + ); + + // Function to recalculate mention dropdown position + const recalculateMentionPosition = useCallback(() => { + if (!mentionState.active) return; + updatePosition(getMentionDropdownPosition()); + }, [mentionState.active, updatePosition, getMentionDropdownPosition]); + + const mentionableFiles = useMemo( + () => + allProjectFiles.filter( + (file) => !file.isDir && !shouldIgnoreFile(file.path), + ), + [allProjectFiles], + ); + + const selectedContextItems = useMemo(() => { + const bufferSelections = buffers + .filter( + (buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id), + ) + .map((buffer) => ({ + type: "buffer" as const, + id: buffer.id, + name: buffer.name, + databaseType: + buffer.type === "database" ? buffer.databaseType : undefined, + isDirty: buffer.type === "editor" && buffer.isDirty, + })); + + const fileSelections = Array.from(selectedFilesPaths).map((filePath) => ({ + type: "file" as const, + id: filePath, + name: filePath.split("/").pop() || "Unknown", + path: filePath, + })); + + return [...bufferSelections, ...fileSelections]; + }, [buffers, selectedBufferIds, selectedFilesPaths]); + + // ResizeObserver to track container size changes + useEffect(() => { + if (!aiChatContainerRef.current) return; + + const resizeObserver = new ResizeObserver(() => { + recalculateMentionPosition(); + if (slashCommandState.active) { + showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); + } + }); + + resizeObserver.observe(aiChatContainerRef.current); + + // Also observe the window resize + const handleWindowResize = () => { + recalculateMentionPosition(); + if (slashCommandState.active) { + showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); + } + }; + + window.addEventListener("resize", handleWindowResize); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", handleWindowResize); + // Cleanup timers + if (performanceTimer.current) { + clearTimeout(performanceTimer.current); + } + }; + }, [ + recalculateMentionPosition, + slashCommandState.active, + slashCommandState.search, + showSlashCommands, + getSlashDropdownPosition, + ]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle slash command navigation + if (slashCommandState.active) { + if (e.key === "ArrowDown") { + e.preventDefault(); + selectNextSlashCommand(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selectPreviousSlashCommand(); + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + if (filteredSlashCommands[slashCommandState.selectedIndex]) { + handleSlashCommandSelect( + filteredSlashCommands[slashCommandState.selectedIndex], + ); + } + } else if (e.key === "Escape") { + e.preventDefault(); + hideSlashCommands(); + } + } else if (mentionState.active) { + if (e.key === "ArrowDown") { + e.preventDefault(); + const lastIndex = visibleMentionFilesRef.current.length - 1; + setSelectedIndex( + lastIndex < 0 + ? 0 + : Math.min(mentionState.selectedIndex + 1, lastIndex), + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex(Math.max(mentionState.selectedIndex - 1, 0)); + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + const visibleFiles = visibleMentionFilesRef.current; + if (visibleFiles[mentionState.selectedIndex]) { + handleFileMentionSelect(visibleFiles[mentionState.selectedIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + hideMention(); + } + } else if (e.key === "Backspace" || e.key === "Delete") { + // Handle composer token deletion + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && inputRef.current) { + const range = selection.getRangeAt(0); + if (!range.collapsed) return; + + const container = range.startContainer; + const offset = range.startOffset; + let tokenToRemove: Element | null = null; + const isBackwardDelete = e.key === "Backspace"; + + if (container === inputRef.current) { + const candidateIndex = isBackwardDelete ? offset - 1 : offset; + const candidateNode = + inputRef.current.childNodes[candidateIndex] ?? null; + if (isComposerTokenElement(candidateNode)) { + tokenToRemove = candidateNode; + } + } + + // Check if cursor is at the beginning of a text node that follows a composer token + if (!tokenToRemove && container.nodeType === Node.TEXT_NODE) { + const textContent = container.textContent || ""; + const candidateSibling = + isBackwardDelete && offset === 0 + ? container.previousSibling + : !isBackwardDelete && offset === textContent.length + ? container.nextSibling + : null; + + if (isComposerTokenElement(candidateSibling)) { + tokenToRemove = candidateSibling; + } + } + + // Check if cursor is right after a composer token (in separator text node) + if ( + isBackwardDelete && + !tokenToRemove && + container.nodeType === Node.TEXT_NODE && + container.textContent === "\u200B" && + offset === 1 + ) { + const previousSibling = + container.previousSibling?.previousSibling ?? null; // Skip the space node + + if (isComposerTokenElement(previousSibling)) { + tokenToRemove = previousSibling; + } + } + + if (tokenToRemove) { + e.preventDefault(); + removeComposerToken(tokenToRemove); + return; + } + } + } else if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + // Debounced mention detection - increased delay for better performance + const debouncedMentionDetection = useCallback(() => { + if (performanceTimer.current) { + clearTimeout(performanceTimer.current); + } + + performanceTimer.current = window.setTimeout(() => { + if (!inputRef.current) return; + + const textBeforeCaret = getTextBeforeCaret(); + const lastAtIndex = textBeforeCaret.lastIndexOf("@"); + + if (lastAtIndex !== -1) { + const afterAt = textBeforeCaret.slice(lastAtIndex + 1); + // Check if there's no space between @ and end, and it's not part of a mention badge + if ( + !afterAt.includes(" ") && + !afterAt.includes("]") && + afterAt.length < 50 + ) { + const position = getMentionDropdownPosition(); + showMention(position, afterAt, lastAtIndex); + } else { + hideMention(); + } + } else { + hideMention(); + } + }, 150); // Increased to 150ms for better performance + }, [ + showMention, + hideMention, + getMentionDropdownPosition, + getTextBeforeCaret, + ]); + + // Optimized input change handler - no throttle for immediate response + const handleInputChange = useCallback(() => { + if (!inputRef.current || isUpdatingContentRef.current) return; + + const plainTextFromDiv = getPlainTextFromDiv(); + + // Keep keystrokes local to this composer so sibling surfaces cannot mirror them. + const currentInput = inputValueRef.current; + + // Only update if content actually changed + if (plainTextFromDiv !== currentInput) { + setInput(plainTextFromDiv); + + // Update local state for button enabled/disabled + setHasInputText(plainTextFromDiv.trim().length > 0); + + const textBeforeCaret = getTextBeforeCaret(); + const slashMatch = textBeforeCaret.match(/(?:^|\s)\/([^\s/]*)$/); + if (slashMatch && slashMatch[1].length < 50) { + const search = slashMatch[1]; + const startIndex = textBeforeCaret.length - search.length - 1; + slashCommandRangeRef.current = { + startIndex, + endIndex: textBeforeCaret.length, + }; + if (isContextDropdownOpen) { + setIsContextDropdownOpen(false); + } + showSlashCommands(getSlashDropdownPosition(), search); + } else if (slashCommandState.active) { + hideSlashCommands(); + } + + // Only do mention detection if text contains @ and is reasonably short + if (plainTextFromDiv.includes("@") && plainTextFromDiv.length < 500) { + debouncedMentionDetection(); + } else if (mentionState.active) { + hideMention(); + } + } + }, [ + setInput, + getPlainTextFromDiv, + getTextBeforeCaret, + debouncedMentionDetection, + hideMention, + mentionState.active, + showSlashCommands, + hideSlashCommands, + slashCommandState.active, + getSlashDropdownPosition, + isContextDropdownOpen, + setIsContextDropdownOpen, + ]); + + const handleEditableMouseDown = useCallback( + (event: React.MouseEvent) => { + if (!inputRef.current) return; + + const target = event.target as HTMLElement | null; + const token = target?.closest("[data-mention],[data-slash-command]"); + if (!token || !inputRef.current.contains(token)) return; + + event.preventDefault(); + inputRef.current.focus(); + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStartAfter(token); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }, + [], + ); + + const insertTextAtCursor = useCallback( + (text: string) => { + if (!inputRef.current || !text) return; + + const normalizedText = text.replace(/\s+/g, " ").trim(); + if (!normalizedText) return; + + const selection = window.getSelection(); + const range = document.createRange(); + const currentText = getPlainTextFromDiv(); + const prefix = + currentText.trim().length > 0 && !/\s$/.test(currentText) ? " " : ""; + const textNode = document.createTextNode(`${prefix}${normalizedText} `); + + inputRef.current.focus(); + + const selectionInsideInput = + !!selection && + selection.rangeCount > 0 && + inputRef.current.contains(selection.anchorNode); + + if (selectionInsideInput && selection) { + const selectedRange = selection.getRangeAt(0); + selectedRange.deleteContents(); + selectedRange.insertNode(textNode); + range.setStartAfter(textNode); + } else { + range.selectNodeContents(inputRef.current); + range.collapse(false); + range.insertNode(textNode); + range.setStartAfter(textNode); + } + + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + handleInputChange(); + }, + [getPlainTextFromDiv, handleInputChange], + ); + + const insertSkillAtCursor = useCallback( + (skill: AIChatSkill) => { + if (!inputRef.current || !skill.content.trim()) return; + + const selection = window.getSelection(); + const range = document.createRange(); + const currentText = getPlainTextFromDiv(); + const prefix = + currentText.trim().length > 0 && !/\s$/.test(currentText) ? "\n\n" : ""; + const textNode = document.createTextNode( + `${prefix}${skill.content.trim()} `, + ); + + inputRef.current.focus(); + + const selectionInsideInput = + !!selection && + selection.rangeCount > 0 && + inputRef.current.contains(selection.anchorNode); + + if (selectionInsideInput && selection) { + const selectedRange = selection.getRangeAt(0); + selectedRange.deleteContents(); + selectedRange.insertNode(textNode); + range.setStartAfter(textNode); + } else { + range.selectNodeContents(inputRef.current); + range.collapse(false); + range.insertNode(textNode); + range.setStartAfter(textNode); + } + + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + handleInputChange(); + setHasInputText(true); + }, + [getPlainTextFromDiv, handleInputChange], + ); + + useEffect(() => { + const handleInsertSkill = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!isActiveSurface || detail?.surfaceId !== surfaceId) return; + insertSkillAtCursor(detail.skill); + }; + + window.addEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); + return () => + window.removeEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); + }, [insertSkillAtCursor, isActiveSurface]); + + // Handle paste - strip HTML formatting, keep only plain text. Images are added to preview. + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + // Check for images first + const items = clipboardData.items; + let hasImage = false; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith("image/")) { + hasImage = true; + e.preventDefault(); + + const file = items[i].getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result as string; + if (dataUrl) { + addPastedImage({ + id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + dataUrl, + name: file.name || `image-${Date.now()}.png`, + size: file.size, + }); + } + }; + reader.readAsDataURL(file); + } + } + } + + // If there was an image, don't process text + if (hasImage) return; + + // For text content, prevent default and insert plain text only + e.preventDefault(); + + // Get plain text from clipboard + const plainText = clipboardData.getData("text/plain"); + if (!plainText) return; + + // Insert plain text at cursor position + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0); + range.deleteContents(); + + const textNode = document.createTextNode(plainText); + range.insertNode(textNode); + + // Move cursor to end of inserted text + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection.removeAllRanges(); + selection.addRange(range); + + // Trigger input change handler to update state + handleInputChange(); + }, + [handleInputChange, addPastedImage], + ); + + // Handle file mention selection + const handleFileMentionSelect = useCallback( + (file: FileEntry) => { + if (!inputRef.current) return; + + isUpdatingContentRef.current = true; + hideMention(); + const mentionRange = getComposerTextRange( + inputRef.current, + mentionState.startIndex, + mentionState.startIndex + mentionState.search.length + 1, + ); + mentionRange.deleteContents(); + + const mentionSpan = document.createElement("span"); + mentionSpan.setAttribute("data-mention", "true"); + mentionSpan.setAttribute("data-mention-name", file.name); + mentionSpan.setAttribute("data-mention-path", file.path); + mentionSpan.setAttribute("contenteditable", "false"); + mentionSpan.title = file.path; + mentionSpan.className = + "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-primary/10 px-1.5 py-0.5 leading-row text-primary align-baseline select-none"; + mentionSpan.textContent = file.name; + + const trailingSpace = document.createTextNode(" "); + const fragment = document.createDocumentFragment(); + fragment.append(mentionSpan, trailingSpace); + mentionRange.insertNode(fragment); + + const selection = window.getSelection(); + if (selection) { + const caretRange = document.createRange(); + caretRange.setStart(trailingSpace, trailingSpace.length); + caretRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(caretRange); + } + + inputRef.current.focus(); + syncInputFromEditable(); + isUpdatingContentRef.current = false; + }, + [ + hideMention, + mentionState.search.length, + mentionState.startIndex, + syncInputFromEditable, + ], + ); + + // Handle slash command selection + const handleSlashCommandSelect = useCallback( + (command: SlashCommand) => { + if (!inputRef.current) return; + + isUpdatingContentRef.current = true; + const { startIndex, endIndex } = slashCommandRangeRef.current; + hideSlashCommands(); + const commandRange = getComposerTextRange( + inputRef.current, + startIndex, + endIndex, + ); + commandRange.deleteContents(); + + const commandSpan = document.createElement("span"); + commandSpan.setAttribute("data-slash-command", "true"); + commandSpan.setAttribute("data-slash-command-name", command.name); + commandSpan.setAttribute("contenteditable", "false"); + commandSpan.title = command.description || `/${command.name}`; + commandSpan.className = + "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-accent/70 px-1.5 py-0.5 leading-row text-foreground align-baseline select-none"; + commandSpan.textContent = `/${command.name}`; + + const trailingSpace = document.createTextNode(" "); + const fragment = document.createDocumentFragment(); + fragment.append(commandSpan, trailingSpace); + commandRange.insertNode(fragment); + + const selection = window.getSelection(); + if (selection) { + const caretRange = document.createRange(); + caretRange.setStart(trailingSpace, trailingSpace.length); + caretRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(caretRange); + } + + inputRef.current.focus(); + syncInputFromEditable(); + isUpdatingContentRef.current = false; + }, + [hideSlashCommands, syncInputFromEditable], + ); + + const handleSendMessage = async () => { + const currentInput = inputValueRef.current; + const currentImages = pastedImages; + const hasContent = currentInput.trim() || currentImages.length > 0; + if (!hasContent || !isInputEnabled) return; + + // Clear input and images immediately after send is triggered + setInput(""); + setHasInputText(false); + clearPastedImages(); + if (inputRef.current) { + inputRef.current.innerHTML = ""; + } + + // Convert pasted images to ImageContent format + const images: ImageContent[] = currentImages.map((img) => ({ + data: img.dataUrl.split(",")[1] || img.dataUrl, + mediaType: img.dataUrl.split(";")[0]?.split(":")[1] || "image/png", + })); + + // Send the captured message with images + await onSendMessage(currentInput, images); + }; + + const focusInput = useCallback(() => inputRef.current?.focus(), []); + const { + interimTranscript, + isListening, + isMacDevBlocked: isMacDevSpeechRecognitionBlocked, + isSupported: isSpeechRecognitionSupported, + toggle: toggleVoiceInput, + } = useVoiceInput({ + enabled: isInputEnabled, + insertText: insertTextAtCursor, + focusInput, + }); + + const hasSlashCommands = availableSlashCommands.length > 0; + const isInitialPresentation = presentation === "initial"; + const inputPlaceholder = isInputEnabled + ? isInitialPresentation + ? "What do you want to create?" + : hasSlashCommands + ? "Ask anything... (@ files, / commands)" + : "Ask anything... (@ to mention files)" + : "Configure API key to enable Agent..."; + + useEffect(() => { + if (!autoFocus || !isActiveSurface) return; + + const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [autoFocus, isActiveSurface]); + + return ( + + + {pastedImages.length > 0 && ( + + {pastedImages.map((image) => ( + + + {image.name} + + + {image.name} + + + removePastedImage(image.id)} + aria-label={`Remove ${image.name}`} + > + + + + + ))} + + )} + + setIsComposerFocused(true)} + onBlur={() => setIsComposerFocused(false)} + onPaste={handlePaste} + data-placeholder={inputPlaceholder} + className={cn( + isInitialPresentation && + "max-h-48 min-h-28 overflow-y-auto px-4 py-4 ui-text-base", + )} + role="textbox" + aria-multiline={!isInitialPresentation} + aria-label="Message input" + tabIndex={isInputEnabled ? 0 : -1} + /> + + +
+ { + if (open) { + closeInlineMenus(); + } + setIsContextDropdownOpen(open); + }} + /> +
+ + {queueCount > 0 && ( + + + {queueCount} + + )} + +
+ {hasSlashCommands && ( + + )} + + + void changeSessionConfigOption(optionId, value) + } + onSelectSkill={insertSkillAtCursor} + onBeforeOpen={closeInlineMenus} + /> + + + + + + +
+
+ + {selectedContextItems.length > 0 ? ( + + {selectedContextItems.map((item) => ( + { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const chips = Array.from( + event.currentTarget.parentElement?.querySelectorAll( + "[data-context-chip]", + ) || [], + ); + const currentIndex = chips.indexOf(event.currentTarget); + const nextIndex = + event.key === "ArrowLeft" + ? Math.max(currentIndex - 1, 0) + : Math.min(currentIndex + 1, chips.length - 1); + chips[nextIndex]?.focus(); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + const chipContainer = event.currentTarget.parentElement; + const chips = Array.from( + chipContainer?.querySelectorAll( + "[data-context-chip]", + ) || [], + ); + const currentIndex = chips.indexOf(event.currentTarget); + const nextFocusIndex = Math.max( + 0, + Math.min(currentIndex, chips.length - 2), + ); + if (item.type === "buffer") { + toggleBufferSelection(item.id); + } else { + toggleFileSelection(item.id); + } + requestAnimationFrame(() => { + const nextChips = Array.from( + chipContainer?.querySelectorAll( + "[data-context-chip]", + ) || [], + ); + const nextChip = nextChips[nextFocusIndex]; + if (nextChip) { + nextChip.focus(); + return; + } + contextTriggerRef.current?.focus(); + }); + } + }} + > + + {item.type === "buffer" ? ( + item.databaseType ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {item.name} + {item.type === "buffer" && item.isDirty ? ( + + ) : null} + + + + { + if (item.type === "buffer") { + toggleBufferSelection(item.id); + } else { + toggleFileSelection(item.id); + } + }} + aria-label={`Remove ${item.name} from context`} + tabIndex={0} + > + + + + + ))} + + ) : null} +
+ + {(isActiveSurface || isComposerFocused) && mentionState.active && ( + { + visibleMentionFilesRef.current = files; + }} + /> + )} + + {slashCommandState.active && ( + { + handleSlashCommandSelect(command); + }} + onClose={hideSlashCommands} + /> + )} +
+ ); }); export default AIChatInputBar; diff --git a/src/features/ai/types/ai-chat.types.ts b/src/features/ai/types/ai-chat.types.ts index 45b6ced54..fd7aeab54 100644 --- a/src/features/ai/types/ai-chat.types.ts +++ b/src/features/ai/types/ai-chat.types.ts @@ -1,7 +1,7 @@ import type { - AcpToolCallLocation, - AcpToolCallStatus, - AcpToolKind, + AcpToolCallLocation, + AcpToolCallStatus, + AcpToolKind, } from "@/features/ai/types/acp.types"; import type { ChatFollowUpAction } from "@/features/ai/lib/follow-up-actions"; import type { FileEntry } from "@/features/file-system/types/app.types"; @@ -13,102 +13,102 @@ export type ChatMode = "chat" | "plan"; export type AssistantResponsePhase = "waiting" | "thinking"; export interface ToolCall { - id?: string; - name: string; - input: any; - output?: any; - error?: string; - kind?: AcpToolKind; - status?: AcpToolCallStatus; - locations?: AcpToolCallLocation[]; - timestamp: Date; - isComplete?: boolean; + id?: string; + name: string; + input: any; + output?: any; + error?: string; + kind?: AcpToolKind; + status?: AcpToolCallStatus; + locations?: AcpToolCallLocation[]; + timestamp: Date; + isComplete?: boolean; } -interface ImageContent { - data: string; - mediaType: string; +export interface ImageContent { + data: string; + mediaType: string; } interface ResourceContent { - uri: string; - name: string | null; + uri: string; + name: string | null; } export interface Message { - id: string; - content: string; - role: "user" | "assistant" | "system"; - timestamp: Date; - isStreaming?: boolean; - responsePhase?: AssistantResponsePhase; - isToolUse?: boolean; - toolName?: string; - toolCalls?: ToolCall[]; - images?: ImageContent[]; - resources?: ResourceContent[]; - ui?: GenerativeUIComponent[]; - followUpActions?: ChatFollowUpAction[]; + id: string; + content: string; + role: "user" | "assistant" | "system"; + timestamp: Date; + isStreaming?: boolean; + responsePhase?: AssistantResponsePhase; + isToolUse?: boolean; + toolName?: string; + toolCalls?: ToolCall[]; + images?: ImageContent[]; + resources?: ResourceContent[]; + ui?: GenerativeUIComponent[]; + followUpActions?: ChatFollowUpAction[]; } // Agent types for AI chat export type AgentType = string; export interface Chat { - id: string; - title: string; - messages: Message[]; - createdAt: Date; - lastMessageAt: Date; - agentId: AgentType; // Which agent this chat uses - acpSessionId?: string | null; - workspacePath?: string | null; - providerId?: string | null; - modelId?: string | null; - branch?: string | null; - isPinned?: boolean; - archivedAt?: Date | null; + id: string; + title: string; + messages: Message[]; + createdAt: Date; + lastMessageAt: Date; + agentId: AgentType; // Which agent this chat uses + acpSessionId?: string | null; + workspacePath?: string | null; + providerId?: string | null; + modelId?: string | null; + branch?: string | null; + isPinned?: boolean; + archivedAt?: Date | null; } export interface AIChatProps { - className?: string; - surfaceId: string; - chatId?: string | null; - isActiveSurface?: boolean; - // Context from the main app - activeBuffer?: PaneContent | null; - buffers?: PaneContent[]; - selectedFiles?: string[]; - allProjectFiles?: FileEntry[]; - mode: "chat"; - // Buffer update functions - onApplyCode?: (code: string) => void; + className?: string; + surfaceId: string; + chatId?: string | null; + isActiveSurface?: boolean; + // Context from the main app + activeBuffer?: PaneContent | null; + buffers?: PaneContent[]; + selectedFiles?: string[]; + allProjectFiles?: FileEntry[]; + mode: "chat"; + // Buffer update functions + onApplyCode?: (code: string) => void; } export interface MarkdownRendererProps { - content: string; - onApplyCode?: (code: string) => void; - chatId?: string | null; + content: string; + onApplyCode?: (code: string) => void; + chatId?: string | null; } export interface AIChatInputBarProps { - surfaceId: string; - buffers: PaneContent[]; - allProjectFiles: FileEntry[]; - currentAgentId: AgentType; - isTyping: boolean; - streamingMessageId: string | null; - queueCount: number; - selectedBufferIds: Set; - selectedFilesPaths: Set; - onToggleBufferSelection: (bufferId: string) => void; - onToggleFileSelection: (filePath: string) => void; - onSetSelectedBufferIds: (bufferIds: Set) => void; - onSetSelectedFilesPaths: (filePaths: Set) => void; - isActiveSurface?: boolean; - presentation?: "default" | "initial"; - autoFocus?: boolean; - onAgentChange?: (agentId: AgentType) => void; - onSendMessage: (message: string) => Promise; - onStopStreaming: () => void; + surfaceId: string; + buffers: PaneContent[]; + allProjectFiles: FileEntry[]; + currentAgentId: AgentType; + isTyping: boolean; + streamingMessageId: string | null; + queueCount: number; + selectedBufferIds: Set; + selectedFilesPaths: Set; + onToggleBufferSelection: (bufferId: string) => void; + onToggleFileSelection: (filePath: string) => void; + onSetSelectedBufferIds: (bufferIds: Set) => void; + onSetSelectedFilesPaths: (filePaths: Set) => void; + isActiveSurface?: boolean; + presentation?: "default" | "initial"; + autoFocus?: boolean; + onAgentChange?: (agentId: AgentType) => void; + onSendMessage: (message: string, images?: ImageContent[]) => Promise; + onStopStreaming: () => void; } diff --git a/src/features/editor/lib/wasm-parser/loader.ts b/src/features/editor/lib/wasm-parser/loader.ts index fba44f2da..4d418a3da 100644 --- a/src/features/editor/lib/wasm-parser/loader.ts +++ b/src/features/editor/lib/wasm-parser/loader.ts @@ -8,811 +8,964 @@ import treeSitterRuntimeWasmUrl from "web-tree-sitter/web-tree-sitter.wasm?url"; import { logger } from "../../utils/logger"; import { indexedDBParserCache } from "./cache-indexeddb"; import { fetchHighlightQuery } from "./extension-assets"; -import type { LoadedParser, ParserConfig } from "../../types/wasm-parser/wasm-parser.types"; +import type { + LoadedParser, + ParserConfig, +} from "../../types/wasm-parser/wasm-parser.types"; + +async function computeSha256(bytes: Uint8Array): Promise { + const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +interface ParserManifest { + version?: string; + name?: string; + [key: string]: unknown; +} + +async function fetchManifestVersion(wasmPath: string): Promise { + try { + const manifestUrl = wasmPath.replace(/\.wasm$/, ".json"); + const response = await fetch(manifestUrl); + if (!response.ok) return null; + const manifest = (await response.json()) as ParserManifest; + return manifest.version ?? null; + } catch { + return null; + } +} export function getTreeSitterRuntimeAssetPath(scriptName: string): string { - if (scriptName === "web-tree-sitter.wasm") { - return treeSitterRuntimeWasmUrl; - } + if (scriptName === "web-tree-sitter.wasm") { + return treeSitterRuntimeWasmUrl; + } - return `/tree-sitter/${scriptName}`; + return `/tree-sitter/${scriptName}`; } class WasmParserLoader { - private static instance: WasmParserLoader; - private initialized = false; - private parsers: Map = new Map(); - private loadingParsers: Map> = new Map(); - - private constructor() {} - - static getInstance(): WasmParserLoader { - if (!WasmParserLoader.instance) { - WasmParserLoader.instance = new WasmParserLoader(); - } - return WasmParserLoader.instance; - } - - /** - * Initialize Tree-sitter WASM - * Must be called once before loading any parsers - */ - async initialize(): Promise { - if (this.initialized) return; - - try { - await Parser.init({ - locateFile(scriptName: string) { - const assetPath = getTreeSitterRuntimeAssetPath(scriptName); - if (typeof globalThis !== "undefined" && globalThis.location?.origin) { - return new URL(assetPath, `${globalThis.location.origin}/`).toString(); - } - - return assetPath; - }, - }); - this.initialized = true; - logger.debug("WasmParser", "Tree-sitter WASM initialized"); - } catch (error) { - logger.error("WasmParser", "Failed to initialize Tree-sitter WASM", error); - throw error; - } - } - - /** - * Check if WASM is initialized and ready to use - */ - isInitialized(): boolean { - return this.initialized; - } - - /** - * Load a parser for a specific language - * Returns cached parser if already loaded - */ - async loadParser(config: ParserConfig): Promise { - const { languageId, highlightQuery } = config; - - // Check if parser is already cached - if (this.parsers.has(languageId)) { - const cached = this.parsers.get(languageId)!; - - // Update highlight query if a new one is provided and differs from the cached one - if (highlightQuery && highlightQuery !== cached.highlightQueryText) { - logger.debug("WasmParser", `Updating highlight query for ${languageId}`); - - try { - const { query, queryText: compiledQueryText } = this.compileHighlightQuery( - cached.language, - languageId, - highlightQuery, - ); - const updatedParser: LoadedParser = { - ...cached, - highlightQuery: query, - highlightQueryText: compiledQueryText, - }; - this.parsers.set(languageId, updatedParser); - - // Also update IndexedDB cache with the highlight query - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry && cachedEntry.highlightQuery !== compiledQueryText) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: compiledQueryText, - }); - } - }) - .catch(() => {}); - - return updatedParser; - } catch (error) { - // Try to fetch local highlight query as fallback before surfacing an error. - const localQuery = await this.fetchHighlightQueryText( - languageId, - config.wasmPath, - config.highlightQueryUrl, - ); - if (localQuery) { - try { - const { query, queryText: compiledQueryText } = this.compileHighlightQuery( - cached.language, - languageId, - localQuery, - ); - const updatedParser: LoadedParser = { - ...cached, - highlightQuery: query, - highlightQueryText: compiledQueryText, - }; - this.parsers.set(languageId, updatedParser); - - // Update IndexedDB cache with the correct local query - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: compiledQueryText, - }); - } - }) - .catch(() => {}); - - logger.debug("WasmParser", `Using refreshed highlight query for ${languageId}`); - return updatedParser; - } catch (localError) { - logger.error( - "WasmParser", - `Failed to create highlight query for ${languageId}:`, - error, - ); - logger.error( - "WasmParser", - `Local highlight query also failed for ${languageId}:`, - localError, - ); - } - } else { - logger.error( - "WasmParser", - `Failed to create highlight query for ${languageId}:`, - error, - ); - } - } - } - - return cached; - } - - // Return ongoing loading promise if exists - if (this.loadingParsers.has(languageId)) { - return this.loadingParsers.get(languageId)!; - } - - // Start loading parser - const loadPromise = this._loadParserInternal(config); - this.loadingParsers.set(languageId, loadPromise); - - try { - const loadedParser = await loadPromise; - this.parsers.set(languageId, loadedParser); - this.loadingParsers.delete(languageId); - return loadedParser; - } catch (error) { - this.loadingParsers.delete(languageId); - throw error; - } - } - - /** - * Fetch highlight query from parser source, CDN or local fallback. - */ - private async fetchHighlightQueryText( - languageId: string, - wasmPath?: string, - queryUrl?: string, - ): Promise { - const { query, sourceUrl } = await fetchHighlightQuery(languageId, { - wasmUrl: wasmPath, - queryUrl, - cacheMode: "no-store", - }); - if (!query) { - logger.debug("WasmParser", `No highlight query source found for ${languageId}`); - return null; - } - - logger.debug( - "WasmParser", - `Resolved highlight query for ${languageId} from ${sourceUrl || "fallback source"}`, - ); - return query; - } - - private ensureValidWasmBytes(languageId: string, wasmPath: string, wasmBytes: Uint8Array): void { - const hasWasmHeader = - wasmBytes.length >= 4 && - wasmBytes[0] === 0x00 && - wasmBytes[1] === 0x61 && - wasmBytes[2] === 0x73 && - wasmBytes[3] === 0x6d; - - if (hasWasmHeader) { - return; - } - - throw new Error( - `Invalid WASM payload for ${languageId} from ${wasmPath} (missing wasm header)`, - ); - } - - /** - * Compile highlight query with compatibility rewrites for parser/query mismatches. - */ - private compileHighlightQuery( - language: Language, - languageId: string, - queryText: string, - ): { query: Query; queryText: string } { - try { - return { - query: new Query(language, queryText), - queryText, - }; - } catch (error) { - const recovered = this.tryRecoverHighlightQuery(language, languageId, queryText, error); - if (recovered) return recovered; - throw error; - } - } - - /** - * Try to recover from unsupported nodes by removing patterns that reference them. - */ - private tryRecoverHighlightQuery( - language: Language, - languageId: string, - queryText: string, - error: unknown, - ): { query: Query; queryText: string } | null { - let rewrittenQuery = queryText; - let currentError = error; - const seenNodes = new Set(); - const seenPredicates = new Set(); - - for (let attempts = 0; attempts < 12; attempts++) { - const badNode = this.extractBadNodeName(currentError); - if (badNode && !seenNodes.has(badNode)) { - seenNodes.add(badNode); - - const nextQuery = this.rewriteIncompatibleHighlightQuery( - languageId, - rewrittenQuery, - badNode, - ); - if (nextQuery !== rewrittenQuery) { - rewrittenQuery = nextQuery; - - logger.warn( - "WasmParser", - `Applied ${languageId} highlight compatibility rewrite for missing node '${badNode}'`, - ); - - try { - return { - query: new Query(language, rewrittenQuery), - queryText: rewrittenQuery, - }; - } catch (rewriteError) { - currentError = rewriteError; - continue; - } - } - } - - const badPredicate = this.extractBadPredicateName(currentError); - if (badPredicate && !seenPredicates.has(badPredicate)) { - seenPredicates.add(badPredicate); - - const nextQuery = this.rewriteIncompatiblePredicateQuery( - languageId, - rewrittenQuery, - badPredicate, - ); - if (nextQuery !== rewrittenQuery) { - rewrittenQuery = nextQuery; - - logger.warn( - "WasmParser", - `Applied ${languageId} highlight compatibility rewrite for unsupported predicate '${badPredicate}'`, - ); - - try { - return { - query: new Query(language, rewrittenQuery), - queryText: rewrittenQuery, - }; - } catch (rewriteError) { - currentError = rewriteError; - continue; - } - } - } - - break; - } - - logger.error("WasmParser", `Highlight query rewrite failed for ${languageId}:`, currentError); - return null; - } - - private extractBadNodeName(error: unknown): string | null { - const message = - error instanceof Error ? error.message : typeof error === "string" ? error : String(error); - const match = message.match(/Bad node name '([^']+)'/); - return match?.[1] ?? null; - } - - private extractBadPredicateName(error: unknown): string | null { - const message = - error instanceof Error ? error.message : typeof error === "string" ? error : String(error); - const backtickMatch = message.match(/`([^`]+)` predicate/); - if (backtickMatch?.[1]) return backtickMatch[1]; - const quoteMatch = message.match(/predicate ['"]([^'"]+)['"]/); - return quoteMatch?.[1] ?? null; - } - - /** - * Rewrite unsupported node references so older parser WASM builds can still highlight partially. - */ - private rewriteIncompatibleHighlightQuery( - _languageId: string, - queryText: string, - badNodeName: string, - ): string { - return this.stripNodeExpressions(queryText, badNodeName); - } - - /** - * Rewrite incompatible predicate invocations (e.g. predicate arity changes across engines). - */ - private rewriteIncompatiblePredicateQuery( - _languageId: string, - queryText: string, - predicateName: string, - ): string { - return this.stripPredicateCalls(queryText, predicateName); - } - - private stripNodeExpressions(queryText: string, badNodeName: string): string { - const nodeRegex = new RegExp( - `\\(${badNodeName.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(?=[\\s)])`, - ); - const lines = queryText.split("\n"); - const output: string[] = []; - let expressionLines: string[] = []; - let depth = 0; - let inExpression = false; - - const flushExpression = () => { - if (expressionLines.length === 0) return; - const expressionText = expressionLines.join("\n"); - if (!nodeRegex.test(expressionText)) { - output.push(expressionText); - } - expressionLines = []; - }; - - for (const line of lines) { - if (!inExpression) { - if (line.trimStart().startsWith("(")) { - inExpression = true; - expressionLines = [line]; - depth = (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; - if (depth <= 0) { - flushExpression(); - inExpression = false; - depth = 0; - } - } else { - output.push(line); - } - continue; - } - - expressionLines.push(line); - depth += (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; - if (depth <= 0) { - flushExpression(); - inExpression = false; - depth = 0; - } - } - - if (inExpression) { - flushExpression(); - } - - return `${output - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd()}\n`; - } - - private stripPredicateCalls(queryText: string, predicateName: string): string { - const escapedPredicate = predicateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const predicateRegex = new RegExp(`\\(${escapedPredicate}(?=[\\s)])`, "g"); - const output: string[] = []; - let cursor = 0; - let changed = false; - let match = predicateRegex.exec(queryText); - while (match !== null) { - const start = match.index; - const end = this.findMatchingParenIndex(queryText, start); - if (end === -1) { - return queryText; - } - - output.push(queryText.slice(cursor, start)); - cursor = end + 1; - changed = true; - match = predicateRegex.exec(queryText); - } - - if (!changed) return queryText; - - output.push(queryText.slice(cursor)); - - return `${output - .join("") - .replace(/[ \t]+\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd()}\n`; - } - - private findMatchingParenIndex(text: string, startIndex: number): number { - if (startIndex < 0 || startIndex >= text.length || text[startIndex] !== "(") { - return -1; - } - - let depth = 0; - let inString = false; - let escapeNext = false; - let inComment = false; - - for (let index = startIndex; index < text.length; index++) { - const char = text[index]; - - if (inComment) { - if (char === "\n") { - inComment = false; - } - continue; - } - - if (inString) { - if (escapeNext) { - escapeNext = false; - continue; - } - if (char === "\\") { - escapeNext = true; - continue; - } - if (char === '"') { - inString = false; - } - continue; - } - - if (char === ";") { - inComment = true; - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "(") { - depth += 1; - continue; - } - - if (char === ")") { - depth -= 1; - if (depth === 0) { - return index; - } - if (depth < 0) { - return -1; - } - } - } - - return -1; - } - - private async readCachedParser( - languageId: string, - ): Promise<{ wasmBytes: Uint8Array; queryText?: string } | null> { - const cached = await indexedDBParserCache.get(languageId); - if (!cached) return null; - - let wasmBytes: Uint8Array; - if (cached.wasmData) { - wasmBytes = new Uint8Array(cached.wasmData); - logger.debug("WasmParser", `Using cached ArrayBuffer for ${languageId}`); - } else if (cached.wasmBlob) { - try { - const arrayBuffer = await cached.wasmBlob.arrayBuffer(); - wasmBytes = new Uint8Array(arrayBuffer); - logger.debug("WasmParser", `Using cached Blob for ${languageId}`); - } catch (blobError) { - logger.error( - "WasmParser", - `Failed to read cached Blob for ${languageId}, clearing cache entry`, - blobError, - ); - await indexedDBParserCache.delete(languageId); - throw new Error(`Cached parser corrupted, please reinstall ${languageId}`); - } - } else { - throw new Error(`Cache entry for ${languageId} has no WASM data`); - } - - return { - wasmBytes, - queryText: cached.highlightQuery?.trim() ? cached.highlightQuery : undefined, - }; - } - - private async _loadParserInternal(config: ParserConfig): Promise { - const { languageId, wasmPath, highlightQuery, highlightQueryUrl } = config; - - try { - // Ensure Tree-sitter is initialized - if (!this.initialized) { - await this.initialize(); - } - - // Check if this is a local/bundled parser (not a remote URL) - // Local parsers should always use the local file, not IndexedDB cache - const isLocalParser = wasmPath.startsWith("/tree-sitter/"); - - // Try to load from IndexedDB cache first (skip for local parsers) - const cached = isLocalParser ? null : await this.readCachedParser(languageId); - - let wasmBytes: Uint8Array; - let queryText = highlightQuery; - - if (cached) { - logger.debug("WasmParser", `Loading ${languageId} from IndexedDB cache`); - wasmBytes = cached.wasmBytes; - - // Use cached highlight query if available and not empty - // Prefer cached query over passed parameter if cached is non-empty - if (cached.queryText) { - queryText = cached.queryText; - logger.debug("WasmParser", `Using cached highlight query for ${languageId}`); - } else if (!queryText) { - logger.warn( - "WasmParser", - `No highlight query available for ${languageId} - syntax highlighting will be disabled`, - ); - } - } else { - logger.debug("WasmParser", `Loading parser for ${languageId} from ${wasmPath}`); - - // Check if wasmPath is a URL (starts with http:// or https://) - const isRemoteUrl = wasmPath.startsWith("http://") || wasmPath.startsWith("https://"); - - if (isRemoteUrl) { - // Download from remote URL - logger.debug("WasmParser", `Downloading ${languageId} from remote: ${wasmPath}`); - - const response = await fetch(wasmPath); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - wasmBytes = new Uint8Array(arrayBuffer); - this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); - - // Cache for future use - try { - await indexedDBParserCache.set({ - languageId, - wasmBlob: new Blob([wasmBytes as BlobPart]), // Legacy compatibility - wasmData: wasmBytes.buffer as ArrayBuffer, // Preferred: ArrayBuffer - highlightQuery: queryText || "", - version: "1.0.0", // TODO: Get version from manifest - checksum: "", // TODO: Calculate checksum - downloadedAt: Date.now(), - lastUsedAt: Date.now(), - size: wasmBytes.byteLength, - sourceUrl: wasmPath, - }); - logger.debug("WasmParser", `Cached ${languageId} to IndexedDB`); - } catch (cacheError) { - logger.warn("WasmParser", `Failed to cache ${languageId}:`, cacheError); - // Continue even if caching fails - } - } else { - // Load from local path - logger.debug("WasmParser", `Loading ${languageId} from local path: ${wasmPath}`); - - try { - const response = await fetch(wasmPath); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - wasmBytes = new Uint8Array(arrayBuffer); - this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); - } catch (localError) { - const cachedFallback = await this.readCachedParser(languageId); - if (!cachedFallback) { - throw localError; - } - - logger.debug("WasmParser", `Falling back to installed parser cache for ${languageId}`); - wasmBytes = cachedFallback.wasmBytes; - queryText = cachedFallback.queryText || queryText; - } - - // Also fetch highlight query from local path if not provided - if (!queryText) { - const localQuery = await this.fetchHighlightQueryText( - languageId, - wasmPath, - highlightQueryUrl, - ); - if (localQuery) { - queryText = localQuery; - logger.debug("WasmParser", `Loaded highlight query for ${languageId}`); - } - } - - // Cache local parsers to IndexedDB for future use - try { - await indexedDBParserCache.set({ - languageId, - wasmBlob: new Blob([wasmBytes as BlobPart]), - wasmData: wasmBytes.buffer as ArrayBuffer, - highlightQuery: queryText || "", - version: "1.0.0", - checksum: "", - downloadedAt: Date.now(), - lastUsedAt: Date.now(), - size: wasmBytes.byteLength, - sourceUrl: wasmPath, - }); - logger.debug("WasmParser", `Cached ${languageId} to IndexedDB (from local path)`); - } catch (cacheError) { - logger.warn("WasmParser", `Failed to cache ${languageId}:`, cacheError); - } - } - } - - // Create parser instance - const parser = new Parser(); - - // Load language from WASM bytes - const language = await Language.load(wasmBytes); - parser.setLanguage(language); - - // Compile highlight query if provided - let query: Query | undefined; - if (queryText) { - const sourceQueryText = queryText; - try { - const compiled = this.compileHighlightQuery(language, languageId, queryText); - query = compiled.query; - queryText = compiled.queryText; - - if (queryText !== sourceQueryText) { - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: queryText || "", - }); - } - }) - .catch(() => {}); - } - } catch (error) { - logger.warn("WasmParser", `Failed to compile highlight query for ${languageId}`, error); - // Try to fetch local highlight query as fallback - const localQuery = await this.fetchHighlightQueryText( - languageId, - wasmPath, - highlightQueryUrl, - ); - if (localQuery && localQuery !== queryText) { - try { - const compiled = this.compileHighlightQuery(language, languageId, localQuery); - query = compiled.query; - queryText = compiled.queryText; - const resolvedQueryText = compiled.queryText; - logger.debug("WasmParser", `Using highlight query fallback for ${languageId}`); - // Update IndexedDB cache with the correct local query - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: resolvedQueryText, - }); - } - }) - .catch(() => {}); - } catch (localError) { - logger.error( - "WasmParser", - `Local highlight query also failed for ${languageId}:`, - localError, - ); - } - } - } - } - - logger.debug("WasmParser", `Successfully loaded parser for ${languageId}`); - - return { - parser, - language, - highlightQuery: query, - highlightQueryText: queryText || undefined, - languageId, - }; - } catch (error) { - logger.error("WasmParser", `Failed to load parser for ${languageId}`, error); - throw new Error(`Failed to load parser for ${languageId}: ${error}`); - } - } - - /** - * Check if a parser is loaded - */ - isLoaded(languageId: string): boolean { - return this.parsers.has(languageId); - } - - /** - * Get a loaded parser (throws if not loaded) - */ - getParser(languageId: string): LoadedParser { - const parser = this.parsers.get(languageId); - if (!parser) { - throw new Error(`Parser for ${languageId} is not loaded`); - } - return parser; - } - - /** - * Unload a parser to free memory - */ - unloadParser(languageId: string): void { - const parser = this.parsers.get(languageId); - if (parser) { - parser.parser.delete(); - this.parsers.delete(languageId); - logger.debug("WasmParser", `Unloaded parser for ${languageId}`); - } - } - - /** - * Clear all loaded parsers - */ - clear(): void { - for (const [languageId, parser] of this.parsers) { - parser.parser.delete(); - logger.debug("WasmParser", `Unloaded parser for ${languageId}`); - } - this.parsers.clear(); - this.loadingParsers.clear(); - } - - /** - * Get list of loaded parser language IDs - */ - getLoadedLanguages(): string[] { - return Array.from(this.parsers.keys()); - } + private static instance: WasmParserLoader; + private initialized = false; + private parsers: Map = new Map(); + private loadingParsers: Map> = new Map(); + + private constructor() {} + + static getInstance(): WasmParserLoader { + if (!WasmParserLoader.instance) { + WasmParserLoader.instance = new WasmParserLoader(); + } + return WasmParserLoader.instance; + } + + /** + * Initialize Tree-sitter WASM + * Must be called once before loading any parsers + */ + async initialize(): Promise { + if (this.initialized) return; + + try { + await Parser.init({ + locateFile(scriptName: string) { + const assetPath = getTreeSitterRuntimeAssetPath(scriptName); + if ( + typeof globalThis !== "undefined" && + globalThis.location?.origin + ) { + return new URL( + assetPath, + `${globalThis.location.origin}/`, + ).toString(); + } + + return assetPath; + }, + }); + this.initialized = true; + logger.debug("WasmParser", "Tree-sitter WASM initialized"); + } catch (error) { + logger.error( + "WasmParser", + "Failed to initialize Tree-sitter WASM", + error, + ); + throw error; + } + } + + /** + * Check if WASM is initialized and ready to use + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Load a parser for a specific language + * Returns cached parser if already loaded + */ + async loadParser(config: ParserConfig): Promise { + const { languageId, highlightQuery } = config; + + // Check if parser is already cached + if (this.parsers.has(languageId)) { + const cached = this.parsers.get(languageId)!; + + // Update highlight query if a new one is provided and differs from the cached one + if (highlightQuery && highlightQuery !== cached.highlightQueryText) { + logger.debug( + "WasmParser", + `Updating highlight query for ${languageId}`, + ); + + try { + const { query, queryText: compiledQueryText } = + this.compileHighlightQuery( + cached.language, + languageId, + highlightQuery, + ); + const updatedParser: LoadedParser = { + ...cached, + highlightQuery: query, + highlightQueryText: compiledQueryText, + }; + this.parsers.set(languageId, updatedParser); + + // Also update IndexedDB cache with the highlight query + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if ( + cachedEntry && + cachedEntry.highlightQuery !== compiledQueryText + ) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: compiledQueryText, + }); + } + }) + .catch(() => {}); + + return updatedParser; + } catch (error) { + // Try to fetch local highlight query as fallback before surfacing an error. + const localQuery = await this.fetchHighlightQueryText( + languageId, + config.wasmPath, + config.highlightQueryUrl, + ); + if (localQuery) { + try { + const { query, queryText: compiledQueryText } = + this.compileHighlightQuery( + cached.language, + languageId, + localQuery, + ); + const updatedParser: LoadedParser = { + ...cached, + highlightQuery: query, + highlightQueryText: compiledQueryText, + }; + this.parsers.set(languageId, updatedParser); + + // Update IndexedDB cache with the correct local query + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: compiledQueryText, + }); + } + }) + .catch(() => {}); + + logger.debug( + "WasmParser", + `Using refreshed highlight query for ${languageId}`, + ); + return updatedParser; + } catch (localError) { + logger.error( + "WasmParser", + `Failed to create highlight query for ${languageId}:`, + error, + ); + logger.error( + "WasmParser", + `Local highlight query also failed for ${languageId}:`, + localError, + ); + } + } else { + logger.error( + "WasmParser", + `Failed to create highlight query for ${languageId}:`, + error, + ); + } + } + } + + return cached; + } + + // Return ongoing loading promise if exists + if (this.loadingParsers.has(languageId)) { + return this.loadingParsers.get(languageId)!; + } + + // Start loading parser + const loadPromise = this._loadParserInternal(config); + this.loadingParsers.set(languageId, loadPromise); + + try { + const loadedParser = await loadPromise; + this.parsers.set(languageId, loadedParser); + this.loadingParsers.delete(languageId); + return loadedParser; + } catch (error) { + this.loadingParsers.delete(languageId); + throw error; + } + } + + /** + * Fetch highlight query from parser source, CDN or local fallback. + */ + private async fetchHighlightQueryText( + languageId: string, + wasmPath?: string, + queryUrl?: string, + ): Promise { + const { query, sourceUrl } = await fetchHighlightQuery(languageId, { + wasmUrl: wasmPath, + queryUrl, + cacheMode: "no-store", + }); + if (!query) { + logger.debug( + "WasmParser", + `No highlight query source found for ${languageId}`, + ); + return null; + } + + logger.debug( + "WasmParser", + `Resolved highlight query for ${languageId} from ${sourceUrl || "fallback source"}`, + ); + return query; + } + + private ensureValidWasmBytes( + languageId: string, + wasmPath: string, + wasmBytes: Uint8Array, + ): void { + const hasWasmHeader = + wasmBytes.length >= 4 && + wasmBytes[0] === 0x00 && + wasmBytes[1] === 0x61 && + wasmBytes[2] === 0x73 && + wasmBytes[3] === 0x6d; + + if (hasWasmHeader) { + return; + } + + throw new Error( + `Invalid WASM payload for ${languageId} from ${wasmPath} (missing wasm header)`, + ); + } + + /** + * Compile highlight query with compatibility rewrites for parser/query mismatches. + */ + private compileHighlightQuery( + language: Language, + languageId: string, + queryText: string, + ): { query: Query; queryText: string } { + try { + return { + query: new Query(language, queryText), + queryText, + }; + } catch (error) { + const recovered = this.tryRecoverHighlightQuery( + language, + languageId, + queryText, + error, + ); + if (recovered) return recovered; + throw error; + } + } + + /** + * Try to recover from unsupported nodes by removing patterns that reference them. + */ + private tryRecoverHighlightQuery( + language: Language, + languageId: string, + queryText: string, + error: unknown, + ): { query: Query; queryText: string } | null { + let rewrittenQuery = queryText; + let currentError = error; + const seenNodes = new Set(); + const seenPredicates = new Set(); + + for (let attempts = 0; attempts < 12; attempts++) { + const badNode = this.extractBadNodeName(currentError); + if (badNode && !seenNodes.has(badNode)) { + seenNodes.add(badNode); + + const nextQuery = this.rewriteIncompatibleHighlightQuery( + languageId, + rewrittenQuery, + badNode, + ); + if (nextQuery !== rewrittenQuery) { + rewrittenQuery = nextQuery; + + logger.warn( + "WasmParser", + `Applied ${languageId} highlight compatibility rewrite for missing node '${badNode}'`, + ); + + try { + return { + query: new Query(language, rewrittenQuery), + queryText: rewrittenQuery, + }; + } catch (rewriteError) { + currentError = rewriteError; + continue; + } + } + } + + const badPredicate = this.extractBadPredicateName(currentError); + if (badPredicate && !seenPredicates.has(badPredicate)) { + seenPredicates.add(badPredicate); + + const nextQuery = this.rewriteIncompatiblePredicateQuery( + languageId, + rewrittenQuery, + badPredicate, + ); + if (nextQuery !== rewrittenQuery) { + rewrittenQuery = nextQuery; + + logger.warn( + "WasmParser", + `Applied ${languageId} highlight compatibility rewrite for unsupported predicate '${badPredicate}'`, + ); + + try { + return { + query: new Query(language, rewrittenQuery), + queryText: rewrittenQuery, + }; + } catch (rewriteError) { + currentError = rewriteError; + continue; + } + } + } + + break; + } + + logger.error( + "WasmParser", + `Highlight query rewrite failed for ${languageId}:`, + currentError, + ); + return null; + } + + private extractBadNodeName(error: unknown): string | null { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : String(error); + const match = message.match(/Bad node name '([^']+)'/); + return match?.[1] ?? null; + } + + private extractBadPredicateName(error: unknown): string | null { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : String(error); + const backtickMatch = message.match(/`([^`]+)` predicate/); + if (backtickMatch?.[1]) return backtickMatch[1]; + const quoteMatch = message.match(/predicate ['"]([^'"]+)['"]/); + return quoteMatch?.[1] ?? null; + } + + /** + * Rewrite unsupported node references so older parser WASM builds can still highlight partially. + */ + private rewriteIncompatibleHighlightQuery( + _languageId: string, + queryText: string, + badNodeName: string, + ): string { + return this.stripNodeExpressions(queryText, badNodeName); + } + + /** + * Rewrite incompatible predicate invocations (e.g. predicate arity changes across engines). + */ + private rewriteIncompatiblePredicateQuery( + _languageId: string, + queryText: string, + predicateName: string, + ): string { + return this.stripPredicateCalls(queryText, predicateName); + } + + private stripNodeExpressions(queryText: string, badNodeName: string): string { + const nodeRegex = new RegExp( + `\\(${badNodeName.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(?=[\\s)])`, + ); + const lines = queryText.split("\n"); + const output: string[] = []; + let expressionLines: string[] = []; + let depth = 0; + let inExpression = false; + + const flushExpression = () => { + if (expressionLines.length === 0) return; + const expressionText = expressionLines.join("\n"); + if (!nodeRegex.test(expressionText)) { + output.push(expressionText); + } + expressionLines = []; + }; + + for (const line of lines) { + if (!inExpression) { + if (line.trimStart().startsWith("(")) { + inExpression = true; + expressionLines = [line]; + depth = + (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; + if (depth <= 0) { + flushExpression(); + inExpression = false; + depth = 0; + } + } else { + output.push(line); + } + continue; + } + + expressionLines.push(line); + depth += + (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; + if (depth <= 0) { + flushExpression(); + inExpression = false; + depth = 0; + } + } + + if (inExpression) { + flushExpression(); + } + + return `${output + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`; + } + + private stripPredicateCalls( + queryText: string, + predicateName: string, + ): string { + const escapedPredicate = predicateName.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + const predicateRegex = new RegExp(`\\(${escapedPredicate}(?=[\\s)])`, "g"); + const output: string[] = []; + let cursor = 0; + let changed = false; + let match = predicateRegex.exec(queryText); + while (match !== null) { + const start = match.index; + const end = this.findMatchingParenIndex(queryText, start); + if (end === -1) { + return queryText; + } + + output.push(queryText.slice(cursor, start)); + cursor = end + 1; + changed = true; + match = predicateRegex.exec(queryText); + } + + if (!changed) return queryText; + + output.push(queryText.slice(cursor)); + + return `${output + .join("") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`; + } + + private findMatchingParenIndex(text: string, startIndex: number): number { + if ( + startIndex < 0 || + startIndex >= text.length || + text[startIndex] !== "(" + ) { + return -1; + } + + let depth = 0; + let inString = false; + let escapeNext = false; + let inComment = false; + + for (let index = startIndex; index < text.length; index++) { + const char = text[index]; + + if (inComment) { + if (char === "\n") { + inComment = false; + } + continue; + } + + if (inString) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (char === "\\") { + escapeNext = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + if (char === ";") { + inComment = true; + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "(") { + depth += 1; + continue; + } + + if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + if (depth < 0) { + return -1; + } + } + } + + return -1; + } + + private async readCachedParser( + languageId: string, + ): Promise<{ wasmBytes: Uint8Array; queryText?: string } | null> { + const cached = await indexedDBParserCache.get(languageId); + if (!cached) return null; + + let wasmBytes: Uint8Array; + if (cached.wasmData) { + wasmBytes = new Uint8Array(cached.wasmData); + logger.debug("WasmParser", `Using cached ArrayBuffer for ${languageId}`); + } else if (cached.wasmBlob) { + try { + const arrayBuffer = await cached.wasmBlob.arrayBuffer(); + wasmBytes = new Uint8Array(arrayBuffer); + logger.debug("WasmParser", `Using cached Blob for ${languageId}`); + } catch (blobError) { + logger.error( + "WasmParser", + `Failed to read cached Blob for ${languageId}, clearing cache entry`, + blobError, + ); + await indexedDBParserCache.delete(languageId); + throw new Error( + `Cached parser corrupted, please reinstall ${languageId}`, + ); + } + } else { + throw new Error(`Cache entry for ${languageId} has no WASM data`); + } + + return { + wasmBytes, + queryText: cached.highlightQuery?.trim() + ? cached.highlightQuery + : undefined, + }; + } + + private async _loadParserInternal( + config: ParserConfig, + ): Promise { + const { languageId, wasmPath, highlightQuery, highlightQueryUrl } = config; + + try { + // Ensure Tree-sitter is initialized + if (!this.initialized) { + await this.initialize(); + } + + // Check if this is a local/bundled parser (not a remote URL) + // Local parsers should always use the local file, not IndexedDB cache + const isLocalParser = wasmPath.startsWith("/tree-sitter/"); + + // Try to load from IndexedDB cache first (skip for local parsers) + const cached = isLocalParser + ? null + : await this.readCachedParser(languageId); + + let wasmBytes: Uint8Array; + let queryText = highlightQuery; + + if (cached) { + logger.debug( + "WasmParser", + `Loading ${languageId} from IndexedDB cache`, + ); + wasmBytes = cached.wasmBytes; + + // Use cached highlight query if available and not empty + // Prefer cached query over passed parameter if cached is non-empty + if (cached.queryText) { + queryText = cached.queryText; + logger.debug( + "WasmParser", + `Using cached highlight query for ${languageId}`, + ); + } else if (!queryText) { + logger.warn( + "WasmParser", + `No highlight query available for ${languageId} - syntax highlighting will be disabled`, + ); + } + } else { + logger.debug( + "WasmParser", + `Loading parser for ${languageId} from ${wasmPath}`, + ); + + // Check if wasmPath is a URL (starts with http:// or https://) + const isRemoteUrl = + wasmPath.startsWith("http://") || wasmPath.startsWith("https://"); + + if (isRemoteUrl) { + // Download from remote URL + logger.debug( + "WasmParser", + `Downloading ${languageId} from remote: ${wasmPath}`, + ); + + const response = await fetch(wasmPath); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + wasmBytes = new Uint8Array(arrayBuffer); + this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); + + // Cache for future use + try { + const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; + const checksum = await computeSha256(wasmBytes); + await indexedDBParserCache.set({ + languageId, + wasmBlob: new Blob([wasmBytes as BlobPart]), // Legacy compatibility + wasmData: wasmBytes.buffer as ArrayBuffer, // Preferred: ArrayBuffer + highlightQuery: queryText || "", + version, + checksum, + downloadedAt: Date.now(), + lastUsedAt: Date.now(), + size: wasmBytes.byteLength, + sourceUrl: wasmPath, + }); + logger.debug("WasmParser", `Cached ${languageId} to IndexedDB`); + } catch (cacheError) { + logger.warn( + "WasmParser", + `Failed to cache ${languageId}:`, + cacheError, + ); + // Continue even if caching fails + } + } else { + // Load from local path + logger.debug( + "WasmParser", + `Loading ${languageId} from local path: ${wasmPath}`, + ); + + try { + const response = await fetch(wasmPath); + if (!response.ok) { + throw new Error( + `HTTP ${response.status}: ${response.statusText}`, + ); + } + + const arrayBuffer = await response.arrayBuffer(); + wasmBytes = new Uint8Array(arrayBuffer); + this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); + } catch (localError) { + const cachedFallback = await this.readCachedParser(languageId); + if (!cachedFallback) { + throw localError; + } + + logger.debug( + "WasmParser", + `Falling back to installed parser cache for ${languageId}`, + ); + wasmBytes = cachedFallback.wasmBytes; + queryText = cachedFallback.queryText || queryText; + } + + // Also fetch highlight query from local path if not provided + if (!queryText) { + const localQuery = await this.fetchHighlightQueryText( + languageId, + wasmPath, + highlightQueryUrl, + ); + if (localQuery) { + queryText = localQuery; + logger.debug( + "WasmParser", + `Loaded highlight query for ${languageId}`, + ); + } + } + + // Cache local parsers to IndexedDB for future use + try { + const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; + const checksum = await computeSha256(wasmBytes); + await indexedDBParserCache.set({ + languageId, + wasmBlob: new Blob([wasmBytes as BlobPart]), + wasmData: wasmBytes.buffer as ArrayBuffer, + highlightQuery: queryText || "", + version, + checksum, + downloadedAt: Date.now(), + lastUsedAt: Date.now(), + size: wasmBytes.byteLength, + sourceUrl: wasmPath, + }); + logger.debug( + "WasmParser", + `Cached ${languageId} to IndexedDB (from local path)`, + ); + } catch (cacheError) { + logger.warn( + "WasmParser", + `Failed to cache ${languageId}:`, + cacheError, + ); + } + } + } + + // Create parser instance + const parser = new Parser(); + + // Load language from WASM bytes + const language = await Language.load(wasmBytes); + parser.setLanguage(language); + + // Compile highlight query if provided + let query: Query | undefined; + if (queryText) { + const sourceQueryText = queryText; + try { + const compiled = this.compileHighlightQuery( + language, + languageId, + queryText, + ); + query = compiled.query; + queryText = compiled.queryText; + + if (queryText !== sourceQueryText) { + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: queryText || "", + }); + } + }) + .catch(() => {}); + } + } catch (error) { + logger.warn( + "WasmParser", + `Failed to compile highlight query for ${languageId}`, + error, + ); + // Try to fetch local highlight query as fallback + const localQuery = await this.fetchHighlightQueryText( + languageId, + wasmPath, + highlightQueryUrl, + ); + if (localQuery && localQuery !== queryText) { + try { + const compiled = this.compileHighlightQuery( + language, + languageId, + localQuery, + ); + query = compiled.query; + queryText = compiled.queryText; + const resolvedQueryText = compiled.queryText; + logger.debug( + "WasmParser", + `Using highlight query fallback for ${languageId}`, + ); + // Update IndexedDB cache with the correct local query + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: resolvedQueryText, + }); + } + }) + .catch(() => {}); + } catch (localError) { + logger.error( + "WasmParser", + `Local highlight query also failed for ${languageId}:`, + localError, + ); + } + } + } + } + + logger.debug( + "WasmParser", + `Successfully loaded parser for ${languageId}`, + ); + + return { + parser, + language, + highlightQuery: query, + highlightQueryText: queryText || undefined, + languageId, + }; + } catch (error) { + logger.error( + "WasmParser", + `Failed to load parser for ${languageId}`, + error, + ); + throw new Error(`Failed to load parser for ${languageId}: ${error}`); + } + } + + /** + * Check if a parser is loaded + */ + isLoaded(languageId: string): boolean { + return this.parsers.has(languageId); + } + + /** + * Get a loaded parser (throws if not loaded) + */ + getParser(languageId: string): LoadedParser { + const parser = this.parsers.get(languageId); + if (!parser) { + throw new Error(`Parser for ${languageId} is not loaded`); + } + return parser; + } + + /** + * Unload a parser to free memory + */ + unloadParser(languageId: string): void { + const parser = this.parsers.get(languageId); + if (parser) { + parser.parser.delete(); + this.parsers.delete(languageId); + logger.debug("WasmParser", `Unloaded parser for ${languageId}`); + } + } + + /** + * Clear all loaded parsers + */ + clear(): void { + for (const [languageId, parser] of this.parsers) { + parser.parser.delete(); + logger.debug("WasmParser", `Unloaded parser for ${languageId}`); + } + this.parsers.clear(); + this.loadingParsers.clear(); + } + + /** + * Get list of loaded parser language IDs + */ + getLoadedLanguages(): string[] { + return Array.from(this.parsers.keys()); + } } export const wasmParserLoader = WasmParserLoader.getInstance(); From 6cca79955c3414cd414a4bb9d13a14e946ef9375 Mon Sep 17 00:00:00 2001 From: Ryan Bahadori Date: Sun, 20 Sep 2026 23:54:11 +0000 Subject: [PATCH 3/4] chore: trigger PR recalculation From 386d111af42f93674baf6df2d7a3369a5427ff8a Mon Sep 17 00:00:00 2001 From: Ryan Bahadori Date: Sun, 20 Sep 2026 23:57:02 +0000 Subject: [PATCH 4/4] Fix TypeScript errors and formatting - Fix processMessage call signature in ai-chat.tsx - Fix computeSha256 Uint8Array type in loader.ts - Run bun fix for formatting --- src/features/ai/components/chat/ai-chat.tsx | 2448 ++++++++-------- .../ai/components/input/chat-input-bar.tsx | 2610 ++++++++--------- src/features/ai/types/ai-chat.types.ts | 154 +- src/features/editor/lib/wasm-parser/loader.ts | 1765 ++++++----- 4 files changed, 3297 insertions(+), 3680 deletions(-) diff --git a/src/features/ai/components/chat/ai-chat.tsx b/src/features/ai/components/chat/ai-chat.tsx index 4147dfd04..c841bf49d 100644 --- a/src/features/ai/components/chat/ai-chat.tsx +++ b/src/features/ai/components/chat/ai-chat.tsx @@ -1,48 +1,38 @@ import { listen } from "@tauri-apps/api/event"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { appendChatAcpEvent, type ChatAcpEventInput } from "@/features/ai/lib/acp-event-timeline"; import { - appendChatAcpEvent, - type ChatAcpEventInput, -} from "@/features/ai/lib/acp-event-timeline"; -import { - isAcpAuthenticationError, - isAcpConfigurationError, + isAcpAuthenticationError, + isAcpConfigurationError, } from "@/features/ai/lib/acp-authentication"; import { getChatTitleFromSessionInfo } from "@/features/ai/lib/acp-session-info"; import { parseDirectAcpUiAction } from "@/features/ai/lib/acp-ui-intents"; import { - appendReferencedFiles, - loadFilesByPaths, - parseMentionsAndLoadFiles, + appendReferencedFiles, + loadFilesByPaths, + parseMentionsAndLoadFiles, } from "@/features/ai/lib/file-mentions"; import { extractFollowUpActions } from "@/features/ai/lib/follow-up-actions"; import { buildConversationHistory } from "@/features/ai/lib/conversation-history"; import { openAgentHistoryChat } from "@/features/ai/lib/open-agent-history"; import { - createToolCall, - markToolCallComplete, - updateToolCall, + createToolCall, + markToolCallComplete, + updateToolCall, } from "@/features/ai/lib/tool-call-state"; import { requestInlineEdit } from "@/features/editor/services/editor-inline-edit-service"; import { AcpStreamHandler } from "@/features/ai/services/acp-stream-handler"; import { CodexIntegrationService } from "@/features/ai/integrations/codex/codex-integration-service"; import { CODEX_INTEGRATION_ID } from "@/features/ai/integrations/integration-registry"; -import { - getChatCompletionStream, - isAcpAgent, -} from "@/features/ai/services/ai-chat-service"; +import { getChatCompletionStream, isAcpAgent } from "@/features/ai/services/ai-chat-service"; import { useAIChatStore } from "@/features/ai/stores/ai-chat.store"; import type { AcpEvent } from "@/features/ai/types/acp.types"; import type { ContextInfo } from "@/features/ai/types/ai-context.types"; -import type { - AIChatProps, - Message, - ImageContent, -} from "@/features/ai/types/ai-chat.types"; +import type { AIChatProps, Message, ImageContent } from "@/features/ai/types/ai-chat.types"; import type { ChatAcpEvent } from "@/features/ai/types/chat-ui.types"; import { - getFallbackAgentSessionTitle, - normalizeAgentSessionTitle, + getFallbackAgentSessionTitle, + normalizeAgentSessionTitle, } from "@/features/ai/utils/chat-session-title"; import { getMessageSearchMatches } from "@/features/ai/utils/message-search"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; @@ -53,1305 +43,1165 @@ import { hasProductCapability } from "@/features/window/lib/product-capabilities import { useProjectStore } from "@/features/window/stores/project.store"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/ui/empty"; import { - MessageScroller, - MessageScrollerButton, - MessageScrollerProvider, - MessageScrollerViewport, + MessageScroller, + MessageScrollerButton, + MessageScrollerProvider, + MessageScrollerViewport, } from "@/ui/message-scroller"; import { cn } from "@/utils/cn"; import { AgentStartView } from "../agent-start-view"; import { useChatActions, useChatState } from "../../hooks/use-chat-store"; import AIChatInputBar from "../input/chat-input-bar"; -import { - AcpPermissionPrompt, - type AcpPermissionRequest, -} from "./acp-permission-prompt"; +import { AcpPermissionPrompt, type AcpPermissionRequest } from "./acp-permission-prompt"; import { ChatHeader } from "./chat-header"; import { ChatMessages } from "./chat-messages"; const createMessageId = () => - globalThis.crypto?.randomUUID?.() ?? - `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const AIChat = memo(function AIChat({ - className, - surfaceId, - chatId, - isActiveSurface = true, - activeBuffer, - buffers = [], - selectedFiles = [], - allProjectFiles = [], - onApplyCode, + className, + surfaceId, + chatId, + isActiveSurface = true, + activeBuffer, + buffers = [], + selectedFiles = [], + allProjectFiles = [], + onApplyCode, }: AIChatProps) { - const rootFolderPath = useProjectStore((state) => state.rootFolderPath); - const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); - const subscription = useAuthStore((state) => state.subscription); - const enterprisePolicy = subscription?.enterprise?.policy; - const isAiChatBlockedByPolicy = Boolean( - enterprisePolicy?.managedMode && !enterprisePolicy.aiChatEnabled, - ); - - const chatState = useChatState(); - const chatActions = useChatActions(); - const { showToast } = useToast(); - - const abortControllerRef = useRef(null); - const [permissionQueue, setPermissionQueue] = useState< - AcpPermissionRequest[] - >([]); - const [acpEvents, setAcpEvents] = useState([]); - const [isMessageSearchOpen, setIsMessageSearchOpen] = useState(false); - const [messageSearchQuery, setMessageSearchQuery] = useState(""); - const [activeMessageSearchIndex, setActiveMessageSearchIndex] = useState(0); - const [selectedBufferIds, setSelectedBufferIds] = useState>( - new Set(), - ); - const [selectedFilesPaths, setSelectedFilesPaths] = useState>( - new Set(), - ); - const [isSurfaceTyping, setIsSurfaceTyping] = useState(false); - const [surfaceStreamingMessageId, setSurfaceStreamingMessageId] = useState< - string | null - >(null); - const [queueCount, setQueueCount] = useState(0); - const messageQueueRef = useRef([]); - const effectiveChatId = chatId ?? chatState.currentChatId; - const currentChat = useMemo( - () => chatState.chats.find((chat) => chat.id === effectiveChatId), - [chatState.chats, effectiveChatId], - ); - const currentAgentId = - currentChat?.agentId ?? useAIChatStore.getState().selectedAgentId; - const messageSearchMatches = useMemo( - () => - getMessageSearchMatches(currentChat?.messages ?? [], messageSearchQuery), - [currentChat?.messages, messageSearchQuery], - ); - const activeMessageSearchMatch = - messageSearchMatches[activeMessageSearchIndex] ?? null; - - const closeMessageSearch = useCallback(() => { - setIsMessageSearchOpen(false); - setMessageSearchQuery(""); - setActiveMessageSearchIndex(0); - }, []); - - const goToPreviousMessageSearchMatch = useCallback(() => { - if (messageSearchMatches.length === 0) return; - setActiveMessageSearchIndex((index) => - index === 0 ? messageSearchMatches.length - 1 : index - 1, - ); - }, [messageSearchMatches.length]); - - const goToNextMessageSearchMatch = useCallback(() => { - if (messageSearchMatches.length === 0) return; - setActiveMessageSearchIndex( - (index) => (index + 1) % messageSearchMatches.length, - ); - }, [messageSearchMatches.length]); - - useEffect(() => { - chatActions.checkApiKey(aiProviderId); - chatActions.checkAllProviderApiKeys(); - }, [ - aiProviderId, - chatActions.checkApiKey, - chatActions.checkAllProviderApiKeys, - ]); - - // Clear ACP events when switching chats - useEffect(() => { - setAcpEvents([]); - closeMessageSearch(); - setSelectedBufferIds(new Set()); - setSelectedFilesPaths(new Set()); - }, [closeMessageSearch, effectiveChatId]); - - useEffect(() => { - setActiveMessageSearchIndex(0); - }, [messageSearchQuery]); - - useEffect(() => { - if (messageSearchMatches.length === 0) { - setActiveMessageSearchIndex(0); - return; - } - - setActiveMessageSearchIndex((index) => - Math.min(index, messageSearchMatches.length - 1), - ); - }, [messageSearchMatches.length]); - - useEffect(() => { - if (!isActiveSurface || isAiChatBlockedByPolicy) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { - event.preventDefault(); - setIsMessageSearchOpen(true); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [isActiveSurface, isAiChatBlockedByPolicy]); - - useEffect(() => { - let unlisten: (() => void) | undefined; - let disposed = false; - - const setupAcpStateSync = async () => { - unlisten = await listen("acp-event", ({ payload }) => { - const store = useAIChatStore.getState(); - const { actions } = store; - - switch (payload.type) { - case "slash_commands_update": - actions.setAvailableSlashCommands(payload.commands); - break; - case "session_mode_update": - actions.setSessionModeState( - payload.modeState.currentModeId, - payload.modeState.availableModes, - ); - break; - case "current_mode_update": - actions.setCurrentModeId(payload.currentModeId); - break; - case "config_options_update": - actions.setSessionConfigOptions(payload.configOptions); - break; - case "session_info_update": { - const chat = - store.chats.find( - (item) => item.acpSessionId === payload.sessionId, - ) ?? - (store.acpStatus?.sessionId === payload.sessionId - ? actions.getCurrentChat() - : null); - const nextTitle = chat - ? getChatTitleFromSessionInfo(chat.title, payload.title) - : null; - if (chat && nextTitle) { - actions.updateChatTitle(chat.id, nextTitle); - } - break; - } - case "status_changed": - actions.setAcpStatus(payload.status); - if (!payload.status.running) { - actions.setAvailableSlashCommands([]); - actions.setSessionModeState(null, []); - actions.setSessionConfigOptions([]); - } - break; - default: - break; - } - }); - }; - - setupAcpStateSync().catch((error) => { - if (!disposed) { - console.error("Failed to initialize ACP state sync listener:", error); - } - }); - - return () => { - disposed = true; - if (unlisten) { - unlisten(); - } - }; - }, []); - - const appendAcpEvent = useCallback((event: ChatAcpEventInput) => { - setAcpEvents((prev) => appendChatAcpEvent(prev, event)); - }, []); - - // Agent availability is handled dynamically by the agent selector. - - const handleDeleteChat = (chatId: string) => { - chatActions.deleteChat(chatId); - }; - - const updateInitialAgentSessionTitle = useCallback( - async (chatId: string, userMessage: string) => { - const fallbackTitle = getFallbackAgentSessionTitle(userMessage); - chatActions.updateChatTitle(chatId, fallbackTitle); - - const authState = useAuthStore.getState(); - const enterprisePolicy = authState.subscription?.enterprise?.policy; - const managedPolicy = enterprisePolicy?.managedMode - ? enterprisePolicy - : null; - const isPro = hasProductCapability(authState.subscription, "hostedAi"); - - if (!isPro || (managedPolicy && !managedPolicy.aiCompletionEnabled)) { - return; - } - - const model = useSettingsStore.getState().settings.aiAutocompleteModelId; - if (!model) return; - - try { - const { editedText } = await requestInlineEdit( - { - model, - beforeSelection: "", - selectedText: userMessage, - afterSelection: "", - instruction: - "Name the software feature or task being worked on. Return exactly one or two words, no punctuation, no quotes, no explanation. Prefer a concrete product feature label over a generic verb.", - filePath: "agent-session-title", - languageId: "text", - }, - { useByok: false }, - ); - - const generatedTitle = normalizeAgentSessionTitle(editedText); - if (!generatedTitle) return; - - const currentChat = useAIChatStore - .getState() - .actions.getChatById(chatId); - if (!currentChat) return; - - if ( - currentChat.title === fallbackTitle || - currentChat.title === "New Session" - ) { - chatActions.updateChatTitle(chatId, generatedTitle); - } - } catch (error) { - console.debug("Failed to generate agent session title:", error); - } - }, - [chatActions], - ); - - const buildContext = async ( - agentId: string, - providerId: string, - ): Promise => { - const selectedBuffers = buffers.filter( - (buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id), - ); - const selectedActiveBuffer = - activeBuffer && - activeBuffer.type !== "agent" && - selectedBufferIds.has(activeBuffer.id) - ? activeBuffer - : undefined; - - let activeBufferContext: - | (typeof activeBuffer & { webViewerContent?: string }) - | undefined = selectedActiveBuffer; - if ( - selectedActiveBuffer?.type === "webViewer" && - selectedActiveBuffer.url - ) { - const { fetchWebPageContent } = await import( - "@/features/ai/services/web-content-service" - ); - const webContent = await fetchWebPageContent(selectedActiveBuffer.url); - activeBufferContext = { - ...selectedActiveBuffer, - webViewerContent: webContent, - }; - } - - const context: ContextInfo = { - activeBuffer: activeBufferContext, - openBuffers: selectedBuffers, - selectedFiles, - selectedProjectFiles: Array.from(selectedFilesPaths), - projectRoot: rootFolderPath, - providerId, - agentId, - }; - - if (selectedActiveBuffer && selectedActiveBuffer.type !== "webViewer") { - const extension = - selectedActiveBuffer.path.split(".").pop()?.toLowerCase() || ""; - const languageMap: Record = { - js: "JavaScript", - jsx: "JavaScript (React)", - ts: "TypeScript", - tsx: "TypeScript (React)", - py: "Python", - rs: "Rust", - go: "Go", - java: "Java", - cpp: "C++", - c: "C", - css: "CSS", - html: "HTML", - json: "JSON", - md: "Markdown", - sql: "SQL", - sh: "Shell Script", - yml: "YAML", - yaml: "YAML", - }; - - context.language = languageMap[extension] || "Text"; - } - - return context; - }; - - const stopStreaming = async () => { - const pendingPermissions = permissionQueue; - setPermissionQueue([]); - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - - if (currentAgentId === CODEX_INTEGRATION_ID) { - try { - await CodexIntegrationService.cancel(); - await Promise.all( - pendingPermissions.map((item) => - CodexIntegrationService.respond(item.requestId, false), - ), - ); - } catch (error) { - console.error("Failed to cancel Codex turn:", error); - } - } else if (isAcpAgent(currentAgentId)) { - try { - await AcpStreamHandler.cancelPrompt(); - if (pendingPermissions.length > 0) { - await Promise.all( - pendingPermissions.map((item) => - AcpStreamHandler.respondToPermission(item.requestId, false, true), - ), - ); - } - } catch (error) { - console.error("Failed to cancel ACP prompt:", error); - } - } - }; - - const updateStreamingAssistantMessage = useCallback( - ( - chatId: string, - messageId: string, - mutate: (currentMessage: Message | undefined) => Partial, - ) => { - const currentMessages = useAIChatStore - .getState() - .actions.getMessagesForChat(chatId); - const currentMessage = currentMessages.find( - (message) => message.id === messageId, - ); - chatActions.updateMessage(chatId, messageId, mutate(currentMessage)); - }, - [chatActions.updateMessage], - ); - - const processMessage = async ( - messageContent: string, - images: ImageContent[] = [], - options: { editedUserMessageId?: string } = {}, - ) => { - const store = useAIChatStore.getState(); - const targetChat = effectiveChatId - ? store.chats.find((chat) => chat.id === effectiveChatId) - : null; - const currentAgentId = - targetChat?.agentId ?? store.actions.getCurrentAgentId(); - const isAcp = isAcpAgent(currentAgentId); - const trimmedMessageContent = messageContent.trim(); - // For ACP agents, we don't need an API key. - // For Custom API, we need an API key to be set - if (!trimmedMessageContent || (!isAcp && !store.hasApiKey)) return; - if (options.editedUserMessageId && currentAgentId !== "custom") return; - - // Agents are started automatically by AcpStreamHandler when needed - - let targetChatId = effectiveChatId ?? store.currentChatId; - if (!targetChatId) { - targetChatId = chatActions.createNewChat(currentAgentId); - } else { - targetChatId = chatActions.ensureChatSession( - targetChatId, - currentAgentId, - { - activate: !chatId, - }, - ); - } - - const existingMessages = useAIChatStore - .getState() - .actions.getMessagesForChat(targetChatId); - const editedUserMessageIndex = options.editedUserMessageId - ? existingMessages.findIndex( - (message) => - message.id === options.editedUserMessageId && - message.role === "user", - ) - : -1; - if (options.editedUserMessageId && editedUserMessageIndex === -1) return; - - const conversationContext = buildConversationHistory( - editedUserMessageIndex >= 0 - ? existingMessages.slice(0, editedUserMessageIndex) - : existingMessages, - ); - const userMessage: Message = - editedUserMessageIndex >= 0 - ? { - ...existingMessages[editedUserMessageIndex], - content: trimmedMessageContent, - timestamp: new Date(), - images: - images.length > 0 - ? images - : existingMessages[editedUserMessageIndex].images, - } - : { - id: createMessageId(), - content: trimmedMessageContent, - role: "user", - timestamp: new Date(), - images: images.length > 0 ? images : undefined, - }; - - const assistantMessageId = createMessageId(); - const assistantMessage: Message = { - id: assistantMessageId, - content: "", - role: "assistant", - timestamp: new Date(), - isStreaming: true, - responsePhase: "waiting", - }; - - if (options.editedUserMessageId) { - const didReplace = chatActions.replaceUserMessage( - targetChatId, - options.editedUserMessageId, - trimmedMessageContent, - ); - if (!didReplace) return; - } else { - chatActions.addMessage(targetChatId, userMessage); - } - chatActions.addMessage(targetChatId, assistantMessage); - - const currentMessages = useAIChatStore - .getState() - .actions.getMessagesForChat(targetChatId); - if (currentMessages.length === 2) { - void updateInitialAgentSessionTitle(targetChatId, userMessage.content); - } - - setIsSurfaceTyping(true); - setSurfaceStreamingMessageId(assistantMessageId); - - abortControllerRef.current = new AbortController(); - let currentAssistantMessageId = assistantMessageId; - let currentAssistantRawContent = ""; - let acpProducedStateOnlyUpdate = false; - let acpCommandResultLabel: string | null = null; - - try { - const { mentionedFiles } = await parseMentionsAndLoadFiles( - trimmedMessageContent, - allProjectFiles, - ); - const mentionedPaths = new Set(mentionedFiles.map((file) => file.path)); - const attachedFiles = isAcp - ? [] - : await loadFilesByPaths( - Array.from(selectedFilesPaths).filter( - (path) => !mentionedPaths.has(path), - ), - ); - const latestSettings = useSettingsStore.getState().settings; - const context = await buildContext( - currentAgentId, - latestSettings.aiProviderId, - ); - context.mentionedFiles = [...mentionedFiles, ...attachedFiles]; - - // Handle direct ACP UI intents locally so they are always reliable. - if (isAcp) { - const directAction = parseDirectAcpUiAction(trimmedMessageContent); - if (directAction) { - const bufferActions = useBufferStore.getState().actions; - if (directAction.kind === "open_web_viewer" && directAction.url) { - if (!useSettingsStore.getState().settings.coreFeatures.webViewer) { - chatActions.updateMessage( - targetChatId, - currentAssistantMessageId, - { - content: - "Web Viewer is disabled. Enable it in Settings > Features to open URLs.", - isStreaming: false, - }, - ); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - return; - } - - bufferActions.openWebViewerBuffer(directAction.url); - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - content: `Opened ${directAction.url} in Athas web viewer.`, - isStreaming: false, - }); - } else if ( - directAction.kind === "open_terminal" && - directAction.command - ) { - bufferActions.openTerminalBuffer({ - command: directAction.command, - name: directAction.command, - }); - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - content: `Opened terminal and ran \`${directAction.command}\`.`, - isStreaming: false, - }); - } - - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - return; - } - } - - const enhancedMessage = isAcp - ? trimmedMessageContent - : appendReferencedFiles(trimmedMessageContent, [ - ...mentionedFiles, - ...attachedFiles, - ]); - if (isAcp) { - setAcpEvents([]); - } - - await getChatCompletionStream( - currentAgentId, - latestSettings.aiProviderId, - latestSettings.aiModelId, - enhancedMessage, - context, - (chunk: string) => { - currentAssistantRawContent += chunk; - const extracted = extractFollowUpActions(currentAssistantRawContent); - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - () => ({ - content: extracted.content, - followUpActions: extracted.actions, - responsePhase: undefined, - }), - ); - }, - () => { - const currentMessage = chatActions - .getMessagesForChat(targetChatId) - .find((message) => message.id === currentAssistantMessageId); - const hasVisibleResponse = Boolean( - currentMessage?.content?.trim() || - currentMessage?.toolCalls?.length || - currentMessage?.images?.length || - currentMessage?.resources?.length, - ); - - if (!hasVisibleResponse) { - if (isAcpAgent(currentAgentId) && acpProducedStateOnlyUpdate) { - const slashCommand = - trimmedMessageContent.match(/^\/([^\s]+)/)?.[1]; - const fallbackContent = - acpCommandResultLabel || - (slashCommand - ? `Applied \`/${slashCommand}\`.` - : "Session updated."); - - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - () => ({ - content: fallbackContent, - isStreaming: false, - }), - ); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - return; - } - - const isAcp = isAcpAgent(currentAgentId); - const fallbackMessage = isAcp - ? "The selected agent did not return a visible response. Try sending the message again." - : "The selected provider did not return a visible response. Try another model or send the message again."; - const emptyResponseSource = isAcp - ? "agent session" - : "provider request"; - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - () => ({ - content: `[ERROR_BLOCK] + const rootFolderPath = useProjectStore((state) => state.rootFolderPath); + const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); + const subscription = useAuthStore((state) => state.subscription); + const enterprisePolicy = subscription?.enterprise?.policy; + const isAiChatBlockedByPolicy = Boolean( + enterprisePolicy?.managedMode && !enterprisePolicy.aiChatEnabled, + ); + + const chatState = useChatState(); + const chatActions = useChatActions(); + const { showToast } = useToast(); + + const abortControllerRef = useRef(null); + const [permissionQueue, setPermissionQueue] = useState([]); + const [acpEvents, setAcpEvents] = useState([]); + const [isMessageSearchOpen, setIsMessageSearchOpen] = useState(false); + const [messageSearchQuery, setMessageSearchQuery] = useState(""); + const [activeMessageSearchIndex, setActiveMessageSearchIndex] = useState(0); + const [selectedBufferIds, setSelectedBufferIds] = useState>(new Set()); + const [selectedFilesPaths, setSelectedFilesPaths] = useState>(new Set()); + const [isSurfaceTyping, setIsSurfaceTyping] = useState(false); + const [surfaceStreamingMessageId, setSurfaceStreamingMessageId] = useState(null); + const [queueCount, setQueueCount] = useState(0); + const messageQueueRef = useRef([]); + const effectiveChatId = chatId ?? chatState.currentChatId; + const currentChat = useMemo( + () => chatState.chats.find((chat) => chat.id === effectiveChatId), + [chatState.chats, effectiveChatId], + ); + const currentAgentId = currentChat?.agentId ?? useAIChatStore.getState().selectedAgentId; + const messageSearchMatches = useMemo( + () => getMessageSearchMatches(currentChat?.messages ?? [], messageSearchQuery), + [currentChat?.messages, messageSearchQuery], + ); + const activeMessageSearchMatch = messageSearchMatches[activeMessageSearchIndex] ?? null; + + const closeMessageSearch = useCallback(() => { + setIsMessageSearchOpen(false); + setMessageSearchQuery(""); + setActiveMessageSearchIndex(0); + }, []); + + const goToPreviousMessageSearchMatch = useCallback(() => { + if (messageSearchMatches.length === 0) return; + setActiveMessageSearchIndex((index) => + index === 0 ? messageSearchMatches.length - 1 : index - 1, + ); + }, [messageSearchMatches.length]); + + const goToNextMessageSearchMatch = useCallback(() => { + if (messageSearchMatches.length === 0) return; + setActiveMessageSearchIndex((index) => (index + 1) % messageSearchMatches.length); + }, [messageSearchMatches.length]); + + useEffect(() => { + chatActions.checkApiKey(aiProviderId); + chatActions.checkAllProviderApiKeys(); + }, [aiProviderId, chatActions.checkApiKey, chatActions.checkAllProviderApiKeys]); + + // Clear ACP events when switching chats + useEffect(() => { + setAcpEvents([]); + closeMessageSearch(); + setSelectedBufferIds(new Set()); + setSelectedFilesPaths(new Set()); + }, [closeMessageSearch, effectiveChatId]); + + useEffect(() => { + setActiveMessageSearchIndex(0); + }, [messageSearchQuery]); + + useEffect(() => { + if (messageSearchMatches.length === 0) { + setActiveMessageSearchIndex(0); + return; + } + + setActiveMessageSearchIndex((index) => Math.min(index, messageSearchMatches.length - 1)); + }, [messageSearchMatches.length]); + + useEffect(() => { + if (!isActiveSurface || isAiChatBlockedByPolicy) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + setIsMessageSearchOpen(true); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isActiveSurface, isAiChatBlockedByPolicy]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + let disposed = false; + + const setupAcpStateSync = async () => { + unlisten = await listen("acp-event", ({ payload }) => { + const store = useAIChatStore.getState(); + const { actions } = store; + + switch (payload.type) { + case "slash_commands_update": + actions.setAvailableSlashCommands(payload.commands); + break; + case "session_mode_update": + actions.setSessionModeState( + payload.modeState.currentModeId, + payload.modeState.availableModes, + ); + break; + case "current_mode_update": + actions.setCurrentModeId(payload.currentModeId); + break; + case "config_options_update": + actions.setSessionConfigOptions(payload.configOptions); + break; + case "session_info_update": { + const chat = + store.chats.find((item) => item.acpSessionId === payload.sessionId) ?? + (store.acpStatus?.sessionId === payload.sessionId ? actions.getCurrentChat() : null); + const nextTitle = chat ? getChatTitleFromSessionInfo(chat.title, payload.title) : null; + if (chat && nextTitle) { + actions.updateChatTitle(chat.id, nextTitle); + } + break; + } + case "status_changed": + actions.setAcpStatus(payload.status); + if (!payload.status.running) { + actions.setAvailableSlashCommands([]); + actions.setSessionModeState(null, []); + actions.setSessionConfigOptions([]); + } + break; + default: + break; + } + }); + }; + + setupAcpStateSync().catch((error) => { + if (!disposed) { + console.error("Failed to initialize ACP state sync listener:", error); + } + }); + + return () => { + disposed = true; + if (unlisten) { + unlisten(); + } + }; + }, []); + + const appendAcpEvent = useCallback((event: ChatAcpEventInput) => { + setAcpEvents((prev) => appendChatAcpEvent(prev, event)); + }, []); + + // Agent availability is handled dynamically by the agent selector. + + const handleDeleteChat = (chatId: string) => { + chatActions.deleteChat(chatId); + }; + + const updateInitialAgentSessionTitle = useCallback( + async (chatId: string, userMessage: string) => { + const fallbackTitle = getFallbackAgentSessionTitle(userMessage); + chatActions.updateChatTitle(chatId, fallbackTitle); + + const authState = useAuthStore.getState(); + const enterprisePolicy = authState.subscription?.enterprise?.policy; + const managedPolicy = enterprisePolicy?.managedMode ? enterprisePolicy : null; + const isPro = hasProductCapability(authState.subscription, "hostedAi"); + + if (!isPro || (managedPolicy && !managedPolicy.aiCompletionEnabled)) { + return; + } + + const model = useSettingsStore.getState().settings.aiAutocompleteModelId; + if (!model) return; + + try { + const { editedText } = await requestInlineEdit( + { + model, + beforeSelection: "", + selectedText: userMessage, + afterSelection: "", + instruction: + "Name the software feature or task being worked on. Return exactly one or two words, no punctuation, no quotes, no explanation. Prefer a concrete product feature label over a generic verb.", + filePath: "agent-session-title", + languageId: "text", + }, + { useByok: false }, + ); + + const generatedTitle = normalizeAgentSessionTitle(editedText); + if (!generatedTitle) return; + + const currentChat = useAIChatStore.getState().actions.getChatById(chatId); + if (!currentChat) return; + + if (currentChat.title === fallbackTitle || currentChat.title === "New Session") { + chatActions.updateChatTitle(chatId, generatedTitle); + } + } catch (error) { + console.debug("Failed to generate agent session title:", error); + } + }, + [chatActions], + ); + + const buildContext = async (agentId: string, providerId: string): Promise => { + const selectedBuffers = buffers.filter( + (buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id), + ); + const selectedActiveBuffer = + activeBuffer && activeBuffer.type !== "agent" && selectedBufferIds.has(activeBuffer.id) + ? activeBuffer + : undefined; + + let activeBufferContext: (typeof activeBuffer & { webViewerContent?: string }) | undefined = + selectedActiveBuffer; + if (selectedActiveBuffer?.type === "webViewer" && selectedActiveBuffer.url) { + const { fetchWebPageContent } = await import("@/features/ai/services/web-content-service"); + const webContent = await fetchWebPageContent(selectedActiveBuffer.url); + activeBufferContext = { + ...selectedActiveBuffer, + webViewerContent: webContent, + }; + } + + const context: ContextInfo = { + activeBuffer: activeBufferContext, + openBuffers: selectedBuffers, + selectedFiles, + selectedProjectFiles: Array.from(selectedFilesPaths), + projectRoot: rootFolderPath, + providerId, + agentId, + }; + + if (selectedActiveBuffer && selectedActiveBuffer.type !== "webViewer") { + const extension = selectedActiveBuffer.path.split(".").pop()?.toLowerCase() || ""; + const languageMap: Record = { + js: "JavaScript", + jsx: "JavaScript (React)", + ts: "TypeScript", + tsx: "TypeScript (React)", + py: "Python", + rs: "Rust", + go: "Go", + java: "Java", + cpp: "C++", + c: "C", + css: "CSS", + html: "HTML", + json: "JSON", + md: "Markdown", + sql: "SQL", + sh: "Shell Script", + yml: "YAML", + yaml: "YAML", + }; + + context.language = languageMap[extension] || "Text"; + } + + return context; + }; + + const stopStreaming = async () => { + const pendingPermissions = permissionQueue; + setPermissionQueue([]); + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + + if (currentAgentId === CODEX_INTEGRATION_ID) { + try { + await CodexIntegrationService.cancel(); + await Promise.all( + pendingPermissions.map((item) => CodexIntegrationService.respond(item.requestId, false)), + ); + } catch (error) { + console.error("Failed to cancel Codex turn:", error); + } + } else if (isAcpAgent(currentAgentId)) { + try { + await AcpStreamHandler.cancelPrompt(); + if (pendingPermissions.length > 0) { + await Promise.all( + pendingPermissions.map((item) => + AcpStreamHandler.respondToPermission(item.requestId, false, true), + ), + ); + } + } catch (error) { + console.error("Failed to cancel ACP prompt:", error); + } + } + }; + + const updateStreamingAssistantMessage = useCallback( + ( + chatId: string, + messageId: string, + mutate: (currentMessage: Message | undefined) => Partial, + ) => { + const currentMessages = useAIChatStore.getState().actions.getMessagesForChat(chatId); + const currentMessage = currentMessages.find((message) => message.id === messageId); + chatActions.updateMessage(chatId, messageId, mutate(currentMessage)); + }, + [chatActions.updateMessage], + ); + + const processMessage = async ( + messageContent: string, + images: ImageContent[] = [], + options: { editedUserMessageId?: string } = {}, + ) => { + const store = useAIChatStore.getState(); + const targetChat = effectiveChatId + ? store.chats.find((chat) => chat.id === effectiveChatId) + : null; + const currentAgentId = targetChat?.agentId ?? store.actions.getCurrentAgentId(); + const isAcp = isAcpAgent(currentAgentId); + const trimmedMessageContent = messageContent.trim(); + // For ACP agents, we don't need an API key. + // For Custom API, we need an API key to be set + if (!trimmedMessageContent || (!isAcp && !store.hasApiKey)) return; + if (options.editedUserMessageId && currentAgentId !== "custom") return; + + // Agents are started automatically by AcpStreamHandler when needed + + let targetChatId = effectiveChatId ?? store.currentChatId; + if (!targetChatId) { + targetChatId = chatActions.createNewChat(currentAgentId); + } else { + targetChatId = chatActions.ensureChatSession(targetChatId, currentAgentId, { + activate: !chatId, + }); + } + + const existingMessages = useAIChatStore.getState().actions.getMessagesForChat(targetChatId); + const editedUserMessageIndex = options.editedUserMessageId + ? existingMessages.findIndex( + (message) => message.id === options.editedUserMessageId && message.role === "user", + ) + : -1; + if (options.editedUserMessageId && editedUserMessageIndex === -1) return; + + const conversationContext = buildConversationHistory( + editedUserMessageIndex >= 0 + ? existingMessages.slice(0, editedUserMessageIndex) + : existingMessages, + ); + const userMessage: Message = + editedUserMessageIndex >= 0 + ? { + ...existingMessages[editedUserMessageIndex], + content: trimmedMessageContent, + timestamp: new Date(), + images: images.length > 0 ? images : existingMessages[editedUserMessageIndex].images, + } + : { + id: createMessageId(), + content: trimmedMessageContent, + role: "user", + timestamp: new Date(), + images: images.length > 0 ? images : undefined, + }; + + const assistantMessageId = createMessageId(); + const assistantMessage: Message = { + id: assistantMessageId, + content: "", + role: "assistant", + timestamp: new Date(), + isStreaming: true, + responsePhase: "waiting", + }; + + if (options.editedUserMessageId) { + const didReplace = chatActions.replaceUserMessage( + targetChatId, + options.editedUserMessageId, + trimmedMessageContent, + ); + if (!didReplace) return; + } else { + chatActions.addMessage(targetChatId, userMessage); + } + chatActions.addMessage(targetChatId, assistantMessage); + + const currentMessages = useAIChatStore.getState().actions.getMessagesForChat(targetChatId); + if (currentMessages.length === 2) { + void updateInitialAgentSessionTitle(targetChatId, userMessage.content); + } + + setIsSurfaceTyping(true); + setSurfaceStreamingMessageId(assistantMessageId); + + abortControllerRef.current = new AbortController(); + let currentAssistantMessageId = assistantMessageId; + let currentAssistantRawContent = ""; + let acpProducedStateOnlyUpdate = false; + let acpCommandResultLabel: string | null = null; + + try { + const { mentionedFiles } = await parseMentionsAndLoadFiles( + trimmedMessageContent, + allProjectFiles, + ); + const mentionedPaths = new Set(mentionedFiles.map((file) => file.path)); + const attachedFiles = isAcp + ? [] + : await loadFilesByPaths( + Array.from(selectedFilesPaths).filter((path) => !mentionedPaths.has(path)), + ); + const latestSettings = useSettingsStore.getState().settings; + const context = await buildContext(currentAgentId, latestSettings.aiProviderId); + context.mentionedFiles = [...mentionedFiles, ...attachedFiles]; + + // Handle direct ACP UI intents locally so they are always reliable. + if (isAcp) { + const directAction = parseDirectAcpUiAction(trimmedMessageContent); + if (directAction) { + const bufferActions = useBufferStore.getState().actions; + if (directAction.kind === "open_web_viewer" && directAction.url) { + if (!useSettingsStore.getState().settings.coreFeatures.webViewer) { + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + content: "Web Viewer is disabled. Enable it in Settings > Features to open URLs.", + isStreaming: false, + }); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + return; + } + + bufferActions.openWebViewerBuffer(directAction.url); + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + content: `Opened ${directAction.url} in Athas web viewer.`, + isStreaming: false, + }); + } else if (directAction.kind === "open_terminal" && directAction.command) { + bufferActions.openTerminalBuffer({ + command: directAction.command, + name: directAction.command, + }); + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + content: `Opened terminal and ran \`${directAction.command}\`.`, + isStreaming: false, + }); + } + + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + return; + } + } + + const enhancedMessage = isAcp + ? trimmedMessageContent + : appendReferencedFiles(trimmedMessageContent, [...mentionedFiles, ...attachedFiles]); + if (isAcp) { + setAcpEvents([]); + } + + await getChatCompletionStream( + currentAgentId, + latestSettings.aiProviderId, + latestSettings.aiModelId, + enhancedMessage, + context, + (chunk: string) => { + currentAssistantRawContent += chunk; + const extracted = extractFollowUpActions(currentAssistantRawContent); + updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ + content: extracted.content, + followUpActions: extracted.actions, + responsePhase: undefined, + })); + }, + () => { + const currentMessage = chatActions + .getMessagesForChat(targetChatId) + .find((message) => message.id === currentAssistantMessageId); + const hasVisibleResponse = Boolean( + currentMessage?.content?.trim() || + currentMessage?.toolCalls?.length || + currentMessage?.images?.length || + currentMessage?.resources?.length, + ); + + if (!hasVisibleResponse) { + if (isAcpAgent(currentAgentId) && acpProducedStateOnlyUpdate) { + const slashCommand = trimmedMessageContent.match(/^\/([^\s]+)/)?.[1]; + const fallbackContent = + acpCommandResultLabel || + (slashCommand ? `Applied \`/${slashCommand}\`.` : "Session updated."); + + updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ + content: fallbackContent, + isStreaming: false, + })); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + return; + } + + const isAcp = isAcpAgent(currentAgentId); + const fallbackMessage = isAcp + ? "The selected agent did not return a visible response. Try sending the message again." + : "The selected provider did not return a visible response. Try another model or send the message again."; + const emptyResponseSource = isAcp ? "agent session" : "provider request"; + updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ + content: `[ERROR_BLOCK] title: No Response code: EMPTY_RESPONSE message: ${fallbackMessage} details: The ${emptyResponseSource} completed, but no content, tool output, or resource was returned. [/ERROR_BLOCK]`, - isStreaming: false, - }), - ); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - return; - } - - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - isStreaming: false, - }); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - }, - (error: string, canReconnect?: boolean) => { - console.error("Streaming error:", error); - - let errorTitle = "API Error"; - let errorMessage = error; - let errorCode = ""; - let errorDetails = ""; - - const parts = error.split("|||"); - const mainError = parts[0]; - if (parts.length > 1) { - errorDetails = parts[1]; - } - - const codeMatch = mainError.match(/error:\s*(\d+)/i); - if (codeMatch) { - errorCode = codeMatch[1]; - if (errorCode === "429") { - errorTitle = "Rate Limit Exceeded"; - errorMessage = - "The API is temporarily rate-limited. Please wait a moment and try again."; - } else if (errorCode === "401") { - errorTitle = "Authentication Error"; - errorMessage = "Invalid API key. Please check your API settings."; - } else if (errorCode === "403") { - errorTitle = "Access Denied"; - errorMessage = - "You don't have permission to access this resource."; - } else if (errorCode === "500") { - errorTitle = "Server Error"; - errorMessage = - "The API server encountered an error. Please try again later."; - } else if (errorCode === "400") { - errorTitle = "Bad Request"; - if (errorDetails) { - try { - const parsed = JSON.parse(errorDetails); - if (parsed.error?.message) { - errorMessage = parsed.error.message; - } - } catch { - errorMessage = mainError; - } - } - } - } - - const isAcpConfigError = - isAcpAgent(currentAgentId) && - isAcpConfigurationError(mainError, errorDetails); - const isAcpAuthError = - !isAcpConfigError && - isAcpAgent(currentAgentId) && - isAcpAuthenticationError(mainError, errorDetails); - - if (isAcpConfigError) { - errorTitle = "Agent Configuration Required"; - errorCode = "CONFIG_REQUIRED"; - errorMessage = - "The selected agent is authenticated, but its account configuration is incomplete."; - } else if (isAcpAuthError) { - errorTitle = "Authentication Required"; - errorCode = "AUTH_REQUIRED"; - errorMessage = - "The selected agent needs external authentication before it can accept prompts."; - - if ( - mainError.includes("Method not implemented") || - errorDetails.includes("Method not implemented") - ) { - errorDetails = - "This ACP adapter does not implement the protocol authenticate flow. Complete login in the underlying CLI/adapter, then try again."; - } else if (!errorDetails) { - errorDetails = - "Complete authentication in the underlying CLI/adapter, then try again."; - } - } - - if (canReconnect) { - errorTitle = "Connection Lost"; - errorCode = "RECONNECT"; - } - - const shouldSuppressToast = - isAcpAgent(currentAgentId) && - (mainError.includes("did not return any response") || - errorCode === "RECONNECT"); - - const formattedError = `[ERROR_BLOCK] + isStreaming: false, + })); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + return; + } + + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + isStreaming: false, + }); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + }, + (error: string, canReconnect?: boolean) => { + console.error("Streaming error:", error); + + let errorTitle = "API Error"; + let errorMessage = error; + let errorCode = ""; + let errorDetails = ""; + + const parts = error.split("|||"); + const mainError = parts[0]; + if (parts.length > 1) { + errorDetails = parts[1]; + } + + const codeMatch = mainError.match(/error:\s*(\d+)/i); + if (codeMatch) { + errorCode = codeMatch[1]; + if (errorCode === "429") { + errorTitle = "Rate Limit Exceeded"; + errorMessage = + "The API is temporarily rate-limited. Please wait a moment and try again."; + } else if (errorCode === "401") { + errorTitle = "Authentication Error"; + errorMessage = "Invalid API key. Please check your API settings."; + } else if (errorCode === "403") { + errorTitle = "Access Denied"; + errorMessage = "You don't have permission to access this resource."; + } else if (errorCode === "500") { + errorTitle = "Server Error"; + errorMessage = "The API server encountered an error. Please try again later."; + } else if (errorCode === "400") { + errorTitle = "Bad Request"; + if (errorDetails) { + try { + const parsed = JSON.parse(errorDetails); + if (parsed.error?.message) { + errorMessage = parsed.error.message; + } + } catch { + errorMessage = mainError; + } + } + } + } + + const isAcpConfigError = + isAcpAgent(currentAgentId) && isAcpConfigurationError(mainError, errorDetails); + const isAcpAuthError = + !isAcpConfigError && + isAcpAgent(currentAgentId) && + isAcpAuthenticationError(mainError, errorDetails); + + if (isAcpConfigError) { + errorTitle = "Agent Configuration Required"; + errorCode = "CONFIG_REQUIRED"; + errorMessage = + "The selected agent is authenticated, but its account configuration is incomplete."; + } else if (isAcpAuthError) { + errorTitle = "Authentication Required"; + errorCode = "AUTH_REQUIRED"; + errorMessage = + "The selected agent needs external authentication before it can accept prompts."; + + if ( + mainError.includes("Method not implemented") || + errorDetails.includes("Method not implemented") + ) { + errorDetails = + "This ACP adapter does not implement the protocol authenticate flow. Complete login in the underlying CLI/adapter, then try again."; + } else if (!errorDetails) { + errorDetails = + "Complete authentication in the underlying CLI/adapter, then try again."; + } + } + + if (canReconnect) { + errorTitle = "Connection Lost"; + errorCode = "RECONNECT"; + } + + const shouldSuppressToast = + isAcpAgent(currentAgentId) && + (mainError.includes("did not return any response") || errorCode === "RECONNECT"); + + const formattedError = `[ERROR_BLOCK] title: ${errorTitle} code: ${errorCode} message: ${errorMessage} details: ${errorDetails || mainError} [/ERROR_BLOCK]`; - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - content: currentMessage?.content || formattedError, - isStreaming: false, - }), - ); - if (!shouldSuppressToast) { - showToast({ - message: errorMessage, - type: "error", - }); - } - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - processQueuedMessages(); - }, - conversationContext, - () => { - chatActions.updateMessage(targetChatId, currentAssistantMessageId, { - isStreaming: false, - responsePhase: undefined, - }); - - const newMessageId = createMessageId(); - currentAssistantRawContent = ""; - const newAssistantMessage: Message = { - id: newMessageId, - content: "", - role: "assistant", - timestamp: new Date(), - isStreaming: true, - responsePhase: "waiting", - }; - - chatActions.addMessage(targetChatId, newAssistantMessage); - currentAssistantMessageId = newMessageId; - setSurfaceStreamingMessageId(newMessageId); - }, - (event) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - isToolUse: true, - toolName: event.toolName, - toolCalls: [ - ...(currentMessage?.toolCalls || []), - createToolCall( - event.toolName, - event.input, - event.toolId, - event.kind, - event.status, - event.locations, - ), - ], - }), - ); - }, - (event) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - toolCalls: updateToolCall(currentMessage?.toolCalls || [], { - id: event.toolId, - name: event.toolName, - input: event.input, - output: event.output, - error: event.error, - kind: event.kind, - status: event.status, - locations: event.locations, - }), - }), - ); - }, - ( - toolName: string, - toolId?: string, - output?: unknown, - error?: string, - ) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - toolCalls: markToolCallComplete( - currentMessage?.toolCalls || [], - toolName, - toolId, - output, - error, - ), - }), - ); - }, - (event) => { - appendAcpEvent({ - id: `permission-request-${event.requestId}`, - category: "permission", - label: "Permission requested", - detail: - event.description || - `${event.permissionType} ${event.resource}`.trim(), - state: "info", - }); - setPermissionQueue((prev) => [ - ...prev, - { - requestId: event.requestId, - description: event.description, - permissionType: event.permissionType, - resource: event.resource, - options: event.options, - }, - ]); - }, - (event) => { - if ( - !isAcpAgent(currentAgentId) && - currentAgentId !== CODEX_INTEGRATION_ID - ) - return; - // Only show meaningful events, skip noisy ones - if ( - event.type === "content_chunk" || - event.type === "user_message_chunk" || - event.type === "session_complete" - ) { - return; - } - switch (event.type) { - case "thought_chunk": - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - () => ({ - responsePhase: "thinking", - }), - ); - break; - case "tool_start": - case "tool_update": - break; - case "tool_complete": - break; - case "permission_request": - break; // Handled separately with permission UI - case "prompt_complete": - break; // Not useful to show - case "session_mode_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = event.modeState.currentModeId - ? `Mode set to \`${event.modeState.currentModeId}\`.` - : "Session mode updated."; - break; - case "config_options_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = - event.configOptions.length === 1 - ? "Session option updated." - : "Session options updated."; - break; - case "session_info_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = event.title - ? `Session title updated to "${event.title}".` - : "Session metadata updated."; - if (event.title) { - appendAcpEvent({ - category: "status", - label: "Session title updated", - detail: event.title, - state: "info", - }); - } - break; - case "current_mode_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = `Mode set to \`${event.currentModeId}\`.`; - break; - case "slash_commands_update": - acpProducedStateOnlyUpdate = true; - acpCommandResultLabel = "Slash commands refreshed."; - break; // Not useful to show - case "plan_update": { - const summary = - event.entries.length > 0 - ? event.entries.map((entry) => entry.content).join(" | ") - : "No plan steps"; - appendAcpEvent({ - category: "plan", - label: `Plan updated (${event.entries.length} steps)`, - detail: summary, - state: "info", - }); - break; - } - case "usage_update": { - break; - } - case "status_changed": - useAIChatStore.getState().actions.setAcpStatus(event.status); - break; // internal state sync - case "error": - appendAcpEvent({ - category: "error", - label: "Agent error", - detail: event.error, - state: "error", - }); - break; - case "ui_action": - break; // Handled by acp-handler - } - }, - chatState.mode, - chatState.outputStyle, - (data: string, mediaType: string) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - images: [...(currentMessage?.images || []), { data, mediaType }], - }), - ); - }, - (uri: string, name: string | null) => { - updateStreamingAssistantMessage( - targetChatId, - currentAssistantMessageId, - (currentMessage) => ({ - resources: [...(currentMessage?.resources || []), { uri, name }], - }), - ); - }, - targetChatId, - ); - } catch (error) { - console.error("Failed to start streaming:", error); - chatActions.updateMessage(targetChatId, assistantMessageId, { - content: - "Error: Failed to connect to Agent service. Please check your API key and try again.", - isStreaming: false, - }); - setIsSurfaceTyping(false); - setSurfaceStreamingMessageId(null); - abortControllerRef.current = null; - } - }; - - const processQueuedMessages = useCallback(async () => { - if (isSurfaceTyping || surfaceStreamingMessageId) { - return; - } - - const nextMessage = messageQueueRef.current.shift(); - setQueueCount(messageQueueRef.current.length); - if (nextMessage) { - console.log("Processing next queued message:", nextMessage); - await new Promise((resolve) => setTimeout(resolve, 500)); - await processMessage(nextMessage); - } - }, [isSurfaceTyping, surfaceStreamingMessageId]); - - const sendMessage = useCallback( - async (messageContent: string, images?: ImageContent[]) => { - const isAcp = isAcpAgent(currentAgentId); - // For ACP agents, we don't need an API key. - if (!messageContent.trim() || (!isAcp && !chatState.hasApiKey)) return; - - if (isSurfaceTyping || surfaceStreamingMessageId) { - messageQueueRef.current.push(messageContent); - setQueueCount(messageQueueRef.current.length); - return; - } - - await processMessage(messageContent, images); - }, - [ - chatState.hasApiKey, - currentAgentId, - isSurfaceTyping, - surfaceStreamingMessageId, - ], - ); - - const handleSendMessage = useCallback( - async (messageContent: string, images?: ImageContent[]) => { - await sendMessage(messageContent, images); - }, - [sendMessage], - ); - - const handleEditUserMessage = async (messageId: string, content: string) => { - if ( - isSurfaceTyping || - surfaceStreamingMessageId || - currentChat?.agentId !== "custom" - ) { - return; - } - - await processMessage(content, { editedUserMessageId: messageId }); - }; - - useEffect(() => { - const pendingLaunch = chatState.pendingAgentLaunchRequest; - if (!pendingLaunch) return; - if (pendingLaunch.chatId !== effectiveChatId) return; - if (activeBuffer?.type !== "agent") return; - if (activeBuffer.sessionId !== pendingLaunch.chatId) return; - if (isSurfaceTyping || surfaceStreamingMessageId) return; - if (!isAcpAgent(pendingLaunch.agentId) && !chatState.hasApiKey) return; - - setSelectedBufferIds(new Set(pendingLaunch.selectedBufferIds)); - setSelectedFilesPaths(new Set(pendingLaunch.selectedFilesPaths)); - chatActions.setPendingAgentLaunchRequest(null); - void sendMessage(pendingLaunch.prompt); - }, [ - chatActions, - effectiveChatId, - chatState.hasApiKey, - isSurfaceTyping, - chatState.pendingAgentLaunchRequest, - surfaceStreamingMessageId, - activeBuffer, - sendMessage, - ]); - - const currentPermission = permissionQueue[0]; - const isNewSession = - (currentChat?.messages.length ?? 0) === 0 && acpEvents.length === 0; - const useInitialComposer = isNewSession && !currentPermission; - const handlePermission = async (approved: boolean, optionId?: string) => { - if (!currentPermission) return; - try { - const option = currentPermission.options.find( - (item) => item.id === optionId, - ); - appendAcpEvent({ - id: `permission-response-${currentPermission.requestId}`, - category: "permission", - label: "Permission response", - detail: option?.name || (approved ? "allow" : "deny"), - state: approved ? "success" : "info", - }); - if (currentAgentId === CODEX_INTEGRATION_ID) { - await CodexIntegrationService.respond( - currentPermission.requestId, - approved, - ); - } else { - await AcpStreamHandler.respondToPermission( - currentPermission.requestId, - approved, - false, - optionId, - ); - } - } finally { - setPermissionQueue((prev) => prev.slice(1)); - } - }; - - return ( -
- { - if (isMessageSearchOpen) { - closeMessageSearch(); - return; - } - - setIsMessageSearchOpen(true); - }} - onCloseMessageSearch={closeMessageSearch} - onMessageSearchQueryChange={setMessageSearchQuery} - messageSearchMatchCount={messageSearchMatches.length} - activeMessageSearchIndex={activeMessageSearchIndex} - onPreviousMessageSearchMatch={goToPreviousMessageSearchMatch} - onNextMessageSearchMatch={goToNextMessageSearchMatch} - /> - {isAiChatBlockedByPolicy ? ( - - - Agent is disabled - - Your organization policy has disabled Agent for this workspace. - - - - ) : ( - <> - {useInitialComposer ? ( - - - setSelectedBufferIds((current) => { - const next = new Set(current); - if (next.has(bufferId)) next.delete(bufferId); - else next.add(bufferId); - return next; - }) - } - onToggleFileSelection={(filePath) => - setSelectedFilesPaths((current) => { - const next = new Set(current); - if (next.has(filePath)) next.delete(filePath); - else next.add(filePath); - return next; - }) - } - onSetSelectedBufferIds={setSelectedBufferIds} - onSetSelectedFilesPaths={setSelectedFilesPaths} - isActiveSurface={isActiveSurface} - presentation="initial" - onSendMessage={handleSendMessage} - onStopStreaming={stopStreaming} - /> - - ) : ( - - - - - - - - - )} - - {currentPermission ? ( - - ) : null} - - {!useInitialComposer ? ( - - setSelectedBufferIds((current) => { - const next = new Set(current); - if (next.has(bufferId)) next.delete(bufferId); - else next.add(bufferId); - return next; - }) - } - onToggleFileSelection={(filePath) => - setSelectedFilesPaths((current) => { - const next = new Set(current); - if (next.has(filePath)) next.delete(filePath); - else next.add(filePath); - return next; - }) - } - onSetSelectedBufferIds={setSelectedBufferIds} - onSetSelectedFilesPaths={setSelectedFilesPaths} - isActiveSurface={isActiveSurface} - onSendMessage={handleSendMessage} - onStopStreaming={stopStreaming} - /> - ) : null} - - )} -
- ); + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + content: currentMessage?.content || formattedError, + isStreaming: false, + }), + ); + if (!shouldSuppressToast) { + showToast({ + message: errorMessage, + type: "error", + }); + } + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + processQueuedMessages(); + }, + conversationContext, + () => { + chatActions.updateMessage(targetChatId, currentAssistantMessageId, { + isStreaming: false, + responsePhase: undefined, + }); + + const newMessageId = createMessageId(); + currentAssistantRawContent = ""; + const newAssistantMessage: Message = { + id: newMessageId, + content: "", + role: "assistant", + timestamp: new Date(), + isStreaming: true, + responsePhase: "waiting", + }; + + chatActions.addMessage(targetChatId, newAssistantMessage); + currentAssistantMessageId = newMessageId; + setSurfaceStreamingMessageId(newMessageId); + }, + (event) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + isToolUse: true, + toolName: event.toolName, + toolCalls: [ + ...(currentMessage?.toolCalls || []), + createToolCall( + event.toolName, + event.input, + event.toolId, + event.kind, + event.status, + event.locations, + ), + ], + }), + ); + }, + (event) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + toolCalls: updateToolCall(currentMessage?.toolCalls || [], { + id: event.toolId, + name: event.toolName, + input: event.input, + output: event.output, + error: event.error, + kind: event.kind, + status: event.status, + locations: event.locations, + }), + }), + ); + }, + (toolName: string, toolId?: string, output?: unknown, error?: string) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + toolCalls: markToolCallComplete( + currentMessage?.toolCalls || [], + toolName, + toolId, + output, + error, + ), + }), + ); + }, + (event) => { + appendAcpEvent({ + id: `permission-request-${event.requestId}`, + category: "permission", + label: "Permission requested", + detail: event.description || `${event.permissionType} ${event.resource}`.trim(), + state: "info", + }); + setPermissionQueue((prev) => [ + ...prev, + { + requestId: event.requestId, + description: event.description, + permissionType: event.permissionType, + resource: event.resource, + options: event.options, + }, + ]); + }, + (event) => { + if (!isAcpAgent(currentAgentId) && currentAgentId !== CODEX_INTEGRATION_ID) return; + // Only show meaningful events, skip noisy ones + if ( + event.type === "content_chunk" || + event.type === "user_message_chunk" || + event.type === "session_complete" + ) { + return; + } + switch (event.type) { + case "thought_chunk": + updateStreamingAssistantMessage(targetChatId, currentAssistantMessageId, () => ({ + responsePhase: "thinking", + })); + break; + case "tool_start": + case "tool_update": + break; + case "tool_complete": + break; + case "permission_request": + break; // Handled separately with permission UI + case "prompt_complete": + break; // Not useful to show + case "session_mode_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = event.modeState.currentModeId + ? `Mode set to \`${event.modeState.currentModeId}\`.` + : "Session mode updated."; + break; + case "config_options_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = + event.configOptions.length === 1 + ? "Session option updated." + : "Session options updated."; + break; + case "session_info_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = event.title + ? `Session title updated to "${event.title}".` + : "Session metadata updated."; + if (event.title) { + appendAcpEvent({ + category: "status", + label: "Session title updated", + detail: event.title, + state: "info", + }); + } + break; + case "current_mode_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = `Mode set to \`${event.currentModeId}\`.`; + break; + case "slash_commands_update": + acpProducedStateOnlyUpdate = true; + acpCommandResultLabel = "Slash commands refreshed."; + break; // Not useful to show + case "plan_update": { + const summary = + event.entries.length > 0 + ? event.entries.map((entry) => entry.content).join(" | ") + : "No plan steps"; + appendAcpEvent({ + category: "plan", + label: `Plan updated (${event.entries.length} steps)`, + detail: summary, + state: "info", + }); + break; + } + case "usage_update": { + break; + } + case "status_changed": + useAIChatStore.getState().actions.setAcpStatus(event.status); + break; // internal state sync + case "error": + appendAcpEvent({ + category: "error", + label: "Agent error", + detail: event.error, + state: "error", + }); + break; + case "ui_action": + break; // Handled by acp-handler + } + }, + chatState.mode, + chatState.outputStyle, + (data: string, mediaType: string) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + images: [...(currentMessage?.images || []), { data, mediaType }], + }), + ); + }, + (uri: string, name: string | null) => { + updateStreamingAssistantMessage( + targetChatId, + currentAssistantMessageId, + (currentMessage) => ({ + resources: [...(currentMessage?.resources || []), { uri, name }], + }), + ); + }, + targetChatId, + ); + } catch (error) { + console.error("Failed to start streaming:", error); + chatActions.updateMessage(targetChatId, assistantMessageId, { + content: + "Error: Failed to connect to Agent service. Please check your API key and try again.", + isStreaming: false, + }); + setIsSurfaceTyping(false); + setSurfaceStreamingMessageId(null); + abortControllerRef.current = null; + } + }; + + const processQueuedMessages = useCallback(async () => { + if (isSurfaceTyping || surfaceStreamingMessageId) { + return; + } + + const nextMessage = messageQueueRef.current.shift(); + setQueueCount(messageQueueRef.current.length); + if (nextMessage) { + console.log("Processing next queued message:", nextMessage); + await new Promise((resolve) => setTimeout(resolve, 500)); + await processMessage(nextMessage); + } + }, [isSurfaceTyping, surfaceStreamingMessageId]); + + const sendMessage = useCallback( + async (messageContent: string, images?: ImageContent[]) => { + const isAcp = isAcpAgent(currentAgentId); + // For ACP agents, we don't need an API key. + if (!messageContent.trim() || (!isAcp && !chatState.hasApiKey)) return; + + if (isSurfaceTyping || surfaceStreamingMessageId) { + messageQueueRef.current.push(messageContent); + setQueueCount(messageQueueRef.current.length); + return; + } + + await processMessage(messageContent, images); + }, + [chatState.hasApiKey, currentAgentId, isSurfaceTyping, surfaceStreamingMessageId], + ); + + const handleSendMessage = useCallback( + async (messageContent: string, images?: ImageContent[]) => { + await sendMessage(messageContent, images); + }, + [sendMessage], + ); + + const handleEditUserMessage = async (messageId: string, content: string) => { + if (isSurfaceTyping || surfaceStreamingMessageId || currentChat?.agentId !== "custom") { + return; + } + + await processMessage(content, [], { editedUserMessageId: messageId }); + }; + + useEffect(() => { + const pendingLaunch = chatState.pendingAgentLaunchRequest; + if (!pendingLaunch) return; + if (pendingLaunch.chatId !== effectiveChatId) return; + if (activeBuffer?.type !== "agent") return; + if (activeBuffer.sessionId !== pendingLaunch.chatId) return; + if (isSurfaceTyping || surfaceStreamingMessageId) return; + if (!isAcpAgent(pendingLaunch.agentId) && !chatState.hasApiKey) return; + + setSelectedBufferIds(new Set(pendingLaunch.selectedBufferIds)); + setSelectedFilesPaths(new Set(pendingLaunch.selectedFilesPaths)); + chatActions.setPendingAgentLaunchRequest(null); + void sendMessage(pendingLaunch.prompt); + }, [ + chatActions, + effectiveChatId, + chatState.hasApiKey, + isSurfaceTyping, + chatState.pendingAgentLaunchRequest, + surfaceStreamingMessageId, + activeBuffer, + sendMessage, + ]); + + const currentPermission = permissionQueue[0]; + const isNewSession = (currentChat?.messages.length ?? 0) === 0 && acpEvents.length === 0; + const useInitialComposer = isNewSession && !currentPermission; + const handlePermission = async (approved: boolean, optionId?: string) => { + if (!currentPermission) return; + try { + const option = currentPermission.options.find((item) => item.id === optionId); + appendAcpEvent({ + id: `permission-response-${currentPermission.requestId}`, + category: "permission", + label: "Permission response", + detail: option?.name || (approved ? "allow" : "deny"), + state: approved ? "success" : "info", + }); + if (currentAgentId === CODEX_INTEGRATION_ID) { + await CodexIntegrationService.respond(currentPermission.requestId, approved); + } else { + await AcpStreamHandler.respondToPermission( + currentPermission.requestId, + approved, + false, + optionId, + ); + } + } finally { + setPermissionQueue((prev) => prev.slice(1)); + } + }; + + return ( +
+ { + if (isMessageSearchOpen) { + closeMessageSearch(); + return; + } + + setIsMessageSearchOpen(true); + }} + onCloseMessageSearch={closeMessageSearch} + onMessageSearchQueryChange={setMessageSearchQuery} + messageSearchMatchCount={messageSearchMatches.length} + activeMessageSearchIndex={activeMessageSearchIndex} + onPreviousMessageSearchMatch={goToPreviousMessageSearchMatch} + onNextMessageSearchMatch={goToNextMessageSearchMatch} + /> + {isAiChatBlockedByPolicy ? ( + + + Agent is disabled + + Your organization policy has disabled Agent for this workspace. + + + + ) : ( + <> + {useInitialComposer ? ( + + + setSelectedBufferIds((current) => { + const next = new Set(current); + if (next.has(bufferId)) next.delete(bufferId); + else next.add(bufferId); + return next; + }) + } + onToggleFileSelection={(filePath) => + setSelectedFilesPaths((current) => { + const next = new Set(current); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }) + } + onSetSelectedBufferIds={setSelectedBufferIds} + onSetSelectedFilesPaths={setSelectedFilesPaths} + isActiveSurface={isActiveSurface} + presentation="initial" + onSendMessage={handleSendMessage} + onStopStreaming={stopStreaming} + /> + + ) : ( + + + + + + + + + )} + + {currentPermission ? ( + + ) : null} + + {!useInitialComposer ? ( + + setSelectedBufferIds((current) => { + const next = new Set(current); + if (next.has(bufferId)) next.delete(bufferId); + else next.add(bufferId); + return next; + }) + } + onToggleFileSelection={(filePath) => + setSelectedFilesPaths((current) => { + const next = new Set(current); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }) + } + onSetSelectedBufferIds={setSelectedBufferIds} + onSetSelectedFilesPaths={setSelectedFilesPaths} + isActiveSurface={isActiveSurface} + onSendMessage={handleSendMessage} + onStopStreaming={stopStreaming} + /> + ) : null} + + )} +
+ ); }); export default AIChat; diff --git a/src/features/ai/components/input/chat-input-bar.tsx b/src/features/ai/components/input/chat-input-bar.tsx index ec47ea7b5..14efb1168 100644 --- a/src/features/ai/components/input/chat-input-bar.tsx +++ b/src/features/ai/components/input/chat-input-bar.tsx @@ -1,65 +1,59 @@ import { - CommandIcon, - ArrowUpIcon as ArrowUp, - DatabaseIcon as Database, - FileTextIcon as FileText, - MicrophoneIcon as Mic, - StopIcon as Stop, - XIcon as X, + CommandIcon, + ArrowUpIcon as ArrowUp, + DatabaseIcon as Database, + FileTextIcon as FileText, + MicrophoneIcon as Mic, + StopIcon as Stop, + XIcon as X, } from "@/ui/icons"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { shouldIgnoreFile } from "@/features/quick-open/utils/file-filtering"; import { - AI_CHAT_INSERT_SKILL_EVENT, - type AIChatSkillInsertDetail, + AI_CHAT_INSERT_SKILL_EVENT, + type AIChatSkillInsertDetail, } from "@/features/ai/lib/skill-events"; import { useAIChatStore } from "@/features/ai/stores/ai-chat.store"; import { useVoiceInput } from "@/features/ai/hooks/use-voice-input"; import { - getComposerDropdownPosition, - getComposerText, - getComposerTextBeforeCaret, - getComposerTextRange, - isComposerTokenElement, + getComposerDropdownPosition, + getComposerText, + getComposerTextBeforeCaret, + getComposerTextRange, + isComposerTokenElement, } from "@/features/ai/utils/chat-composer-dom"; -import type { - InlineDropdownPosition, - PastedImage, -} from "@/features/ai/types/chat-composer.types"; +import type { InlineDropdownPosition, PastedImage } from "@/features/ai/types/chat-composer.types"; import type { AIChatSkill } from "@/features/ai/types/skills.types"; import type { SlashCommand } from "@/features/ai/types/acp.types"; -import type { - AIChatInputBarProps, - ImageContent, -} from "@/features/ai/types/ai-chat.types"; +import type { AIChatInputBarProps, ImageContent } from "@/features/ai/types/ai-chat.types"; import type { FileEntry } from "@/features/file-system/types/app.types"; import { getProviderById } from "@/features/ai/types/providers.types"; import { openSidebarResourceBuffer } from "@/features/sidebar/utils/open-sidebar-resource"; import { - hasSidebarResourceDragData, - readSidebarResourceDragData, - SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, - type SidebarDragResource, + hasSidebarResourceDragData, + readSidebarResourceDragData, + SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, + type SidebarDragResource, } from "@/features/sidebar/utils/sidebar-resource-drag"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { - Attachment, - AttachmentAction, - AttachmentActions, - AttachmentContent, - AttachmentGroup, - AttachmentMedia, - AttachmentTitle, + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, } from "@/ui/attachment"; import Badge from "@/ui/badge"; import { Button } from "@/ui/button"; import { Toggle } from "@/ui/toggle"; import { cn } from "@/utils/cn"; import { - ChatComposer, - ChatComposerBody, - ChatComposerEditable, - ChatComposerToolbar, + ChatComposer, + ChatComposerBody, + ChatComposerEditable, + ChatComposerToolbar, } from "./chat-composer"; import { ChatPreferencesMenu } from "./chat-preferences-menu"; import { FileMentionDropdown } from "../mentions/file-mention-dropdown"; @@ -67,1329 +61,1227 @@ import { SlashCommandDropdown } from "../mentions/slash-command-dropdown"; import { ContextSelector } from "../selectors/context-selector"; const AIChatInputBar = memo(function AIChatInputBar({ - buffers, - allProjectFiles, - surfaceId, - currentAgentId, - isTyping, - streamingMessageId, - queueCount, - selectedBufferIds, - selectedFilesPaths, - onToggleBufferSelection, - onToggleFileSelection, - onSetSelectedBufferIds, - onSetSelectedFilesPaths, - isActiveSurface = true, - presentation = "default", - autoFocus = false, - onAgentChange, - onSendMessage, - onStopStreaming, + buffers, + allProjectFiles, + surfaceId, + currentAgentId, + isTyping, + streamingMessageId, + queueCount, + selectedBufferIds, + selectedFilesPaths, + onToggleBufferSelection, + onToggleFileSelection, + onSetSelectedBufferIds, + onSetSelectedFilesPaths, + isActiveSurface = true, + presentation = "default", + autoFocus = false, + onAgentChange, + onSendMessage, + onStopStreaming, }: AIChatInputBarProps) { - const inputRef = useRef(null); - const contextTriggerRef = useRef(null); - const aiChatContainerRef = useRef(null); - const isUpdatingContentRef = useRef(false); - const visibleMentionFilesRef = useRef([]); - const performanceTimer = useRef(null); - - // Local state for input emptiness check (to avoid subscribing to full input text) - const [hasInputText, setHasInputText] = useState(false); - const [isContextDragOver, setIsContextDragOver] = useState(false); - const [isComposerFocused, setIsComposerFocused] = useState(false); - const inputValueRef = useRef(""); - const [pastedImages, setPastedImages] = useState([]); - const [isContextDropdownOpen, setIsContextDropdownOpen] = useState(false); - const [mentionState, setMentionState] = useState({ - active: false, - position: { top: 0, bottom: 0, left: 0, width: 0 }, - search: "", - startIndex: 0, - selectedIndex: 0, - }); - const [slashCommandState, setSlashCommandState] = useState({ - active: false, - position: { top: 0, bottom: 0, left: 0, width: 0 }, - search: "", - selectedIndex: 0, - }); - const slashCommandRangeRef = useRef({ startIndex: 0, endIndex: 0 }); - - const hasApiKey = useAIChatStore((state) => state.hasApiKey); - const sessionConfigOptions = useAIChatStore( - (state) => state.sessionConfigOptions, - ); - const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); - const aiModelId = useSettingsStore((state) => state.settings.aiModelId); - const aiCustomModelId = useSettingsStore( - (state) => state.settings.aiCustomModelId, - ); - const aiAutocompleteCustomModelId = useSettingsStore( - (state) => state.settings.aiAutocompleteCustomModelId, - ); - const updateSetting = useSettingsStore( - (state) => state.actions.updateSetting, - ); - - // Check if current agent is "custom" (only show model selector for custom agent) - const isCustomAgent = currentAgentId === "custom"; - - // ACP agents don't need API key (they handle their own auth) - const isInputEnabled = isCustomAgent ? hasApiKey : true; - const isStreaming = isTyping && !!streamingMessageId; - const changeSessionConfigOption = useAIChatStore( - (state) => state.actions.changeSessionConfigOption, - ); - - const handleAthasProviderChange = useCallback( - (nextProviderId: string) => { - const provider = getProviderById(nextProviderId); - void updateSetting("aiProviderId", nextProviderId); - if (nextProviderId === "custom") { - void updateSetting( - "aiModelId", - aiCustomModelId || aiAutocompleteCustomModelId, - ); - return; - } - if (provider && provider.models.length > 0) { - void updateSetting("aiModelId", provider.models[0].id); - } - }, - [aiAutocompleteCustomModelId, aiCustomModelId, updateSetting], - ); - - const handleAthasModelChange = useCallback( - (nextModelId: string) => { - if (aiProviderId === "custom") { - void updateSetting("aiCustomModelId", nextModelId); - } - void updateSetting("aiModelId", nextModelId); - }, - [aiProviderId, updateSetting], - ); - - const availableSlashCommands = useAIChatStore( - (state) => state.availableSlashCommands, - ); - const filteredSlashCommands = useMemo(() => { - const search = slashCommandState.search.trim().toLowerCase(); - if (!search) return availableSlashCommands; - return availableSlashCommands.filter( - (command) => - command.name.toLowerCase().includes(search) || - command.description?.toLowerCase().includes(search), - ); - }, [availableSlashCommands, slashCommandState.search]); - - const setInput = useCallback((input: string) => { - inputValueRef.current = input; - }, []); - const addPastedImage = useCallback((image: PastedImage) => { - setPastedImages((current) => [...current, image]); - }, []); - const removePastedImage = useCallback((imageId: string) => { - setPastedImages((current) => - current.filter((image) => image.id !== imageId), - ); - }, []); - const clearPastedImages = useCallback(() => setPastedImages([]), []); - const toggleBufferSelection = onToggleBufferSelection; - const toggleFileSelection = onToggleFileSelection; - const setSelectedBufferIds = onSetSelectedBufferIds; - const setSelectedFilesPaths = onSetSelectedFilesPaths; - const showMention = useCallback( - (position: InlineDropdownPosition, search: string, startIndex: number) => { - setMentionState({ - active: true, - position, - search, - startIndex, - selectedIndex: 0, - }); - }, - [], - ); - const hideMention = useCallback(() => { - setMentionState((current) => ({ ...current, active: false })); - }, []); - const updatePosition = useCallback((position: InlineDropdownPosition) => { - setMentionState((current) => ({ ...current, position })); - }, []); - const setSelectedIndex = useCallback((selectedIndex: number) => { - setMentionState((current) => ({ ...current, selectedIndex })); - }, []); - const showSlashCommands = useCallback( - (position: InlineDropdownPosition, search: string) => { - setSlashCommandState({ - active: true, - position, - search, - selectedIndex: 0, - }); - }, - [], - ); - const hideSlashCommands = useCallback(() => { - setSlashCommandState((current) => ({ ...current, active: false })); - }, []); - const selectNextSlashCommand = useCallback(() => { - setSlashCommandState((current) => ({ - ...current, - selectedIndex: Math.min( - current.selectedIndex + 1, - Math.max(filteredSlashCommands.length - 1, 0), - ), - })); - }, [filteredSlashCommands.length]); - const selectPreviousSlashCommand = useCallback(() => { - setSlashCommandState((current) => ({ - ...current, - selectedIndex: Math.max(current.selectedIndex - 1, 0), - })); - }, []); - const setSlashCommandSelectedIndex = useCallback((selectedIndex: number) => { - setSlashCommandState((current) => ({ ...current, selectedIndex })); - }, []); - - const closeComposerPopovers = useCallback(() => { - if (slashCommandState.active) { - hideSlashCommands(); - } - if (isContextDropdownOpen) { - setIsContextDropdownOpen(false); - } - if (mentionState.active) { - hideMention(); - } - }, [ - slashCommandState.active, - hideSlashCommands, - isContextDropdownOpen, - setIsContextDropdownOpen, - mentionState.active, - hideMention, - ]); - - const closeInlineMenus = useCallback(() => { - closeComposerPopovers(); - }, [closeComposerPopovers]); - - const addBufferToContext = useCallback( - (bufferId: string) => { - if (selectedBufferIds.has(bufferId)) return; - const nextSelectedBufferIds = new Set(selectedBufferIds); - nextSelectedBufferIds.add(bufferId); - setSelectedBufferIds(nextSelectedBufferIds); - }, - [selectedBufferIds, setSelectedBufferIds], - ); - - const addPathToContext = useCallback( - (filePath: string) => { - if (selectedFilesPaths.has(filePath)) return; - const nextSelectedFilesPaths = new Set(selectedFilesPaths); - nextSelectedFilesPaths.add(filePath); - setSelectedFilesPaths(nextSelectedFilesPaths); - }, - [selectedFilesPaths, setSelectedFilesPaths], - ); - - const addSidebarResourceToContext = useCallback( - async (resource: SidebarDragResource) => { - if (resource.type === "file") { - const matchingBuffer = !resource.isDir - ? buffers.find((buffer) => buffer.path === resource.path) - : null; - if (matchingBuffer) { - addBufferToContext(matchingBuffer.id); - } else { - addPathToContext(resource.path); - } - return; - } - - if (resource.type === "git-worktree") { - addPathToContext(resource.path); - return; - } - - const bufferId = await openSidebarResourceBuffer(resource); - if (bufferId) { - addBufferToContext(bufferId); - } - }, - [addBufferToContext, addPathToContext, buffers], - ); - - useEffect(() => { - const handleSidebarResourceDropOnAI = (event: Event) => { - if (!isActiveSurface || surfaceId !== "activity-sidebar") return; - const resource = ( - event as CustomEvent<{ resource?: SidebarDragResource }> - ).detail?.resource; - if (!resource) return; - void addSidebarResourceToContext(resource); - }; - - window.addEventListener( - SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, - handleSidebarResourceDropOnAI, - ); - return () => - window.removeEventListener( - SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, - handleSidebarResourceDropOnAI, - ); - }, [addSidebarResourceToContext, isActiveSurface, surfaceId]); - - const handleContextDragOver = useCallback( - (event: React.DragEvent) => { - if (!hasSidebarResourceDragData(event.dataTransfer)) return; - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = "copy"; - setIsContextDragOver(true); - }, - [], - ); - - const handleContextDragLeave = useCallback( - (event: React.DragEvent) => { - const relatedTarget = event.relatedTarget as HTMLElement | null; - if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) { - setIsContextDragOver(false); - } - }, - [], - ); - - const handleContextDrop = useCallback( - async (event: React.DragEvent) => { - const resource = readSidebarResourceDragData(event.dataTransfer); - if (!resource) return; - - event.preventDefault(); - event.stopPropagation(); - setIsContextDragOver(false); - await addSidebarResourceToContext(resource); - }, - [addSidebarResourceToContext], - ); - - // Computed state for send button - const hasImages = pastedImages.length > 0; - const isSendDisabled = isStreaming - ? false - : (!hasInputText && !hasImages) || !isInputEnabled; - const getPlainTextFromDiv = useCallback( - () => getComposerText(inputRef.current), - [], - ); - const getTextBeforeCaret = useCallback( - () => getComposerTextBeforeCaret(inputRef.current), - [], - ); - const getCaretDropdownPosition = useCallback( - () => getComposerDropdownPosition(inputRef.current), - [], - ); - - const getMentionDropdownPosition = useCallback(() => { - const position = getCaretDropdownPosition(); - if (!inputRef.current) return position; - - const inputRect = inputRef.current.getBoundingClientRect(); - return { - ...position, - width: Math.min(360, Math.max(220, inputRect.width - 24)), - }; - }, [getCaretDropdownPosition]); - const getSlashDropdownPosition = useCallback(() => { - const position = getCaretDropdownPosition(); - if (!inputRef.current) return position; - - const inputRect = inputRef.current.getBoundingClientRect(); - return { - ...position, - width: Math.min(320, Math.max(180, inputRect.width - 24)), - }; - }, [getCaretDropdownPosition]); - - const syncInputFromEditable = useCallback(() => { - const newPlainText = getPlainTextFromDiv(); - setInput(newPlainText); - setHasInputText(newPlainText.trim().length > 0); - return newPlainText; - }, [getPlainTextFromDiv, setInput]); - - const removeComposerToken = useCallback( - (token: Element) => { - const parent = token.parentNode; - const nextSibling = token.nextSibling; - token.remove(); - if ( - nextSibling?.nodeType === Node.TEXT_NODE && - (nextSibling.textContent === "\u200B" || - nextSibling.textContent === " ") - ) { - nextSibling.remove(); - } - - syncInputFromEditable(); - - if (!parent) return; - - const selection = window.getSelection(); - if (!selection) return; - - const range = document.createRange(); - if (nextSibling?.parentNode === parent) { - range.setStartBefore(nextSibling); - } else { - range.selectNodeContents(parent); - range.collapse(false); - } - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - }, - [syncInputFromEditable], - ); - - // Function to recalculate mention dropdown position - const recalculateMentionPosition = useCallback(() => { - if (!mentionState.active) return; - updatePosition(getMentionDropdownPosition()); - }, [mentionState.active, updatePosition, getMentionDropdownPosition]); - - const mentionableFiles = useMemo( - () => - allProjectFiles.filter( - (file) => !file.isDir && !shouldIgnoreFile(file.path), - ), - [allProjectFiles], - ); - - const selectedContextItems = useMemo(() => { - const bufferSelections = buffers - .filter( - (buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id), - ) - .map((buffer) => ({ - type: "buffer" as const, - id: buffer.id, - name: buffer.name, - databaseType: - buffer.type === "database" ? buffer.databaseType : undefined, - isDirty: buffer.type === "editor" && buffer.isDirty, - })); - - const fileSelections = Array.from(selectedFilesPaths).map((filePath) => ({ - type: "file" as const, - id: filePath, - name: filePath.split("/").pop() || "Unknown", - path: filePath, - })); - - return [...bufferSelections, ...fileSelections]; - }, [buffers, selectedBufferIds, selectedFilesPaths]); - - // ResizeObserver to track container size changes - useEffect(() => { - if (!aiChatContainerRef.current) return; - - const resizeObserver = new ResizeObserver(() => { - recalculateMentionPosition(); - if (slashCommandState.active) { - showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); - } - }); - - resizeObserver.observe(aiChatContainerRef.current); - - // Also observe the window resize - const handleWindowResize = () => { - recalculateMentionPosition(); - if (slashCommandState.active) { - showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); - } - }; - - window.addEventListener("resize", handleWindowResize); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", handleWindowResize); - // Cleanup timers - if (performanceTimer.current) { - clearTimeout(performanceTimer.current); - } - }; - }, [ - recalculateMentionPosition, - slashCommandState.active, - slashCommandState.search, - showSlashCommands, - getSlashDropdownPosition, - ]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle slash command navigation - if (slashCommandState.active) { - if (e.key === "ArrowDown") { - e.preventDefault(); - selectNextSlashCommand(); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - selectPreviousSlashCommand(); - } else if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - if (filteredSlashCommands[slashCommandState.selectedIndex]) { - handleSlashCommandSelect( - filteredSlashCommands[slashCommandState.selectedIndex], - ); - } - } else if (e.key === "Escape") { - e.preventDefault(); - hideSlashCommands(); - } - } else if (mentionState.active) { - if (e.key === "ArrowDown") { - e.preventDefault(); - const lastIndex = visibleMentionFilesRef.current.length - 1; - setSelectedIndex( - lastIndex < 0 - ? 0 - : Math.min(mentionState.selectedIndex + 1, lastIndex), - ); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex(Math.max(mentionState.selectedIndex - 1, 0)); - } else if (e.key === "Enter" || e.key === "Tab") { - e.preventDefault(); - const visibleFiles = visibleMentionFilesRef.current; - if (visibleFiles[mentionState.selectedIndex]) { - handleFileMentionSelect(visibleFiles[mentionState.selectedIndex]); - } - } else if (e.key === "Escape") { - e.preventDefault(); - hideMention(); - } - } else if (e.key === "Backspace" || e.key === "Delete") { - // Handle composer token deletion - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0 && inputRef.current) { - const range = selection.getRangeAt(0); - if (!range.collapsed) return; - - const container = range.startContainer; - const offset = range.startOffset; - let tokenToRemove: Element | null = null; - const isBackwardDelete = e.key === "Backspace"; - - if (container === inputRef.current) { - const candidateIndex = isBackwardDelete ? offset - 1 : offset; - const candidateNode = - inputRef.current.childNodes[candidateIndex] ?? null; - if (isComposerTokenElement(candidateNode)) { - tokenToRemove = candidateNode; - } - } - - // Check if cursor is at the beginning of a text node that follows a composer token - if (!tokenToRemove && container.nodeType === Node.TEXT_NODE) { - const textContent = container.textContent || ""; - const candidateSibling = - isBackwardDelete && offset === 0 - ? container.previousSibling - : !isBackwardDelete && offset === textContent.length - ? container.nextSibling - : null; - - if (isComposerTokenElement(candidateSibling)) { - tokenToRemove = candidateSibling; - } - } - - // Check if cursor is right after a composer token (in separator text node) - if ( - isBackwardDelete && - !tokenToRemove && - container.nodeType === Node.TEXT_NODE && - container.textContent === "\u200B" && - offset === 1 - ) { - const previousSibling = - container.previousSibling?.previousSibling ?? null; // Skip the space node - - if (isComposerTokenElement(previousSibling)) { - tokenToRemove = previousSibling; - } - } - - if (tokenToRemove) { - e.preventDefault(); - removeComposerToken(tokenToRemove); - return; - } - } - } else if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); - } - }; - - // Debounced mention detection - increased delay for better performance - const debouncedMentionDetection = useCallback(() => { - if (performanceTimer.current) { - clearTimeout(performanceTimer.current); - } - - performanceTimer.current = window.setTimeout(() => { - if (!inputRef.current) return; - - const textBeforeCaret = getTextBeforeCaret(); - const lastAtIndex = textBeforeCaret.lastIndexOf("@"); - - if (lastAtIndex !== -1) { - const afterAt = textBeforeCaret.slice(lastAtIndex + 1); - // Check if there's no space between @ and end, and it's not part of a mention badge - if ( - !afterAt.includes(" ") && - !afterAt.includes("]") && - afterAt.length < 50 - ) { - const position = getMentionDropdownPosition(); - showMention(position, afterAt, lastAtIndex); - } else { - hideMention(); - } - } else { - hideMention(); - } - }, 150); // Increased to 150ms for better performance - }, [ - showMention, - hideMention, - getMentionDropdownPosition, - getTextBeforeCaret, - ]); - - // Optimized input change handler - no throttle for immediate response - const handleInputChange = useCallback(() => { - if (!inputRef.current || isUpdatingContentRef.current) return; - - const plainTextFromDiv = getPlainTextFromDiv(); - - // Keep keystrokes local to this composer so sibling surfaces cannot mirror them. - const currentInput = inputValueRef.current; - - // Only update if content actually changed - if (plainTextFromDiv !== currentInput) { - setInput(plainTextFromDiv); - - // Update local state for button enabled/disabled - setHasInputText(plainTextFromDiv.trim().length > 0); - - const textBeforeCaret = getTextBeforeCaret(); - const slashMatch = textBeforeCaret.match(/(?:^|\s)\/([^\s/]*)$/); - if (slashMatch && slashMatch[1].length < 50) { - const search = slashMatch[1]; - const startIndex = textBeforeCaret.length - search.length - 1; - slashCommandRangeRef.current = { - startIndex, - endIndex: textBeforeCaret.length, - }; - if (isContextDropdownOpen) { - setIsContextDropdownOpen(false); - } - showSlashCommands(getSlashDropdownPosition(), search); - } else if (slashCommandState.active) { - hideSlashCommands(); - } - - // Only do mention detection if text contains @ and is reasonably short - if (plainTextFromDiv.includes("@") && plainTextFromDiv.length < 500) { - debouncedMentionDetection(); - } else if (mentionState.active) { - hideMention(); - } - } - }, [ - setInput, - getPlainTextFromDiv, - getTextBeforeCaret, - debouncedMentionDetection, - hideMention, - mentionState.active, - showSlashCommands, - hideSlashCommands, - slashCommandState.active, - getSlashDropdownPosition, - isContextDropdownOpen, - setIsContextDropdownOpen, - ]); - - const handleEditableMouseDown = useCallback( - (event: React.MouseEvent) => { - if (!inputRef.current) return; - - const target = event.target as HTMLElement | null; - const token = target?.closest("[data-mention],[data-slash-command]"); - if (!token || !inputRef.current.contains(token)) return; - - event.preventDefault(); - inputRef.current.focus(); - - const selection = window.getSelection(); - if (!selection) return; - - const range = document.createRange(); - range.setStartAfter(token); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - }, - [], - ); - - const insertTextAtCursor = useCallback( - (text: string) => { - if (!inputRef.current || !text) return; - - const normalizedText = text.replace(/\s+/g, " ").trim(); - if (!normalizedText) return; - - const selection = window.getSelection(); - const range = document.createRange(); - const currentText = getPlainTextFromDiv(); - const prefix = - currentText.trim().length > 0 && !/\s$/.test(currentText) ? " " : ""; - const textNode = document.createTextNode(`${prefix}${normalizedText} `); - - inputRef.current.focus(); - - const selectionInsideInput = - !!selection && - selection.rangeCount > 0 && - inputRef.current.contains(selection.anchorNode); - - if (selectionInsideInput && selection) { - const selectedRange = selection.getRangeAt(0); - selectedRange.deleteContents(); - selectedRange.insertNode(textNode); - range.setStartAfter(textNode); - } else { - range.selectNodeContents(inputRef.current); - range.collapse(false); - range.insertNode(textNode); - range.setStartAfter(textNode); - } - - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - handleInputChange(); - }, - [getPlainTextFromDiv, handleInputChange], - ); - - const insertSkillAtCursor = useCallback( - (skill: AIChatSkill) => { - if (!inputRef.current || !skill.content.trim()) return; - - const selection = window.getSelection(); - const range = document.createRange(); - const currentText = getPlainTextFromDiv(); - const prefix = - currentText.trim().length > 0 && !/\s$/.test(currentText) ? "\n\n" : ""; - const textNode = document.createTextNode( - `${prefix}${skill.content.trim()} `, - ); - - inputRef.current.focus(); - - const selectionInsideInput = - !!selection && - selection.rangeCount > 0 && - inputRef.current.contains(selection.anchorNode); - - if (selectionInsideInput && selection) { - const selectedRange = selection.getRangeAt(0); - selectedRange.deleteContents(); - selectedRange.insertNode(textNode); - range.setStartAfter(textNode); - } else { - range.selectNodeContents(inputRef.current); - range.collapse(false); - range.insertNode(textNode); - range.setStartAfter(textNode); - } - - range.collapse(true); - selection?.removeAllRanges(); - selection?.addRange(range); - handleInputChange(); - setHasInputText(true); - }, - [getPlainTextFromDiv, handleInputChange], - ); - - useEffect(() => { - const handleInsertSkill = (event: Event) => { - const detail = (event as CustomEvent).detail; - if (!isActiveSurface || detail?.surfaceId !== surfaceId) return; - insertSkillAtCursor(detail.skill); - }; - - window.addEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); - return () => - window.removeEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); - }, [insertSkillAtCursor, isActiveSurface]); - - // Handle paste - strip HTML formatting, keep only plain text. Images are added to preview. - const handlePaste = useCallback( - (e: React.ClipboardEvent) => { - const clipboardData = e.clipboardData; - if (!clipboardData) return; - - // Check for images first - const items = clipboardData.items; - let hasImage = false; - - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith("image/")) { - hasImage = true; - e.preventDefault(); - - const file = items[i].getAsFile(); - if (file) { - const reader = new FileReader(); - reader.onload = (event) => { - const dataUrl = event.target?.result as string; - if (dataUrl) { - addPastedImage({ - id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - dataUrl, - name: file.name || `image-${Date.now()}.png`, - size: file.size, - }); - } - }; - reader.readAsDataURL(file); - } - } - } - - // If there was an image, don't process text - if (hasImage) return; - - // For text content, prevent default and insert plain text only - e.preventDefault(); - - // Get plain text from clipboard - const plainText = clipboardData.getData("text/plain"); - if (!plainText) return; - - // Insert plain text at cursor position - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return; - - const range = selection.getRangeAt(0); - range.deleteContents(); - - const textNode = document.createTextNode(plainText); - range.insertNode(textNode); - - // Move cursor to end of inserted text - range.setStartAfter(textNode); - range.setEndAfter(textNode); - selection.removeAllRanges(); - selection.addRange(range); - - // Trigger input change handler to update state - handleInputChange(); - }, - [handleInputChange, addPastedImage], - ); - - // Handle file mention selection - const handleFileMentionSelect = useCallback( - (file: FileEntry) => { - if (!inputRef.current) return; - - isUpdatingContentRef.current = true; - hideMention(); - const mentionRange = getComposerTextRange( - inputRef.current, - mentionState.startIndex, - mentionState.startIndex + mentionState.search.length + 1, - ); - mentionRange.deleteContents(); - - const mentionSpan = document.createElement("span"); - mentionSpan.setAttribute("data-mention", "true"); - mentionSpan.setAttribute("data-mention-name", file.name); - mentionSpan.setAttribute("data-mention-path", file.path); - mentionSpan.setAttribute("contenteditable", "false"); - mentionSpan.title = file.path; - mentionSpan.className = - "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-primary/10 px-1.5 py-0.5 leading-row text-primary align-baseline select-none"; - mentionSpan.textContent = file.name; - - const trailingSpace = document.createTextNode(" "); - const fragment = document.createDocumentFragment(); - fragment.append(mentionSpan, trailingSpace); - mentionRange.insertNode(fragment); - - const selection = window.getSelection(); - if (selection) { - const caretRange = document.createRange(); - caretRange.setStart(trailingSpace, trailingSpace.length); - caretRange.collapse(true); - selection.removeAllRanges(); - selection.addRange(caretRange); - } - - inputRef.current.focus(); - syncInputFromEditable(); - isUpdatingContentRef.current = false; - }, - [ - hideMention, - mentionState.search.length, - mentionState.startIndex, - syncInputFromEditable, - ], - ); - - // Handle slash command selection - const handleSlashCommandSelect = useCallback( - (command: SlashCommand) => { - if (!inputRef.current) return; - - isUpdatingContentRef.current = true; - const { startIndex, endIndex } = slashCommandRangeRef.current; - hideSlashCommands(); - const commandRange = getComposerTextRange( - inputRef.current, - startIndex, - endIndex, - ); - commandRange.deleteContents(); - - const commandSpan = document.createElement("span"); - commandSpan.setAttribute("data-slash-command", "true"); - commandSpan.setAttribute("data-slash-command-name", command.name); - commandSpan.setAttribute("contenteditable", "false"); - commandSpan.title = command.description || `/${command.name}`; - commandSpan.className = - "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-accent/70 px-1.5 py-0.5 leading-row text-foreground align-baseline select-none"; - commandSpan.textContent = `/${command.name}`; - - const trailingSpace = document.createTextNode(" "); - const fragment = document.createDocumentFragment(); - fragment.append(commandSpan, trailingSpace); - commandRange.insertNode(fragment); - - const selection = window.getSelection(); - if (selection) { - const caretRange = document.createRange(); - caretRange.setStart(trailingSpace, trailingSpace.length); - caretRange.collapse(true); - selection.removeAllRanges(); - selection.addRange(caretRange); - } - - inputRef.current.focus(); - syncInputFromEditable(); - isUpdatingContentRef.current = false; - }, - [hideSlashCommands, syncInputFromEditable], - ); - - const handleSendMessage = async () => { - const currentInput = inputValueRef.current; - const currentImages = pastedImages; - const hasContent = currentInput.trim() || currentImages.length > 0; - if (!hasContent || !isInputEnabled) return; - - // Clear input and images immediately after send is triggered - setInput(""); - setHasInputText(false); - clearPastedImages(); - if (inputRef.current) { - inputRef.current.innerHTML = ""; - } - - // Convert pasted images to ImageContent format - const images: ImageContent[] = currentImages.map((img) => ({ - data: img.dataUrl.split(",")[1] || img.dataUrl, - mediaType: img.dataUrl.split(";")[0]?.split(":")[1] || "image/png", - })); - - // Send the captured message with images - await onSendMessage(currentInput, images); - }; - - const focusInput = useCallback(() => inputRef.current?.focus(), []); - const { - interimTranscript, - isListening, - isMacDevBlocked: isMacDevSpeechRecognitionBlocked, - isSupported: isSpeechRecognitionSupported, - toggle: toggleVoiceInput, - } = useVoiceInput({ - enabled: isInputEnabled, - insertText: insertTextAtCursor, - focusInput, - }); - - const hasSlashCommands = availableSlashCommands.length > 0; - const isInitialPresentation = presentation === "initial"; - const inputPlaceholder = isInputEnabled - ? isInitialPresentation - ? "What do you want to create?" - : hasSlashCommands - ? "Ask anything... (@ files, / commands)" - : "Ask anything... (@ to mention files)" - : "Configure API key to enable Agent..."; - - useEffect(() => { - if (!autoFocus || !isActiveSurface) return; - - const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); - return () => window.cancelAnimationFrame(frame); - }, [autoFocus, isActiveSurface]); - - return ( - - - {pastedImages.length > 0 && ( - - {pastedImages.map((image) => ( - - - {image.name} - - - {image.name} - - - removePastedImage(image.id)} - aria-label={`Remove ${image.name}`} - > - - - - - ))} - - )} - - setIsComposerFocused(true)} - onBlur={() => setIsComposerFocused(false)} - onPaste={handlePaste} - data-placeholder={inputPlaceholder} - className={cn( - isInitialPresentation && - "max-h-48 min-h-28 overflow-y-auto px-4 py-4 ui-text-base", - )} - role="textbox" - aria-multiline={!isInitialPresentation} - aria-label="Message input" - tabIndex={isInputEnabled ? 0 : -1} - /> - - -
- { - if (open) { - closeInlineMenus(); - } - setIsContextDropdownOpen(open); - }} - /> -
- - {queueCount > 0 && ( - - - {queueCount} - - )} - -
- {hasSlashCommands && ( - - )} - - - void changeSessionConfigOption(optionId, value) - } - onSelectSkill={insertSkillAtCursor} - onBeforeOpen={closeInlineMenus} - /> - - - - - - -
-
- - {selectedContextItems.length > 0 ? ( - - {selectedContextItems.map((item) => ( - { - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - event.preventDefault(); - const chips = Array.from( - event.currentTarget.parentElement?.querySelectorAll( - "[data-context-chip]", - ) || [], - ); - const currentIndex = chips.indexOf(event.currentTarget); - const nextIndex = - event.key === "ArrowLeft" - ? Math.max(currentIndex - 1, 0) - : Math.min(currentIndex + 1, chips.length - 1); - chips[nextIndex]?.focus(); - return; - } - - if (event.key === "Backspace" || event.key === "Delete") { - event.preventDefault(); - const chipContainer = event.currentTarget.parentElement; - const chips = Array.from( - chipContainer?.querySelectorAll( - "[data-context-chip]", - ) || [], - ); - const currentIndex = chips.indexOf(event.currentTarget); - const nextFocusIndex = Math.max( - 0, - Math.min(currentIndex, chips.length - 2), - ); - if (item.type === "buffer") { - toggleBufferSelection(item.id); - } else { - toggleFileSelection(item.id); - } - requestAnimationFrame(() => { - const nextChips = Array.from( - chipContainer?.querySelectorAll( - "[data-context-chip]", - ) || [], - ); - const nextChip = nextChips[nextFocusIndex]; - if (nextChip) { - nextChip.focus(); - return; - } - contextTriggerRef.current?.focus(); - }); - } - }} - > - - {item.type === "buffer" ? ( - item.databaseType ? ( - - ) : ( - - ) - ) : ( - - )} - - - - {item.name} - {item.type === "buffer" && item.isDirty ? ( - - ) : null} - - - - { - if (item.type === "buffer") { - toggleBufferSelection(item.id); - } else { - toggleFileSelection(item.id); - } - }} - aria-label={`Remove ${item.name} from context`} - tabIndex={0} - > - - - - - ))} - - ) : null} -
- - {(isActiveSurface || isComposerFocused) && mentionState.active && ( - { - visibleMentionFilesRef.current = files; - }} - /> - )} - - {slashCommandState.active && ( - { - handleSlashCommandSelect(command); - }} - onClose={hideSlashCommands} - /> - )} -
- ); + const inputRef = useRef(null); + const contextTriggerRef = useRef(null); + const aiChatContainerRef = useRef(null); + const isUpdatingContentRef = useRef(false); + const visibleMentionFilesRef = useRef([]); + const performanceTimer = useRef(null); + + // Local state for input emptiness check (to avoid subscribing to full input text) + const [hasInputText, setHasInputText] = useState(false); + const [isContextDragOver, setIsContextDragOver] = useState(false); + const [isComposerFocused, setIsComposerFocused] = useState(false); + const inputValueRef = useRef(""); + const [pastedImages, setPastedImages] = useState([]); + const [isContextDropdownOpen, setIsContextDropdownOpen] = useState(false); + const [mentionState, setMentionState] = useState({ + active: false, + position: { top: 0, bottom: 0, left: 0, width: 0 }, + search: "", + startIndex: 0, + selectedIndex: 0, + }); + const [slashCommandState, setSlashCommandState] = useState({ + active: false, + position: { top: 0, bottom: 0, left: 0, width: 0 }, + search: "", + selectedIndex: 0, + }); + const slashCommandRangeRef = useRef({ startIndex: 0, endIndex: 0 }); + + const hasApiKey = useAIChatStore((state) => state.hasApiKey); + const sessionConfigOptions = useAIChatStore((state) => state.sessionConfigOptions); + const aiProviderId = useSettingsStore((state) => state.settings.aiProviderId); + const aiModelId = useSettingsStore((state) => state.settings.aiModelId); + const aiCustomModelId = useSettingsStore((state) => state.settings.aiCustomModelId); + const aiAutocompleteCustomModelId = useSettingsStore( + (state) => state.settings.aiAutocompleteCustomModelId, + ); + const updateSetting = useSettingsStore((state) => state.actions.updateSetting); + + // Check if current agent is "custom" (only show model selector for custom agent) + const isCustomAgent = currentAgentId === "custom"; + + // ACP agents don't need API key (they handle their own auth) + const isInputEnabled = isCustomAgent ? hasApiKey : true; + const isStreaming = isTyping && !!streamingMessageId; + const changeSessionConfigOption = useAIChatStore( + (state) => state.actions.changeSessionConfigOption, + ); + + const handleAthasProviderChange = useCallback( + (nextProviderId: string) => { + const provider = getProviderById(nextProviderId); + void updateSetting("aiProviderId", nextProviderId); + if (nextProviderId === "custom") { + void updateSetting("aiModelId", aiCustomModelId || aiAutocompleteCustomModelId); + return; + } + if (provider && provider.models.length > 0) { + void updateSetting("aiModelId", provider.models[0].id); + } + }, + [aiAutocompleteCustomModelId, aiCustomModelId, updateSetting], + ); + + const handleAthasModelChange = useCallback( + (nextModelId: string) => { + if (aiProviderId === "custom") { + void updateSetting("aiCustomModelId", nextModelId); + } + void updateSetting("aiModelId", nextModelId); + }, + [aiProviderId, updateSetting], + ); + + const availableSlashCommands = useAIChatStore((state) => state.availableSlashCommands); + const filteredSlashCommands = useMemo(() => { + const search = slashCommandState.search.trim().toLowerCase(); + if (!search) return availableSlashCommands; + return availableSlashCommands.filter( + (command) => + command.name.toLowerCase().includes(search) || + command.description?.toLowerCase().includes(search), + ); + }, [availableSlashCommands, slashCommandState.search]); + + const setInput = useCallback((input: string) => { + inputValueRef.current = input; + }, []); + const addPastedImage = useCallback((image: PastedImage) => { + setPastedImages((current) => [...current, image]); + }, []); + const removePastedImage = useCallback((imageId: string) => { + setPastedImages((current) => current.filter((image) => image.id !== imageId)); + }, []); + const clearPastedImages = useCallback(() => setPastedImages([]), []); + const toggleBufferSelection = onToggleBufferSelection; + const toggleFileSelection = onToggleFileSelection; + const setSelectedBufferIds = onSetSelectedBufferIds; + const setSelectedFilesPaths = onSetSelectedFilesPaths; + const showMention = useCallback( + (position: InlineDropdownPosition, search: string, startIndex: number) => { + setMentionState({ + active: true, + position, + search, + startIndex, + selectedIndex: 0, + }); + }, + [], + ); + const hideMention = useCallback(() => { + setMentionState((current) => ({ ...current, active: false })); + }, []); + const updatePosition = useCallback((position: InlineDropdownPosition) => { + setMentionState((current) => ({ ...current, position })); + }, []); + const setSelectedIndex = useCallback((selectedIndex: number) => { + setMentionState((current) => ({ ...current, selectedIndex })); + }, []); + const showSlashCommands = useCallback((position: InlineDropdownPosition, search: string) => { + setSlashCommandState({ + active: true, + position, + search, + selectedIndex: 0, + }); + }, []); + const hideSlashCommands = useCallback(() => { + setSlashCommandState((current) => ({ ...current, active: false })); + }, []); + const selectNextSlashCommand = useCallback(() => { + setSlashCommandState((current) => ({ + ...current, + selectedIndex: Math.min( + current.selectedIndex + 1, + Math.max(filteredSlashCommands.length - 1, 0), + ), + })); + }, [filteredSlashCommands.length]); + const selectPreviousSlashCommand = useCallback(() => { + setSlashCommandState((current) => ({ + ...current, + selectedIndex: Math.max(current.selectedIndex - 1, 0), + })); + }, []); + const setSlashCommandSelectedIndex = useCallback((selectedIndex: number) => { + setSlashCommandState((current) => ({ ...current, selectedIndex })); + }, []); + + const closeComposerPopovers = useCallback(() => { + if (slashCommandState.active) { + hideSlashCommands(); + } + if (isContextDropdownOpen) { + setIsContextDropdownOpen(false); + } + if (mentionState.active) { + hideMention(); + } + }, [ + slashCommandState.active, + hideSlashCommands, + isContextDropdownOpen, + setIsContextDropdownOpen, + mentionState.active, + hideMention, + ]); + + const closeInlineMenus = useCallback(() => { + closeComposerPopovers(); + }, [closeComposerPopovers]); + + const addBufferToContext = useCallback( + (bufferId: string) => { + if (selectedBufferIds.has(bufferId)) return; + const nextSelectedBufferIds = new Set(selectedBufferIds); + nextSelectedBufferIds.add(bufferId); + setSelectedBufferIds(nextSelectedBufferIds); + }, + [selectedBufferIds, setSelectedBufferIds], + ); + + const addPathToContext = useCallback( + (filePath: string) => { + if (selectedFilesPaths.has(filePath)) return; + const nextSelectedFilesPaths = new Set(selectedFilesPaths); + nextSelectedFilesPaths.add(filePath); + setSelectedFilesPaths(nextSelectedFilesPaths); + }, + [selectedFilesPaths, setSelectedFilesPaths], + ); + + const addSidebarResourceToContext = useCallback( + async (resource: SidebarDragResource) => { + if (resource.type === "file") { + const matchingBuffer = !resource.isDir + ? buffers.find((buffer) => buffer.path === resource.path) + : null; + if (matchingBuffer) { + addBufferToContext(matchingBuffer.id); + } else { + addPathToContext(resource.path); + } + return; + } + + if (resource.type === "git-worktree") { + addPathToContext(resource.path); + return; + } + + const bufferId = await openSidebarResourceBuffer(resource); + if (bufferId) { + addBufferToContext(bufferId); + } + }, + [addBufferToContext, addPathToContext, buffers], + ); + + useEffect(() => { + const handleSidebarResourceDropOnAI = (event: Event) => { + if (!isActiveSurface || surfaceId !== "activity-sidebar") return; + const resource = (event as CustomEvent<{ resource?: SidebarDragResource }>).detail?.resource; + if (!resource) return; + void addSidebarResourceToContext(resource); + }; + + window.addEventListener(SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, handleSidebarResourceDropOnAI); + return () => + window.removeEventListener(SIDEBAR_RESOURCE_DROP_ON_AI_EVENT, handleSidebarResourceDropOnAI); + }, [addSidebarResourceToContext, isActiveSurface, surfaceId]); + + const handleContextDragOver = useCallback((event: React.DragEvent) => { + if (!hasSidebarResourceDragData(event.dataTransfer)) return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setIsContextDragOver(true); + }, []); + + const handleContextDragLeave = useCallback((event: React.DragEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement | null; + if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) { + setIsContextDragOver(false); + } + }, []); + + const handleContextDrop = useCallback( + async (event: React.DragEvent) => { + const resource = readSidebarResourceDragData(event.dataTransfer); + if (!resource) return; + + event.preventDefault(); + event.stopPropagation(); + setIsContextDragOver(false); + await addSidebarResourceToContext(resource); + }, + [addSidebarResourceToContext], + ); + + // Computed state for send button + const hasImages = pastedImages.length > 0; + const isSendDisabled = isStreaming ? false : (!hasInputText && !hasImages) || !isInputEnabled; + const getPlainTextFromDiv = useCallback(() => getComposerText(inputRef.current), []); + const getTextBeforeCaret = useCallback(() => getComposerTextBeforeCaret(inputRef.current), []); + const getCaretDropdownPosition = useCallback( + () => getComposerDropdownPosition(inputRef.current), + [], + ); + + const getMentionDropdownPosition = useCallback(() => { + const position = getCaretDropdownPosition(); + if (!inputRef.current) return position; + + const inputRect = inputRef.current.getBoundingClientRect(); + return { + ...position, + width: Math.min(360, Math.max(220, inputRect.width - 24)), + }; + }, [getCaretDropdownPosition]); + const getSlashDropdownPosition = useCallback(() => { + const position = getCaretDropdownPosition(); + if (!inputRef.current) return position; + + const inputRect = inputRef.current.getBoundingClientRect(); + return { + ...position, + width: Math.min(320, Math.max(180, inputRect.width - 24)), + }; + }, [getCaretDropdownPosition]); + + const syncInputFromEditable = useCallback(() => { + const newPlainText = getPlainTextFromDiv(); + setInput(newPlainText); + setHasInputText(newPlainText.trim().length > 0); + return newPlainText; + }, [getPlainTextFromDiv, setInput]); + + const removeComposerToken = useCallback( + (token: Element) => { + const parent = token.parentNode; + const nextSibling = token.nextSibling; + token.remove(); + if ( + nextSibling?.nodeType === Node.TEXT_NODE && + (nextSibling.textContent === "\u200B" || nextSibling.textContent === " ") + ) { + nextSibling.remove(); + } + + syncInputFromEditable(); + + if (!parent) return; + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + if (nextSibling?.parentNode === parent) { + range.setStartBefore(nextSibling); + } else { + range.selectNodeContents(parent); + range.collapse(false); + } + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }, + [syncInputFromEditable], + ); + + // Function to recalculate mention dropdown position + const recalculateMentionPosition = useCallback(() => { + if (!mentionState.active) return; + updatePosition(getMentionDropdownPosition()); + }, [mentionState.active, updatePosition, getMentionDropdownPosition]); + + const mentionableFiles = useMemo( + () => allProjectFiles.filter((file) => !file.isDir && !shouldIgnoreFile(file.path)), + [allProjectFiles], + ); + + const selectedContextItems = useMemo(() => { + const bufferSelections = buffers + .filter((buffer) => buffer.type !== "agent" && selectedBufferIds.has(buffer.id)) + .map((buffer) => ({ + type: "buffer" as const, + id: buffer.id, + name: buffer.name, + databaseType: buffer.type === "database" ? buffer.databaseType : undefined, + isDirty: buffer.type === "editor" && buffer.isDirty, + })); + + const fileSelections = Array.from(selectedFilesPaths).map((filePath) => ({ + type: "file" as const, + id: filePath, + name: filePath.split("/").pop() || "Unknown", + path: filePath, + })); + + return [...bufferSelections, ...fileSelections]; + }, [buffers, selectedBufferIds, selectedFilesPaths]); + + // ResizeObserver to track container size changes + useEffect(() => { + if (!aiChatContainerRef.current) return; + + const resizeObserver = new ResizeObserver(() => { + recalculateMentionPosition(); + if (slashCommandState.active) { + showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); + } + }); + + resizeObserver.observe(aiChatContainerRef.current); + + // Also observe the window resize + const handleWindowResize = () => { + recalculateMentionPosition(); + if (slashCommandState.active) { + showSlashCommands(getSlashDropdownPosition(), slashCommandState.search); + } + }; + + window.addEventListener("resize", handleWindowResize); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener("resize", handleWindowResize); + // Cleanup timers + if (performanceTimer.current) { + clearTimeout(performanceTimer.current); + } + }; + }, [ + recalculateMentionPosition, + slashCommandState.active, + slashCommandState.search, + showSlashCommands, + getSlashDropdownPosition, + ]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle slash command navigation + if (slashCommandState.active) { + if (e.key === "ArrowDown") { + e.preventDefault(); + selectNextSlashCommand(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selectPreviousSlashCommand(); + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + if (filteredSlashCommands[slashCommandState.selectedIndex]) { + handleSlashCommandSelect(filteredSlashCommands[slashCommandState.selectedIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + hideSlashCommands(); + } + } else if (mentionState.active) { + if (e.key === "ArrowDown") { + e.preventDefault(); + const lastIndex = visibleMentionFilesRef.current.length - 1; + setSelectedIndex(lastIndex < 0 ? 0 : Math.min(mentionState.selectedIndex + 1, lastIndex)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex(Math.max(mentionState.selectedIndex - 1, 0)); + } else if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + const visibleFiles = visibleMentionFilesRef.current; + if (visibleFiles[mentionState.selectedIndex]) { + handleFileMentionSelect(visibleFiles[mentionState.selectedIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + hideMention(); + } + } else if (e.key === "Backspace" || e.key === "Delete") { + // Handle composer token deletion + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && inputRef.current) { + const range = selection.getRangeAt(0); + if (!range.collapsed) return; + + const container = range.startContainer; + const offset = range.startOffset; + let tokenToRemove: Element | null = null; + const isBackwardDelete = e.key === "Backspace"; + + if (container === inputRef.current) { + const candidateIndex = isBackwardDelete ? offset - 1 : offset; + const candidateNode = inputRef.current.childNodes[candidateIndex] ?? null; + if (isComposerTokenElement(candidateNode)) { + tokenToRemove = candidateNode; + } + } + + // Check if cursor is at the beginning of a text node that follows a composer token + if (!tokenToRemove && container.nodeType === Node.TEXT_NODE) { + const textContent = container.textContent || ""; + const candidateSibling = + isBackwardDelete && offset === 0 + ? container.previousSibling + : !isBackwardDelete && offset === textContent.length + ? container.nextSibling + : null; + + if (isComposerTokenElement(candidateSibling)) { + tokenToRemove = candidateSibling; + } + } + + // Check if cursor is right after a composer token (in separator text node) + if ( + isBackwardDelete && + !tokenToRemove && + container.nodeType === Node.TEXT_NODE && + container.textContent === "\u200B" && + offset === 1 + ) { + const previousSibling = container.previousSibling?.previousSibling ?? null; // Skip the space node + + if (isComposerTokenElement(previousSibling)) { + tokenToRemove = previousSibling; + } + } + + if (tokenToRemove) { + e.preventDefault(); + removeComposerToken(tokenToRemove); + return; + } + } + } else if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + // Debounced mention detection - increased delay for better performance + const debouncedMentionDetection = useCallback(() => { + if (performanceTimer.current) { + clearTimeout(performanceTimer.current); + } + + performanceTimer.current = window.setTimeout(() => { + if (!inputRef.current) return; + + const textBeforeCaret = getTextBeforeCaret(); + const lastAtIndex = textBeforeCaret.lastIndexOf("@"); + + if (lastAtIndex !== -1) { + const afterAt = textBeforeCaret.slice(lastAtIndex + 1); + // Check if there's no space between @ and end, and it's not part of a mention badge + if (!afterAt.includes(" ") && !afterAt.includes("]") && afterAt.length < 50) { + const position = getMentionDropdownPosition(); + showMention(position, afterAt, lastAtIndex); + } else { + hideMention(); + } + } else { + hideMention(); + } + }, 150); // Increased to 150ms for better performance + }, [showMention, hideMention, getMentionDropdownPosition, getTextBeforeCaret]); + + // Optimized input change handler - no throttle for immediate response + const handleInputChange = useCallback(() => { + if (!inputRef.current || isUpdatingContentRef.current) return; + + const plainTextFromDiv = getPlainTextFromDiv(); + + // Keep keystrokes local to this composer so sibling surfaces cannot mirror them. + const currentInput = inputValueRef.current; + + // Only update if content actually changed + if (plainTextFromDiv !== currentInput) { + setInput(plainTextFromDiv); + + // Update local state for button enabled/disabled + setHasInputText(plainTextFromDiv.trim().length > 0); + + const textBeforeCaret = getTextBeforeCaret(); + const slashMatch = textBeforeCaret.match(/(?:^|\s)\/([^\s/]*)$/); + if (slashMatch && slashMatch[1].length < 50) { + const search = slashMatch[1]; + const startIndex = textBeforeCaret.length - search.length - 1; + slashCommandRangeRef.current = { + startIndex, + endIndex: textBeforeCaret.length, + }; + if (isContextDropdownOpen) { + setIsContextDropdownOpen(false); + } + showSlashCommands(getSlashDropdownPosition(), search); + } else if (slashCommandState.active) { + hideSlashCommands(); + } + + // Only do mention detection if text contains @ and is reasonably short + if (plainTextFromDiv.includes("@") && plainTextFromDiv.length < 500) { + debouncedMentionDetection(); + } else if (mentionState.active) { + hideMention(); + } + } + }, [ + setInput, + getPlainTextFromDiv, + getTextBeforeCaret, + debouncedMentionDetection, + hideMention, + mentionState.active, + showSlashCommands, + hideSlashCommands, + slashCommandState.active, + getSlashDropdownPosition, + isContextDropdownOpen, + setIsContextDropdownOpen, + ]); + + const handleEditableMouseDown = useCallback((event: React.MouseEvent) => { + if (!inputRef.current) return; + + const target = event.target as HTMLElement | null; + const token = target?.closest("[data-mention],[data-slash-command]"); + if (!token || !inputRef.current.contains(token)) return; + + event.preventDefault(); + inputRef.current.focus(); + + const selection = window.getSelection(); + if (!selection) return; + + const range = document.createRange(); + range.setStartAfter(token); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }, []); + + const insertTextAtCursor = useCallback( + (text: string) => { + if (!inputRef.current || !text) return; + + const normalizedText = text.replace(/\s+/g, " ").trim(); + if (!normalizedText) return; + + const selection = window.getSelection(); + const range = document.createRange(); + const currentText = getPlainTextFromDiv(); + const prefix = currentText.trim().length > 0 && !/\s$/.test(currentText) ? " " : ""; + const textNode = document.createTextNode(`${prefix}${normalizedText} `); + + inputRef.current.focus(); + + const selectionInsideInput = + !!selection && selection.rangeCount > 0 && inputRef.current.contains(selection.anchorNode); + + if (selectionInsideInput && selection) { + const selectedRange = selection.getRangeAt(0); + selectedRange.deleteContents(); + selectedRange.insertNode(textNode); + range.setStartAfter(textNode); + } else { + range.selectNodeContents(inputRef.current); + range.collapse(false); + range.insertNode(textNode); + range.setStartAfter(textNode); + } + + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + handleInputChange(); + }, + [getPlainTextFromDiv, handleInputChange], + ); + + const insertSkillAtCursor = useCallback( + (skill: AIChatSkill) => { + if (!inputRef.current || !skill.content.trim()) return; + + const selection = window.getSelection(); + const range = document.createRange(); + const currentText = getPlainTextFromDiv(); + const prefix = currentText.trim().length > 0 && !/\s$/.test(currentText) ? "\n\n" : ""; + const textNode = document.createTextNode(`${prefix}${skill.content.trim()} `); + + inputRef.current.focus(); + + const selectionInsideInput = + !!selection && selection.rangeCount > 0 && inputRef.current.contains(selection.anchorNode); + + if (selectionInsideInput && selection) { + const selectedRange = selection.getRangeAt(0); + selectedRange.deleteContents(); + selectedRange.insertNode(textNode); + range.setStartAfter(textNode); + } else { + range.selectNodeContents(inputRef.current); + range.collapse(false); + range.insertNode(textNode); + range.setStartAfter(textNode); + } + + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); + handleInputChange(); + setHasInputText(true); + }, + [getPlainTextFromDiv, handleInputChange], + ); + + useEffect(() => { + const handleInsertSkill = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!isActiveSurface || detail?.surfaceId !== surfaceId) return; + insertSkillAtCursor(detail.skill); + }; + + window.addEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); + return () => window.removeEventListener(AI_CHAT_INSERT_SKILL_EVENT, handleInsertSkill); + }, [insertSkillAtCursor, isActiveSurface]); + + // Handle paste - strip HTML formatting, keep only plain text. Images are added to preview. + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + // Check for images first + const items = clipboardData.items; + let hasImage = false; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith("image/")) { + hasImage = true; + e.preventDefault(); + + const file = items[i].getAsFile(); + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result as string; + if (dataUrl) { + addPastedImage({ + id: `img-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + dataUrl, + name: file.name || `image-${Date.now()}.png`, + size: file.size, + }); + } + }; + reader.readAsDataURL(file); + } + } + } + + // If there was an image, don't process text + if (hasImage) return; + + // For text content, prevent default and insert plain text only + e.preventDefault(); + + // Get plain text from clipboard + const plainText = clipboardData.getData("text/plain"); + if (!plainText) return; + + // Insert plain text at cursor position + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0); + range.deleteContents(); + + const textNode = document.createTextNode(plainText); + range.insertNode(textNode); + + // Move cursor to end of inserted text + range.setStartAfter(textNode); + range.setEndAfter(textNode); + selection.removeAllRanges(); + selection.addRange(range); + + // Trigger input change handler to update state + handleInputChange(); + }, + [handleInputChange, addPastedImage], + ); + + // Handle file mention selection + const handleFileMentionSelect = useCallback( + (file: FileEntry) => { + if (!inputRef.current) return; + + isUpdatingContentRef.current = true; + hideMention(); + const mentionRange = getComposerTextRange( + inputRef.current, + mentionState.startIndex, + mentionState.startIndex + mentionState.search.length + 1, + ); + mentionRange.deleteContents(); + + const mentionSpan = document.createElement("span"); + mentionSpan.setAttribute("data-mention", "true"); + mentionSpan.setAttribute("data-mention-name", file.name); + mentionSpan.setAttribute("data-mention-path", file.path); + mentionSpan.setAttribute("contenteditable", "false"); + mentionSpan.title = file.path; + mentionSpan.className = + "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-primary/10 px-1.5 py-0.5 leading-row text-primary align-baseline select-none"; + mentionSpan.textContent = file.name; + + const trailingSpace = document.createTextNode(" "); + const fragment = document.createDocumentFragment(); + fragment.append(mentionSpan, trailingSpace); + mentionRange.insertNode(fragment); + + const selection = window.getSelection(); + if (selection) { + const caretRange = document.createRange(); + caretRange.setStart(trailingSpace, trailingSpace.length); + caretRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(caretRange); + } + + inputRef.current.focus(); + syncInputFromEditable(); + isUpdatingContentRef.current = false; + }, + [hideMention, mentionState.search.length, mentionState.startIndex, syncInputFromEditable], + ); + + // Handle slash command selection + const handleSlashCommandSelect = useCallback( + (command: SlashCommand) => { + if (!inputRef.current) return; + + isUpdatingContentRef.current = true; + const { startIndex, endIndex } = slashCommandRangeRef.current; + hideSlashCommands(); + const commandRange = getComposerTextRange(inputRef.current, startIndex, endIndex); + commandRange.deleteContents(); + + const commandSpan = document.createElement("span"); + commandSpan.setAttribute("data-slash-command", "true"); + commandSpan.setAttribute("data-slash-command-name", command.name); + commandSpan.setAttribute("contenteditable", "false"); + commandSpan.title = command.description || `/${command.name}`; + commandSpan.className = + "font-sans ui-text-sm inline-flex min-h-6 max-w-45 items-center gap-1 truncate rounded-full border-0 bg-accent/70 px-1.5 py-0.5 leading-row text-foreground align-baseline select-none"; + commandSpan.textContent = `/${command.name}`; + + const trailingSpace = document.createTextNode(" "); + const fragment = document.createDocumentFragment(); + fragment.append(commandSpan, trailingSpace); + commandRange.insertNode(fragment); + + const selection = window.getSelection(); + if (selection) { + const caretRange = document.createRange(); + caretRange.setStart(trailingSpace, trailingSpace.length); + caretRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(caretRange); + } + + inputRef.current.focus(); + syncInputFromEditable(); + isUpdatingContentRef.current = false; + }, + [hideSlashCommands, syncInputFromEditable], + ); + + const handleSendMessage = async () => { + const currentInput = inputValueRef.current; + const currentImages = pastedImages; + const hasContent = currentInput.trim() || currentImages.length > 0; + if (!hasContent || !isInputEnabled) return; + + // Clear input and images immediately after send is triggered + setInput(""); + setHasInputText(false); + clearPastedImages(); + if (inputRef.current) { + inputRef.current.innerHTML = ""; + } + + // Convert pasted images to ImageContent format + const images: ImageContent[] = currentImages.map((img) => ({ + data: img.dataUrl.split(",")[1] || img.dataUrl, + mediaType: img.dataUrl.split(";")[0]?.split(":")[1] || "image/png", + })); + + // Send the captured message with images + await onSendMessage(currentInput, images); + }; + + const focusInput = useCallback(() => inputRef.current?.focus(), []); + const { + interimTranscript, + isListening, + isMacDevBlocked: isMacDevSpeechRecognitionBlocked, + isSupported: isSpeechRecognitionSupported, + toggle: toggleVoiceInput, + } = useVoiceInput({ + enabled: isInputEnabled, + insertText: insertTextAtCursor, + focusInput, + }); + + const hasSlashCommands = availableSlashCommands.length > 0; + const isInitialPresentation = presentation === "initial"; + const inputPlaceholder = isInputEnabled + ? isInitialPresentation + ? "What do you want to create?" + : hasSlashCommands + ? "Ask anything... (@ files, / commands)" + : "Ask anything... (@ to mention files)" + : "Configure API key to enable Agent..."; + + useEffect(() => { + if (!autoFocus || !isActiveSurface) return; + + const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [autoFocus, isActiveSurface]); + + return ( + + + {pastedImages.length > 0 && ( + + {pastedImages.map((image) => ( + + + {image.name} + + + {image.name} + + + removePastedImage(image.id)} + aria-label={`Remove ${image.name}`} + > + + + + + ))} + + )} + + setIsComposerFocused(true)} + onBlur={() => setIsComposerFocused(false)} + onPaste={handlePaste} + data-placeholder={inputPlaceholder} + className={cn( + isInitialPresentation && "max-h-48 min-h-28 overflow-y-auto px-4 py-4 ui-text-base", + )} + role="textbox" + aria-multiline={!isInitialPresentation} + aria-label="Message input" + tabIndex={isInputEnabled ? 0 : -1} + /> + + +
+ { + if (open) { + closeInlineMenus(); + } + setIsContextDropdownOpen(open); + }} + /> +
+ + {queueCount > 0 && ( + + + {queueCount} + + )} + +
+ {hasSlashCommands && ( + + )} + + + void changeSessionConfigOption(optionId, value) + } + onSelectSkill={insertSkillAtCursor} + onBeforeOpen={closeInlineMenus} + /> + + + + + + +
+
+ + {selectedContextItems.length > 0 ? ( + + {selectedContextItems.map((item) => ( + { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + const chips = Array.from( + event.currentTarget.parentElement?.querySelectorAll( + "[data-context-chip]", + ) || [], + ); + const currentIndex = chips.indexOf(event.currentTarget); + const nextIndex = + event.key === "ArrowLeft" + ? Math.max(currentIndex - 1, 0) + : Math.min(currentIndex + 1, chips.length - 1); + chips[nextIndex]?.focus(); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + const chipContainer = event.currentTarget.parentElement; + const chips = Array.from( + chipContainer?.querySelectorAll("[data-context-chip]") || [], + ); + const currentIndex = chips.indexOf(event.currentTarget); + const nextFocusIndex = Math.max(0, Math.min(currentIndex, chips.length - 2)); + if (item.type === "buffer") { + toggleBufferSelection(item.id); + } else { + toggleFileSelection(item.id); + } + requestAnimationFrame(() => { + const nextChips = Array.from( + chipContainer?.querySelectorAll("[data-context-chip]") || [], + ); + const nextChip = nextChips[nextFocusIndex]; + if (nextChip) { + nextChip.focus(); + return; + } + contextTriggerRef.current?.focus(); + }); + } + }} + > + + {item.type === "buffer" ? ( + item.databaseType ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {item.name} + {item.type === "buffer" && item.isDirty ? ( + + ) : null} + + + + { + if (item.type === "buffer") { + toggleBufferSelection(item.id); + } else { + toggleFileSelection(item.id); + } + }} + aria-label={`Remove ${item.name} from context`} + tabIndex={0} + > + + + + + ))} + + ) : null} +
+ + {(isActiveSurface || isComposerFocused) && mentionState.active && ( + { + visibleMentionFilesRef.current = files; + }} + /> + )} + + {slashCommandState.active && ( + { + handleSlashCommandSelect(command); + }} + onClose={hideSlashCommands} + /> + )} +
+ ); }); export default AIChatInputBar; diff --git a/src/features/ai/types/ai-chat.types.ts b/src/features/ai/types/ai-chat.types.ts index fd7aeab54..a15706af4 100644 --- a/src/features/ai/types/ai-chat.types.ts +++ b/src/features/ai/types/ai-chat.types.ts @@ -1,7 +1,7 @@ import type { - AcpToolCallLocation, - AcpToolCallStatus, - AcpToolKind, + AcpToolCallLocation, + AcpToolCallStatus, + AcpToolKind, } from "@/features/ai/types/acp.types"; import type { ChatFollowUpAction } from "@/features/ai/lib/follow-up-actions"; import type { FileEntry } from "@/features/file-system/types/app.types"; @@ -13,102 +13,102 @@ export type ChatMode = "chat" | "plan"; export type AssistantResponsePhase = "waiting" | "thinking"; export interface ToolCall { - id?: string; - name: string; - input: any; - output?: any; - error?: string; - kind?: AcpToolKind; - status?: AcpToolCallStatus; - locations?: AcpToolCallLocation[]; - timestamp: Date; - isComplete?: boolean; + id?: string; + name: string; + input: any; + output?: any; + error?: string; + kind?: AcpToolKind; + status?: AcpToolCallStatus; + locations?: AcpToolCallLocation[]; + timestamp: Date; + isComplete?: boolean; } export interface ImageContent { - data: string; - mediaType: string; + data: string; + mediaType: string; } interface ResourceContent { - uri: string; - name: string | null; + uri: string; + name: string | null; } export interface Message { - id: string; - content: string; - role: "user" | "assistant" | "system"; - timestamp: Date; - isStreaming?: boolean; - responsePhase?: AssistantResponsePhase; - isToolUse?: boolean; - toolName?: string; - toolCalls?: ToolCall[]; - images?: ImageContent[]; - resources?: ResourceContent[]; - ui?: GenerativeUIComponent[]; - followUpActions?: ChatFollowUpAction[]; + id: string; + content: string; + role: "user" | "assistant" | "system"; + timestamp: Date; + isStreaming?: boolean; + responsePhase?: AssistantResponsePhase; + isToolUse?: boolean; + toolName?: string; + toolCalls?: ToolCall[]; + images?: ImageContent[]; + resources?: ResourceContent[]; + ui?: GenerativeUIComponent[]; + followUpActions?: ChatFollowUpAction[]; } // Agent types for AI chat export type AgentType = string; export interface Chat { - id: string; - title: string; - messages: Message[]; - createdAt: Date; - lastMessageAt: Date; - agentId: AgentType; // Which agent this chat uses - acpSessionId?: string | null; - workspacePath?: string | null; - providerId?: string | null; - modelId?: string | null; - branch?: string | null; - isPinned?: boolean; - archivedAt?: Date | null; + id: string; + title: string; + messages: Message[]; + createdAt: Date; + lastMessageAt: Date; + agentId: AgentType; // Which agent this chat uses + acpSessionId?: string | null; + workspacePath?: string | null; + providerId?: string | null; + modelId?: string | null; + branch?: string | null; + isPinned?: boolean; + archivedAt?: Date | null; } export interface AIChatProps { - className?: string; - surfaceId: string; - chatId?: string | null; - isActiveSurface?: boolean; - // Context from the main app - activeBuffer?: PaneContent | null; - buffers?: PaneContent[]; - selectedFiles?: string[]; - allProjectFiles?: FileEntry[]; - mode: "chat"; - // Buffer update functions - onApplyCode?: (code: string) => void; + className?: string; + surfaceId: string; + chatId?: string | null; + isActiveSurface?: boolean; + // Context from the main app + activeBuffer?: PaneContent | null; + buffers?: PaneContent[]; + selectedFiles?: string[]; + allProjectFiles?: FileEntry[]; + mode: "chat"; + // Buffer update functions + onApplyCode?: (code: string) => void; } export interface MarkdownRendererProps { - content: string; - onApplyCode?: (code: string) => void; - chatId?: string | null; + content: string; + onApplyCode?: (code: string) => void; + chatId?: string | null; } export interface AIChatInputBarProps { - surfaceId: string; - buffers: PaneContent[]; - allProjectFiles: FileEntry[]; - currentAgentId: AgentType; - isTyping: boolean; - streamingMessageId: string | null; - queueCount: number; - selectedBufferIds: Set; - selectedFilesPaths: Set; - onToggleBufferSelection: (bufferId: string) => void; - onToggleFileSelection: (filePath: string) => void; - onSetSelectedBufferIds: (bufferIds: Set) => void; - onSetSelectedFilesPaths: (filePaths: Set) => void; - isActiveSurface?: boolean; - presentation?: "default" | "initial"; - autoFocus?: boolean; - onAgentChange?: (agentId: AgentType) => void; - onSendMessage: (message: string, images?: ImageContent[]) => Promise; - onStopStreaming: () => void; + surfaceId: string; + buffers: PaneContent[]; + allProjectFiles: FileEntry[]; + currentAgentId: AgentType; + isTyping: boolean; + streamingMessageId: string | null; + queueCount: number; + selectedBufferIds: Set; + selectedFilesPaths: Set; + onToggleBufferSelection: (bufferId: string) => void; + onToggleFileSelection: (filePath: string) => void; + onSetSelectedBufferIds: (bufferIds: Set) => void; + onSetSelectedFilesPaths: (filePaths: Set) => void; + isActiveSurface?: boolean; + presentation?: "default" | "initial"; + autoFocus?: boolean; + onAgentChange?: (agentId: AgentType) => void; + onSendMessage: (message: string, images?: ImageContent[]) => Promise; + onStopStreaming: () => void; } diff --git a/src/features/editor/lib/wasm-parser/loader.ts b/src/features/editor/lib/wasm-parser/loader.ts index 4d418a3da..641b114ed 100644 --- a/src/features/editor/lib/wasm-parser/loader.ts +++ b/src/features/editor/lib/wasm-parser/loader.ts @@ -8,964 +8,839 @@ import treeSitterRuntimeWasmUrl from "web-tree-sitter/web-tree-sitter.wasm?url"; import { logger } from "../../utils/logger"; import { indexedDBParserCache } from "./cache-indexeddb"; import { fetchHighlightQuery } from "./extension-assets"; -import type { - LoadedParser, - ParserConfig, -} from "../../types/wasm-parser/wasm-parser.types"; - -async function computeSha256(bytes: Uint8Array): Promise { - const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +import type { LoadedParser, ParserConfig } from "../../types/wasm-parser/wasm-parser.types"; + +async function computeSha256(bytes: Uint8Array): Promise { + const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } interface ParserManifest { - version?: string; - name?: string; - [key: string]: unknown; + version?: string; + name?: string; + [key: string]: unknown; } async function fetchManifestVersion(wasmPath: string): Promise { - try { - const manifestUrl = wasmPath.replace(/\.wasm$/, ".json"); - const response = await fetch(manifestUrl); - if (!response.ok) return null; - const manifest = (await response.json()) as ParserManifest; - return manifest.version ?? null; - } catch { - return null; - } + try { + const manifestUrl = wasmPath.replace(/\.wasm$/, ".json"); + const response = await fetch(manifestUrl); + if (!response.ok) return null; + const manifest = (await response.json()) as ParserManifest; + return manifest.version ?? null; + } catch { + return null; + } } export function getTreeSitterRuntimeAssetPath(scriptName: string): string { - if (scriptName === "web-tree-sitter.wasm") { - return treeSitterRuntimeWasmUrl; - } + if (scriptName === "web-tree-sitter.wasm") { + return treeSitterRuntimeWasmUrl; + } - return `/tree-sitter/${scriptName}`; + return `/tree-sitter/${scriptName}`; } class WasmParserLoader { - private static instance: WasmParserLoader; - private initialized = false; - private parsers: Map = new Map(); - private loadingParsers: Map> = new Map(); - - private constructor() {} - - static getInstance(): WasmParserLoader { - if (!WasmParserLoader.instance) { - WasmParserLoader.instance = new WasmParserLoader(); - } - return WasmParserLoader.instance; - } - - /** - * Initialize Tree-sitter WASM - * Must be called once before loading any parsers - */ - async initialize(): Promise { - if (this.initialized) return; - - try { - await Parser.init({ - locateFile(scriptName: string) { - const assetPath = getTreeSitterRuntimeAssetPath(scriptName); - if ( - typeof globalThis !== "undefined" && - globalThis.location?.origin - ) { - return new URL( - assetPath, - `${globalThis.location.origin}/`, - ).toString(); - } - - return assetPath; - }, - }); - this.initialized = true; - logger.debug("WasmParser", "Tree-sitter WASM initialized"); - } catch (error) { - logger.error( - "WasmParser", - "Failed to initialize Tree-sitter WASM", - error, - ); - throw error; - } - } - - /** - * Check if WASM is initialized and ready to use - */ - isInitialized(): boolean { - return this.initialized; - } - - /** - * Load a parser for a specific language - * Returns cached parser if already loaded - */ - async loadParser(config: ParserConfig): Promise { - const { languageId, highlightQuery } = config; - - // Check if parser is already cached - if (this.parsers.has(languageId)) { - const cached = this.parsers.get(languageId)!; - - // Update highlight query if a new one is provided and differs from the cached one - if (highlightQuery && highlightQuery !== cached.highlightQueryText) { - logger.debug( - "WasmParser", - `Updating highlight query for ${languageId}`, - ); - - try { - const { query, queryText: compiledQueryText } = - this.compileHighlightQuery( - cached.language, - languageId, - highlightQuery, - ); - const updatedParser: LoadedParser = { - ...cached, - highlightQuery: query, - highlightQueryText: compiledQueryText, - }; - this.parsers.set(languageId, updatedParser); - - // Also update IndexedDB cache with the highlight query - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if ( - cachedEntry && - cachedEntry.highlightQuery !== compiledQueryText - ) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: compiledQueryText, - }); - } - }) - .catch(() => {}); - - return updatedParser; - } catch (error) { - // Try to fetch local highlight query as fallback before surfacing an error. - const localQuery = await this.fetchHighlightQueryText( - languageId, - config.wasmPath, - config.highlightQueryUrl, - ); - if (localQuery) { - try { - const { query, queryText: compiledQueryText } = - this.compileHighlightQuery( - cached.language, - languageId, - localQuery, - ); - const updatedParser: LoadedParser = { - ...cached, - highlightQuery: query, - highlightQueryText: compiledQueryText, - }; - this.parsers.set(languageId, updatedParser); - - // Update IndexedDB cache with the correct local query - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: compiledQueryText, - }); - } - }) - .catch(() => {}); - - logger.debug( - "WasmParser", - `Using refreshed highlight query for ${languageId}`, - ); - return updatedParser; - } catch (localError) { - logger.error( - "WasmParser", - `Failed to create highlight query for ${languageId}:`, - error, - ); - logger.error( - "WasmParser", - `Local highlight query also failed for ${languageId}:`, - localError, - ); - } - } else { - logger.error( - "WasmParser", - `Failed to create highlight query for ${languageId}:`, - error, - ); - } - } - } - - return cached; - } - - // Return ongoing loading promise if exists - if (this.loadingParsers.has(languageId)) { - return this.loadingParsers.get(languageId)!; - } - - // Start loading parser - const loadPromise = this._loadParserInternal(config); - this.loadingParsers.set(languageId, loadPromise); - - try { - const loadedParser = await loadPromise; - this.parsers.set(languageId, loadedParser); - this.loadingParsers.delete(languageId); - return loadedParser; - } catch (error) { - this.loadingParsers.delete(languageId); - throw error; - } - } - - /** - * Fetch highlight query from parser source, CDN or local fallback. - */ - private async fetchHighlightQueryText( - languageId: string, - wasmPath?: string, - queryUrl?: string, - ): Promise { - const { query, sourceUrl } = await fetchHighlightQuery(languageId, { - wasmUrl: wasmPath, - queryUrl, - cacheMode: "no-store", - }); - if (!query) { - logger.debug( - "WasmParser", - `No highlight query source found for ${languageId}`, - ); - return null; - } - - logger.debug( - "WasmParser", - `Resolved highlight query for ${languageId} from ${sourceUrl || "fallback source"}`, - ); - return query; - } - - private ensureValidWasmBytes( - languageId: string, - wasmPath: string, - wasmBytes: Uint8Array, - ): void { - const hasWasmHeader = - wasmBytes.length >= 4 && - wasmBytes[0] === 0x00 && - wasmBytes[1] === 0x61 && - wasmBytes[2] === 0x73 && - wasmBytes[3] === 0x6d; - - if (hasWasmHeader) { - return; - } - - throw new Error( - `Invalid WASM payload for ${languageId} from ${wasmPath} (missing wasm header)`, - ); - } - - /** - * Compile highlight query with compatibility rewrites for parser/query mismatches. - */ - private compileHighlightQuery( - language: Language, - languageId: string, - queryText: string, - ): { query: Query; queryText: string } { - try { - return { - query: new Query(language, queryText), - queryText, - }; - } catch (error) { - const recovered = this.tryRecoverHighlightQuery( - language, - languageId, - queryText, - error, - ); - if (recovered) return recovered; - throw error; - } - } - - /** - * Try to recover from unsupported nodes by removing patterns that reference them. - */ - private tryRecoverHighlightQuery( - language: Language, - languageId: string, - queryText: string, - error: unknown, - ): { query: Query; queryText: string } | null { - let rewrittenQuery = queryText; - let currentError = error; - const seenNodes = new Set(); - const seenPredicates = new Set(); - - for (let attempts = 0; attempts < 12; attempts++) { - const badNode = this.extractBadNodeName(currentError); - if (badNode && !seenNodes.has(badNode)) { - seenNodes.add(badNode); - - const nextQuery = this.rewriteIncompatibleHighlightQuery( - languageId, - rewrittenQuery, - badNode, - ); - if (nextQuery !== rewrittenQuery) { - rewrittenQuery = nextQuery; - - logger.warn( - "WasmParser", - `Applied ${languageId} highlight compatibility rewrite for missing node '${badNode}'`, - ); - - try { - return { - query: new Query(language, rewrittenQuery), - queryText: rewrittenQuery, - }; - } catch (rewriteError) { - currentError = rewriteError; - continue; - } - } - } - - const badPredicate = this.extractBadPredicateName(currentError); - if (badPredicate && !seenPredicates.has(badPredicate)) { - seenPredicates.add(badPredicate); - - const nextQuery = this.rewriteIncompatiblePredicateQuery( - languageId, - rewrittenQuery, - badPredicate, - ); - if (nextQuery !== rewrittenQuery) { - rewrittenQuery = nextQuery; - - logger.warn( - "WasmParser", - `Applied ${languageId} highlight compatibility rewrite for unsupported predicate '${badPredicate}'`, - ); - - try { - return { - query: new Query(language, rewrittenQuery), - queryText: rewrittenQuery, - }; - } catch (rewriteError) { - currentError = rewriteError; - continue; - } - } - } - - break; - } - - logger.error( - "WasmParser", - `Highlight query rewrite failed for ${languageId}:`, - currentError, - ); - return null; - } - - private extractBadNodeName(error: unknown): string | null { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : String(error); - const match = message.match(/Bad node name '([^']+)'/); - return match?.[1] ?? null; - } - - private extractBadPredicateName(error: unknown): string | null { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : String(error); - const backtickMatch = message.match(/`([^`]+)` predicate/); - if (backtickMatch?.[1]) return backtickMatch[1]; - const quoteMatch = message.match(/predicate ['"]([^'"]+)['"]/); - return quoteMatch?.[1] ?? null; - } - - /** - * Rewrite unsupported node references so older parser WASM builds can still highlight partially. - */ - private rewriteIncompatibleHighlightQuery( - _languageId: string, - queryText: string, - badNodeName: string, - ): string { - return this.stripNodeExpressions(queryText, badNodeName); - } - - /** - * Rewrite incompatible predicate invocations (e.g. predicate arity changes across engines). - */ - private rewriteIncompatiblePredicateQuery( - _languageId: string, - queryText: string, - predicateName: string, - ): string { - return this.stripPredicateCalls(queryText, predicateName); - } - - private stripNodeExpressions(queryText: string, badNodeName: string): string { - const nodeRegex = new RegExp( - `\\(${badNodeName.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(?=[\\s)])`, - ); - const lines = queryText.split("\n"); - const output: string[] = []; - let expressionLines: string[] = []; - let depth = 0; - let inExpression = false; - - const flushExpression = () => { - if (expressionLines.length === 0) return; - const expressionText = expressionLines.join("\n"); - if (!nodeRegex.test(expressionText)) { - output.push(expressionText); - } - expressionLines = []; - }; - - for (const line of lines) { - if (!inExpression) { - if (line.trimStart().startsWith("(")) { - inExpression = true; - expressionLines = [line]; - depth = - (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; - if (depth <= 0) { - flushExpression(); - inExpression = false; - depth = 0; - } - } else { - output.push(line); - } - continue; - } - - expressionLines.push(line); - depth += - (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; - if (depth <= 0) { - flushExpression(); - inExpression = false; - depth = 0; - } - } - - if (inExpression) { - flushExpression(); - } - - return `${output - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd()}\n`; - } - - private stripPredicateCalls( - queryText: string, - predicateName: string, - ): string { - const escapedPredicate = predicateName.replace( - /[.*+?^${}()|[\]\\]/g, - "\\$&", - ); - const predicateRegex = new RegExp(`\\(${escapedPredicate}(?=[\\s)])`, "g"); - const output: string[] = []; - let cursor = 0; - let changed = false; - let match = predicateRegex.exec(queryText); - while (match !== null) { - const start = match.index; - const end = this.findMatchingParenIndex(queryText, start); - if (end === -1) { - return queryText; - } - - output.push(queryText.slice(cursor, start)); - cursor = end + 1; - changed = true; - match = predicateRegex.exec(queryText); - } - - if (!changed) return queryText; - - output.push(queryText.slice(cursor)); - - return `${output - .join("") - .replace(/[ \t]+\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trimEnd()}\n`; - } - - private findMatchingParenIndex(text: string, startIndex: number): number { - if ( - startIndex < 0 || - startIndex >= text.length || - text[startIndex] !== "(" - ) { - return -1; - } - - let depth = 0; - let inString = false; - let escapeNext = false; - let inComment = false; - - for (let index = startIndex; index < text.length; index++) { - const char = text[index]; - - if (inComment) { - if (char === "\n") { - inComment = false; - } - continue; - } - - if (inString) { - if (escapeNext) { - escapeNext = false; - continue; - } - if (char === "\\") { - escapeNext = true; - continue; - } - if (char === '"') { - inString = false; - } - continue; - } - - if (char === ";") { - inComment = true; - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "(") { - depth += 1; - continue; - } - - if (char === ")") { - depth -= 1; - if (depth === 0) { - return index; - } - if (depth < 0) { - return -1; - } - } - } - - return -1; - } - - private async readCachedParser( - languageId: string, - ): Promise<{ wasmBytes: Uint8Array; queryText?: string } | null> { - const cached = await indexedDBParserCache.get(languageId); - if (!cached) return null; - - let wasmBytes: Uint8Array; - if (cached.wasmData) { - wasmBytes = new Uint8Array(cached.wasmData); - logger.debug("WasmParser", `Using cached ArrayBuffer for ${languageId}`); - } else if (cached.wasmBlob) { - try { - const arrayBuffer = await cached.wasmBlob.arrayBuffer(); - wasmBytes = new Uint8Array(arrayBuffer); - logger.debug("WasmParser", `Using cached Blob for ${languageId}`); - } catch (blobError) { - logger.error( - "WasmParser", - `Failed to read cached Blob for ${languageId}, clearing cache entry`, - blobError, - ); - await indexedDBParserCache.delete(languageId); - throw new Error( - `Cached parser corrupted, please reinstall ${languageId}`, - ); - } - } else { - throw new Error(`Cache entry for ${languageId} has no WASM data`); - } - - return { - wasmBytes, - queryText: cached.highlightQuery?.trim() - ? cached.highlightQuery - : undefined, - }; - } - - private async _loadParserInternal( - config: ParserConfig, - ): Promise { - const { languageId, wasmPath, highlightQuery, highlightQueryUrl } = config; - - try { - // Ensure Tree-sitter is initialized - if (!this.initialized) { - await this.initialize(); - } - - // Check if this is a local/bundled parser (not a remote URL) - // Local parsers should always use the local file, not IndexedDB cache - const isLocalParser = wasmPath.startsWith("/tree-sitter/"); - - // Try to load from IndexedDB cache first (skip for local parsers) - const cached = isLocalParser - ? null - : await this.readCachedParser(languageId); - - let wasmBytes: Uint8Array; - let queryText = highlightQuery; - - if (cached) { - logger.debug( - "WasmParser", - `Loading ${languageId} from IndexedDB cache`, - ); - wasmBytes = cached.wasmBytes; - - // Use cached highlight query if available and not empty - // Prefer cached query over passed parameter if cached is non-empty - if (cached.queryText) { - queryText = cached.queryText; - logger.debug( - "WasmParser", - `Using cached highlight query for ${languageId}`, - ); - } else if (!queryText) { - logger.warn( - "WasmParser", - `No highlight query available for ${languageId} - syntax highlighting will be disabled`, - ); - } - } else { - logger.debug( - "WasmParser", - `Loading parser for ${languageId} from ${wasmPath}`, - ); - - // Check if wasmPath is a URL (starts with http:// or https://) - const isRemoteUrl = - wasmPath.startsWith("http://") || wasmPath.startsWith("https://"); - - if (isRemoteUrl) { - // Download from remote URL - logger.debug( - "WasmParser", - `Downloading ${languageId} from remote: ${wasmPath}`, - ); - - const response = await fetch(wasmPath); - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - wasmBytes = new Uint8Array(arrayBuffer); - this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); - - // Cache for future use - try { - const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; - const checksum = await computeSha256(wasmBytes); - await indexedDBParserCache.set({ - languageId, - wasmBlob: new Blob([wasmBytes as BlobPart]), // Legacy compatibility - wasmData: wasmBytes.buffer as ArrayBuffer, // Preferred: ArrayBuffer - highlightQuery: queryText || "", - version, - checksum, - downloadedAt: Date.now(), - lastUsedAt: Date.now(), - size: wasmBytes.byteLength, - sourceUrl: wasmPath, - }); - logger.debug("WasmParser", `Cached ${languageId} to IndexedDB`); - } catch (cacheError) { - logger.warn( - "WasmParser", - `Failed to cache ${languageId}:`, - cacheError, - ); - // Continue even if caching fails - } - } else { - // Load from local path - logger.debug( - "WasmParser", - `Loading ${languageId} from local path: ${wasmPath}`, - ); - - try { - const response = await fetch(wasmPath); - if (!response.ok) { - throw new Error( - `HTTP ${response.status}: ${response.statusText}`, - ); - } - - const arrayBuffer = await response.arrayBuffer(); - wasmBytes = new Uint8Array(arrayBuffer); - this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); - } catch (localError) { - const cachedFallback = await this.readCachedParser(languageId); - if (!cachedFallback) { - throw localError; - } - - logger.debug( - "WasmParser", - `Falling back to installed parser cache for ${languageId}`, - ); - wasmBytes = cachedFallback.wasmBytes; - queryText = cachedFallback.queryText || queryText; - } - - // Also fetch highlight query from local path if not provided - if (!queryText) { - const localQuery = await this.fetchHighlightQueryText( - languageId, - wasmPath, - highlightQueryUrl, - ); - if (localQuery) { - queryText = localQuery; - logger.debug( - "WasmParser", - `Loaded highlight query for ${languageId}`, - ); - } - } - - // Cache local parsers to IndexedDB for future use - try { - const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; - const checksum = await computeSha256(wasmBytes); - await indexedDBParserCache.set({ - languageId, - wasmBlob: new Blob([wasmBytes as BlobPart]), - wasmData: wasmBytes.buffer as ArrayBuffer, - highlightQuery: queryText || "", - version, - checksum, - downloadedAt: Date.now(), - lastUsedAt: Date.now(), - size: wasmBytes.byteLength, - sourceUrl: wasmPath, - }); - logger.debug( - "WasmParser", - `Cached ${languageId} to IndexedDB (from local path)`, - ); - } catch (cacheError) { - logger.warn( - "WasmParser", - `Failed to cache ${languageId}:`, - cacheError, - ); - } - } - } - - // Create parser instance - const parser = new Parser(); - - // Load language from WASM bytes - const language = await Language.load(wasmBytes); - parser.setLanguage(language); - - // Compile highlight query if provided - let query: Query | undefined; - if (queryText) { - const sourceQueryText = queryText; - try { - const compiled = this.compileHighlightQuery( - language, - languageId, - queryText, - ); - query = compiled.query; - queryText = compiled.queryText; - - if (queryText !== sourceQueryText) { - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: queryText || "", - }); - } - }) - .catch(() => {}); - } - } catch (error) { - logger.warn( - "WasmParser", - `Failed to compile highlight query for ${languageId}`, - error, - ); - // Try to fetch local highlight query as fallback - const localQuery = await this.fetchHighlightQueryText( - languageId, - wasmPath, - highlightQueryUrl, - ); - if (localQuery && localQuery !== queryText) { - try { - const compiled = this.compileHighlightQuery( - language, - languageId, - localQuery, - ); - query = compiled.query; - queryText = compiled.queryText; - const resolvedQueryText = compiled.queryText; - logger.debug( - "WasmParser", - `Using highlight query fallback for ${languageId}`, - ); - // Update IndexedDB cache with the correct local query - indexedDBParserCache - .get(languageId) - .then((cachedEntry) => { - if (cachedEntry) { - indexedDBParserCache.set({ - ...cachedEntry, - highlightQuery: resolvedQueryText, - }); - } - }) - .catch(() => {}); - } catch (localError) { - logger.error( - "WasmParser", - `Local highlight query also failed for ${languageId}:`, - localError, - ); - } - } - } - } - - logger.debug( - "WasmParser", - `Successfully loaded parser for ${languageId}`, - ); - - return { - parser, - language, - highlightQuery: query, - highlightQueryText: queryText || undefined, - languageId, - }; - } catch (error) { - logger.error( - "WasmParser", - `Failed to load parser for ${languageId}`, - error, - ); - throw new Error(`Failed to load parser for ${languageId}: ${error}`); - } - } - - /** - * Check if a parser is loaded - */ - isLoaded(languageId: string): boolean { - return this.parsers.has(languageId); - } - - /** - * Get a loaded parser (throws if not loaded) - */ - getParser(languageId: string): LoadedParser { - const parser = this.parsers.get(languageId); - if (!parser) { - throw new Error(`Parser for ${languageId} is not loaded`); - } - return parser; - } - - /** - * Unload a parser to free memory - */ - unloadParser(languageId: string): void { - const parser = this.parsers.get(languageId); - if (parser) { - parser.parser.delete(); - this.parsers.delete(languageId); - logger.debug("WasmParser", `Unloaded parser for ${languageId}`); - } - } - - /** - * Clear all loaded parsers - */ - clear(): void { - for (const [languageId, parser] of this.parsers) { - parser.parser.delete(); - logger.debug("WasmParser", `Unloaded parser for ${languageId}`); - } - this.parsers.clear(); - this.loadingParsers.clear(); - } - - /** - * Get list of loaded parser language IDs - */ - getLoadedLanguages(): string[] { - return Array.from(this.parsers.keys()); - } + private static instance: WasmParserLoader; + private initialized = false; + private parsers: Map = new Map(); + private loadingParsers: Map> = new Map(); + + private constructor() {} + + static getInstance(): WasmParserLoader { + if (!WasmParserLoader.instance) { + WasmParserLoader.instance = new WasmParserLoader(); + } + return WasmParserLoader.instance; + } + + /** + * Initialize Tree-sitter WASM + * Must be called once before loading any parsers + */ + async initialize(): Promise { + if (this.initialized) return; + + try { + await Parser.init({ + locateFile(scriptName: string) { + const assetPath = getTreeSitterRuntimeAssetPath(scriptName); + if (typeof globalThis !== "undefined" && globalThis.location?.origin) { + return new URL(assetPath, `${globalThis.location.origin}/`).toString(); + } + + return assetPath; + }, + }); + this.initialized = true; + logger.debug("WasmParser", "Tree-sitter WASM initialized"); + } catch (error) { + logger.error("WasmParser", "Failed to initialize Tree-sitter WASM", error); + throw error; + } + } + + /** + * Check if WASM is initialized and ready to use + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Load a parser for a specific language + * Returns cached parser if already loaded + */ + async loadParser(config: ParserConfig): Promise { + const { languageId, highlightQuery } = config; + + // Check if parser is already cached + if (this.parsers.has(languageId)) { + const cached = this.parsers.get(languageId)!; + + // Update highlight query if a new one is provided and differs from the cached one + if (highlightQuery && highlightQuery !== cached.highlightQueryText) { + logger.debug("WasmParser", `Updating highlight query for ${languageId}`); + + try { + const { query, queryText: compiledQueryText } = this.compileHighlightQuery( + cached.language, + languageId, + highlightQuery, + ); + const updatedParser: LoadedParser = { + ...cached, + highlightQuery: query, + highlightQueryText: compiledQueryText, + }; + this.parsers.set(languageId, updatedParser); + + // Also update IndexedDB cache with the highlight query + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry && cachedEntry.highlightQuery !== compiledQueryText) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: compiledQueryText, + }); + } + }) + .catch(() => {}); + + return updatedParser; + } catch (error) { + // Try to fetch local highlight query as fallback before surfacing an error. + const localQuery = await this.fetchHighlightQueryText( + languageId, + config.wasmPath, + config.highlightQueryUrl, + ); + if (localQuery) { + try { + const { query, queryText: compiledQueryText } = this.compileHighlightQuery( + cached.language, + languageId, + localQuery, + ); + const updatedParser: LoadedParser = { + ...cached, + highlightQuery: query, + highlightQueryText: compiledQueryText, + }; + this.parsers.set(languageId, updatedParser); + + // Update IndexedDB cache with the correct local query + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: compiledQueryText, + }); + } + }) + .catch(() => {}); + + logger.debug("WasmParser", `Using refreshed highlight query for ${languageId}`); + return updatedParser; + } catch (localError) { + logger.error( + "WasmParser", + `Failed to create highlight query for ${languageId}:`, + error, + ); + logger.error( + "WasmParser", + `Local highlight query also failed for ${languageId}:`, + localError, + ); + } + } else { + logger.error( + "WasmParser", + `Failed to create highlight query for ${languageId}:`, + error, + ); + } + } + } + + return cached; + } + + // Return ongoing loading promise if exists + if (this.loadingParsers.has(languageId)) { + return this.loadingParsers.get(languageId)!; + } + + // Start loading parser + const loadPromise = this._loadParserInternal(config); + this.loadingParsers.set(languageId, loadPromise); + + try { + const loadedParser = await loadPromise; + this.parsers.set(languageId, loadedParser); + this.loadingParsers.delete(languageId); + return loadedParser; + } catch (error) { + this.loadingParsers.delete(languageId); + throw error; + } + } + + /** + * Fetch highlight query from parser source, CDN or local fallback. + */ + private async fetchHighlightQueryText( + languageId: string, + wasmPath?: string, + queryUrl?: string, + ): Promise { + const { query, sourceUrl } = await fetchHighlightQuery(languageId, { + wasmUrl: wasmPath, + queryUrl, + cacheMode: "no-store", + }); + if (!query) { + logger.debug("WasmParser", `No highlight query source found for ${languageId}`); + return null; + } + + logger.debug( + "WasmParser", + `Resolved highlight query for ${languageId} from ${sourceUrl || "fallback source"}`, + ); + return query; + } + + private ensureValidWasmBytes(languageId: string, wasmPath: string, wasmBytes: Uint8Array): void { + const hasWasmHeader = + wasmBytes.length >= 4 && + wasmBytes[0] === 0x00 && + wasmBytes[1] === 0x61 && + wasmBytes[2] === 0x73 && + wasmBytes[3] === 0x6d; + + if (hasWasmHeader) { + return; + } + + throw new Error( + `Invalid WASM payload for ${languageId} from ${wasmPath} (missing wasm header)`, + ); + } + + /** + * Compile highlight query with compatibility rewrites for parser/query mismatches. + */ + private compileHighlightQuery( + language: Language, + languageId: string, + queryText: string, + ): { query: Query; queryText: string } { + try { + return { + query: new Query(language, queryText), + queryText, + }; + } catch (error) { + const recovered = this.tryRecoverHighlightQuery(language, languageId, queryText, error); + if (recovered) return recovered; + throw error; + } + } + + /** + * Try to recover from unsupported nodes by removing patterns that reference them. + */ + private tryRecoverHighlightQuery( + language: Language, + languageId: string, + queryText: string, + error: unknown, + ): { query: Query; queryText: string } | null { + let rewrittenQuery = queryText; + let currentError = error; + const seenNodes = new Set(); + const seenPredicates = new Set(); + + for (let attempts = 0; attempts < 12; attempts++) { + const badNode = this.extractBadNodeName(currentError); + if (badNode && !seenNodes.has(badNode)) { + seenNodes.add(badNode); + + const nextQuery = this.rewriteIncompatibleHighlightQuery( + languageId, + rewrittenQuery, + badNode, + ); + if (nextQuery !== rewrittenQuery) { + rewrittenQuery = nextQuery; + + logger.warn( + "WasmParser", + `Applied ${languageId} highlight compatibility rewrite for missing node '${badNode}'`, + ); + + try { + return { + query: new Query(language, rewrittenQuery), + queryText: rewrittenQuery, + }; + } catch (rewriteError) { + currentError = rewriteError; + continue; + } + } + } + + const badPredicate = this.extractBadPredicateName(currentError); + if (badPredicate && !seenPredicates.has(badPredicate)) { + seenPredicates.add(badPredicate); + + const nextQuery = this.rewriteIncompatiblePredicateQuery( + languageId, + rewrittenQuery, + badPredicate, + ); + if (nextQuery !== rewrittenQuery) { + rewrittenQuery = nextQuery; + + logger.warn( + "WasmParser", + `Applied ${languageId} highlight compatibility rewrite for unsupported predicate '${badPredicate}'`, + ); + + try { + return { + query: new Query(language, rewrittenQuery), + queryText: rewrittenQuery, + }; + } catch (rewriteError) { + currentError = rewriteError; + continue; + } + } + } + + break; + } + + logger.error("WasmParser", `Highlight query rewrite failed for ${languageId}:`, currentError); + return null; + } + + private extractBadNodeName(error: unknown): string | null { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : String(error); + const match = message.match(/Bad node name '([^']+)'/); + return match?.[1] ?? null; + } + + private extractBadPredicateName(error: unknown): string | null { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : String(error); + const backtickMatch = message.match(/`([^`]+)` predicate/); + if (backtickMatch?.[1]) return backtickMatch[1]; + const quoteMatch = message.match(/predicate ['"]([^'"]+)['"]/); + return quoteMatch?.[1] ?? null; + } + + /** + * Rewrite unsupported node references so older parser WASM builds can still highlight partially. + */ + private rewriteIncompatibleHighlightQuery( + _languageId: string, + queryText: string, + badNodeName: string, + ): string { + return this.stripNodeExpressions(queryText, badNodeName); + } + + /** + * Rewrite incompatible predicate invocations (e.g. predicate arity changes across engines). + */ + private rewriteIncompatiblePredicateQuery( + _languageId: string, + queryText: string, + predicateName: string, + ): string { + return this.stripPredicateCalls(queryText, predicateName); + } + + private stripNodeExpressions(queryText: string, badNodeName: string): string { + const nodeRegex = new RegExp( + `\\(${badNodeName.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(?=[\\s)])`, + ); + const lines = queryText.split("\n"); + const output: string[] = []; + let expressionLines: string[] = []; + let depth = 0; + let inExpression = false; + + const flushExpression = () => { + if (expressionLines.length === 0) return; + const expressionText = expressionLines.join("\n"); + if (!nodeRegex.test(expressionText)) { + output.push(expressionText); + } + expressionLines = []; + }; + + for (const line of lines) { + if (!inExpression) { + if (line.trimStart().startsWith("(")) { + inExpression = true; + expressionLines = [line]; + depth = (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; + if (depth <= 0) { + flushExpression(); + inExpression = false; + depth = 0; + } + } else { + output.push(line); + } + continue; + } + + expressionLines.push(line); + depth += (line.match(/\(/g) || []).length - (line.match(/\)/g) || []).length; + if (depth <= 0) { + flushExpression(); + inExpression = false; + depth = 0; + } + } + + if (inExpression) { + flushExpression(); + } + + return `${output + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`; + } + + private stripPredicateCalls(queryText: string, predicateName: string): string { + const escapedPredicate = predicateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const predicateRegex = new RegExp(`\\(${escapedPredicate}(?=[\\s)])`, "g"); + const output: string[] = []; + let cursor = 0; + let changed = false; + let match = predicateRegex.exec(queryText); + while (match !== null) { + const start = match.index; + const end = this.findMatchingParenIndex(queryText, start); + if (end === -1) { + return queryText; + } + + output.push(queryText.slice(cursor, start)); + cursor = end + 1; + changed = true; + match = predicateRegex.exec(queryText); + } + + if (!changed) return queryText; + + output.push(queryText.slice(cursor)); + + return `${output + .join("") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd()}\n`; + } + + private findMatchingParenIndex(text: string, startIndex: number): number { + if (startIndex < 0 || startIndex >= text.length || text[startIndex] !== "(") { + return -1; + } + + let depth = 0; + let inString = false; + let escapeNext = false; + let inComment = false; + + for (let index = startIndex; index < text.length; index++) { + const char = text[index]; + + if (inComment) { + if (char === "\n") { + inComment = false; + } + continue; + } + + if (inString) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (char === "\\") { + escapeNext = true; + continue; + } + if (char === '"') { + inString = false; + } + continue; + } + + if (char === ";") { + inComment = true; + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "(") { + depth += 1; + continue; + } + + if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + if (depth < 0) { + return -1; + } + } + } + + return -1; + } + + private async readCachedParser( + languageId: string, + ): Promise<{ wasmBytes: Uint8Array; queryText?: string } | null> { + const cached = await indexedDBParserCache.get(languageId); + if (!cached) return null; + + let wasmBytes: Uint8Array; + if (cached.wasmData) { + wasmBytes = new Uint8Array(cached.wasmData); + logger.debug("WasmParser", `Using cached ArrayBuffer for ${languageId}`); + } else if (cached.wasmBlob) { + try { + const arrayBuffer = await cached.wasmBlob.arrayBuffer(); + wasmBytes = new Uint8Array(arrayBuffer); + logger.debug("WasmParser", `Using cached Blob for ${languageId}`); + } catch (blobError) { + logger.error( + "WasmParser", + `Failed to read cached Blob for ${languageId}, clearing cache entry`, + blobError, + ); + await indexedDBParserCache.delete(languageId); + throw new Error(`Cached parser corrupted, please reinstall ${languageId}`); + } + } else { + throw new Error(`Cache entry for ${languageId} has no WASM data`); + } + + return { + wasmBytes, + queryText: cached.highlightQuery?.trim() ? cached.highlightQuery : undefined, + }; + } + + private async _loadParserInternal(config: ParserConfig): Promise { + const { languageId, wasmPath, highlightQuery, highlightQueryUrl } = config; + + try { + // Ensure Tree-sitter is initialized + if (!this.initialized) { + await this.initialize(); + } + + // Check if this is a local/bundled parser (not a remote URL) + // Local parsers should always use the local file, not IndexedDB cache + const isLocalParser = wasmPath.startsWith("/tree-sitter/"); + + // Try to load from IndexedDB cache first (skip for local parsers) + const cached = isLocalParser ? null : await this.readCachedParser(languageId); + + let wasmBytes: Uint8Array; + let queryText = highlightQuery; + + if (cached) { + logger.debug("WasmParser", `Loading ${languageId} from IndexedDB cache`); + wasmBytes = cached.wasmBytes; + + // Use cached highlight query if available and not empty + // Prefer cached query over passed parameter if cached is non-empty + if (cached.queryText) { + queryText = cached.queryText; + logger.debug("WasmParser", `Using cached highlight query for ${languageId}`); + } else if (!queryText) { + logger.warn( + "WasmParser", + `No highlight query available for ${languageId} - syntax highlighting will be disabled`, + ); + } + } else { + logger.debug("WasmParser", `Loading parser for ${languageId} from ${wasmPath}`); + + // Check if wasmPath is a URL (starts with http:// or https://) + const isRemoteUrl = wasmPath.startsWith("http://") || wasmPath.startsWith("https://"); + + if (isRemoteUrl) { + // Download from remote URL + logger.debug("WasmParser", `Downloading ${languageId} from remote: ${wasmPath}`); + + const response = await fetch(wasmPath); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + wasmBytes = new Uint8Array(arrayBuffer); + this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); + + // Cache for future use + try { + const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; + const checksum = await computeSha256(wasmBytes as Uint8Array); + await indexedDBParserCache.set({ + languageId, + wasmBlob: new Blob([wasmBytes as BlobPart]), // Legacy compatibility + wasmData: wasmBytes.buffer as ArrayBuffer, // Preferred: ArrayBuffer + highlightQuery: queryText || "", + version, + checksum, + downloadedAt: Date.now(), + lastUsedAt: Date.now(), + size: wasmBytes.byteLength, + sourceUrl: wasmPath, + }); + logger.debug("WasmParser", `Cached ${languageId} to IndexedDB`); + } catch (cacheError) { + logger.warn("WasmParser", `Failed to cache ${languageId}:`, cacheError); + // Continue even if caching fails + } + } else { + // Load from local path + logger.debug("WasmParser", `Loading ${languageId} from local path: ${wasmPath}`); + + try { + const response = await fetch(wasmPath); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + wasmBytes = new Uint8Array(arrayBuffer); + this.ensureValidWasmBytes(languageId, wasmPath, wasmBytes); + } catch (localError) { + const cachedFallback = await this.readCachedParser(languageId); + if (!cachedFallback) { + throw localError; + } + + logger.debug("WasmParser", `Falling back to installed parser cache for ${languageId}`); + wasmBytes = cachedFallback.wasmBytes; + queryText = cachedFallback.queryText || queryText; + } + + // Also fetch highlight query from local path if not provided + if (!queryText) { + const localQuery = await this.fetchHighlightQueryText( + languageId, + wasmPath, + highlightQueryUrl, + ); + if (localQuery) { + queryText = localQuery; + logger.debug("WasmParser", `Loaded highlight query for ${languageId}`); + } + } + + // Cache local parsers to IndexedDB for future use + try { + const version = (await fetchManifestVersion(wasmPath)) || "1.0.0"; + const checksum = await computeSha256(wasmBytes as Uint8Array); + await indexedDBParserCache.set({ + languageId, + wasmBlob: new Blob([wasmBytes as BlobPart]), + wasmData: wasmBytes.buffer as ArrayBuffer, + highlightQuery: queryText || "", + version, + checksum, + downloadedAt: Date.now(), + lastUsedAt: Date.now(), + size: wasmBytes.byteLength, + sourceUrl: wasmPath, + }); + logger.debug("WasmParser", `Cached ${languageId} to IndexedDB (from local path)`); + } catch (cacheError) { + logger.warn("WasmParser", `Failed to cache ${languageId}:`, cacheError); + } + } + } + + // Create parser instance + const parser = new Parser(); + + // Load language from WASM bytes + const language = await Language.load(wasmBytes); + parser.setLanguage(language); + + // Compile highlight query if provided + let query: Query | undefined; + if (queryText) { + const sourceQueryText = queryText; + try { + const compiled = this.compileHighlightQuery(language, languageId, queryText); + query = compiled.query; + queryText = compiled.queryText; + + if (queryText !== sourceQueryText) { + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: queryText || "", + }); + } + }) + .catch(() => {}); + } + } catch (error) { + logger.warn("WasmParser", `Failed to compile highlight query for ${languageId}`, error); + // Try to fetch local highlight query as fallback + const localQuery = await this.fetchHighlightQueryText( + languageId, + wasmPath, + highlightQueryUrl, + ); + if (localQuery && localQuery !== queryText) { + try { + const compiled = this.compileHighlightQuery(language, languageId, localQuery); + query = compiled.query; + queryText = compiled.queryText; + const resolvedQueryText = compiled.queryText; + logger.debug("WasmParser", `Using highlight query fallback for ${languageId}`); + // Update IndexedDB cache with the correct local query + indexedDBParserCache + .get(languageId) + .then((cachedEntry) => { + if (cachedEntry) { + indexedDBParserCache.set({ + ...cachedEntry, + highlightQuery: resolvedQueryText, + }); + } + }) + .catch(() => {}); + } catch (localError) { + logger.error( + "WasmParser", + `Local highlight query also failed for ${languageId}:`, + localError, + ); + } + } + } + } + + logger.debug("WasmParser", `Successfully loaded parser for ${languageId}`); + + return { + parser, + language, + highlightQuery: query, + highlightQueryText: queryText || undefined, + languageId, + }; + } catch (error) { + logger.error("WasmParser", `Failed to load parser for ${languageId}`, error); + throw new Error(`Failed to load parser for ${languageId}: ${error}`); + } + } + + /** + * Check if a parser is loaded + */ + isLoaded(languageId: string): boolean { + return this.parsers.has(languageId); + } + + /** + * Get a loaded parser (throws if not loaded) + */ + getParser(languageId: string): LoadedParser { + const parser = this.parsers.get(languageId); + if (!parser) { + throw new Error(`Parser for ${languageId} is not loaded`); + } + return parser; + } + + /** + * Unload a parser to free memory + */ + unloadParser(languageId: string): void { + const parser = this.parsers.get(languageId); + if (parser) { + parser.parser.delete(); + this.parsers.delete(languageId); + logger.debug("WasmParser", `Unloaded parser for ${languageId}`); + } + } + + /** + * Clear all loaded parsers + */ + clear(): void { + for (const [languageId, parser] of this.parsers) { + parser.parser.delete(); + logger.debug("WasmParser", `Unloaded parser for ${languageId}`); + } + this.parsers.clear(); + this.loadingParsers.clear(); + } + + /** + * Get list of loaded parser language IDs + */ + getLoadedLanguages(): string[] { + return Array.from(this.parsers.keys()); + } } export const wasmParserLoader = WasmParserLoader.getInstance();