diff --git a/apps/desktop/src/renderer/stores/tabs/actions/file-viewer-actions.ts b/apps/desktop/src/renderer/stores/tabs/actions/file-viewer-actions.ts new file mode 100644 index 000000000..53d54e3f4 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/actions/file-viewer-actions.ts @@ -0,0 +1,174 @@ +import type { MosaicNode } from "react-mosaic-component"; +import type { AddFileViewerPaneOptions, Pane, Tab, TabsState } from "../types"; +import { createFileViewerPane, extractPaneIdsFromLayout } from "../utils"; + +interface FileViewerResult { + tabs?: Tab[]; + panes: Record; + focusedPaneIds: Record; +} + +/** + * Adds a file viewer pane to the active tab in a workspace. + * Handles preview mode (unpinned panes that can be replaced) and pinned panes. + * + * Returns null if there's no active tab (caller should create one first). + */ +export function addFileViewerPaneAction( + state: TabsState, + activeTab: Tab, + options: AddFileViewerPaneOptions, +): { result: FileViewerResult; paneId: string } { + const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); + + // First, check if the file is already open in a pinned pane - if so, just focus it + const existingPinnedPane = tabPaneIds + .map((id) => state.panes[id]) + .find( + (p) => + p?.type === "file-viewer" && + p.fileViewer?.isPinned && + p.fileViewer.filePath === options.filePath && + p.fileViewer.diffCategory === options.diffCategory && + p.fileViewer.commitHash === options.commitHash, + ); + + if (existingPinnedPane) { + // File is already open in a pinned pane, just focus it + return { + result: { + panes: state.panes, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: existingPinnedPane.id, + }, + }, + paneId: existingPinnedPane.id, + }; + } + + // Look for an existing unpinned (preview) file-viewer pane in the active tab + const fileViewerPanes = tabPaneIds + .map((id) => state.panes[id]) + .filter( + (p) => + p?.type === "file-viewer" && p.fileViewer && !p.fileViewer.isPinned, + ); + + // If we found an unpinned (preview) file-viewer pane, check if it's the same file + if (fileViewerPanes.length > 0) { + const paneToReuse = fileViewerPanes[0]; + const existingFileViewer = paneToReuse.fileViewer; + if (!existingFileViewer) { + // Should not happen due to filter above, but satisfy type checker + return createNewFileViewerPane(state, activeTab, options); + } + + // If clicking the same file that's already in preview, pin it + const isSameFile = + existingFileViewer.filePath === options.filePath && + existingFileViewer.diffCategory === options.diffCategory && + existingFileViewer.commitHash === options.commitHash; + + if (isSameFile) { + // Pin the preview pane + return { + result: { + panes: { + ...state.panes, + [paneToReuse.id]: { + ...paneToReuse, + fileViewer: { + ...existingFileViewer, + isPinned: true, + }, + }, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: paneToReuse.id, + }, + }, + paneId: paneToReuse.id, + }; + } + + // Different file - replace the preview pane content + const fileName = options.filePath.split("/").pop() || options.filePath; + + // Determine default view mode + let viewMode: "raw" | "rendered" | "diff" = "raw"; + if (options.diffCategory) { + viewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") || + options.filePath.endsWith(".mdx") + ) { + viewMode = "rendered"; + } + + return { + result: { + panes: { + ...state.panes, + [paneToReuse.id]: { + ...paneToReuse, + name: fileName, + fileViewer: { + filePath: options.filePath, + viewMode, + isPinned: options.isPinned ?? false, + diffLayout: "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, + }, + }, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: paneToReuse.id, + }, + }, + paneId: paneToReuse.id, + }; + } + + // No reusable pane found, create a new one + return createNewFileViewerPane(state, activeTab, options); +} + +/** + * Creates a new file viewer pane and adds it to the tab layout + */ +function createNewFileViewerPane( + state: TabsState, + activeTab: Tab, + options: AddFileViewerPaneOptions, +): { result: FileViewerResult; paneId: string } { + const newPane = createFileViewerPane(activeTab.id, options); + + const newLayout: MosaicNode = { + direction: "row", + first: activeTab.layout, + second: newPane.id, + splitPercentage: 50, + }; + + return { + result: { + tabs: state.tabs.map((t) => + t.id === activeTab.id ? { ...t, layout: newLayout } : t, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: newPane.id, + }, + }, + paneId: newPane.id, + }; +} diff --git a/apps/desktop/src/renderer/stores/tabs/actions/split-actions.ts b/apps/desktop/src/renderer/stores/tabs/actions/split-actions.ts new file mode 100644 index 000000000..c34d38eae --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/actions/split-actions.ts @@ -0,0 +1,95 @@ +import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import { updateTree } from "react-mosaic-component"; +import type { Pane, Tab, TabsState } from "../types"; +import { type CreatePaneOptions, createPane } from "../utils"; + +interface SplitResult { + tabs: Tab[]; + panes: Record; + focusedPaneIds: Record; +} + +/** + * Splits a pane in the specified direction. + * Returns null if the tab or source pane is not found. + */ +export function splitPane( + state: TabsState, + tabId: string, + sourcePaneId: string, + direction: "row" | "column", + path?: MosaicBranch[], + options?: CreatePaneOptions, +): SplitResult | null { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab) return null; + + const sourcePane = state.panes[sourcePaneId]; + if (!sourcePane || sourcePane.tabId !== tabId) return null; + + // Always create a new terminal when splitting + const newPane = createPane(tabId, "terminal", options); + + let newLayout: MosaicNode; + if (path && path.length > 0) { + // Split at a specific path in the layout + newLayout = updateTree(tab.layout, [ + { + path, + spec: { + $set: { + direction, + first: sourcePaneId, + second: newPane.id, + splitPercentage: 50, + }, + }, + }, + ]); + } else { + // Split the pane directly + newLayout = { + direction, + first: tab.layout, + second: newPane.id, + splitPercentage: 50, + }; + } + + return { + tabs: state.tabs.map((t) => + t.id === tabId ? { ...t, layout: newLayout } : t, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [tabId]: newPane.id, + }, + }; +} + +/** + * Splits a pane vertically (side by side). + */ +export function splitPaneVertical( + state: TabsState, + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + options?: CreatePaneOptions, +): SplitResult | null { + return splitPane(state, tabId, sourcePaneId, "row", path, options); +} + +/** + * Splits a pane horizontally (stacked). + */ +export function splitPaneHorizontal( + state: TabsState, + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + options?: CreatePaneOptions, +): SplitResult | null { + return splitPane(state, tabId, sourcePaneId, "column", path, options); +} diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index e7c43b6e7..a4601c56e 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1,9 +1,13 @@ import type { MosaicNode } from "react-mosaic-component"; -import { updateTree } from "react-mosaic-component"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { trpcTabsStorage } from "../../lib/trpc-storage"; +import { addFileViewerPaneAction } from "./actions/file-viewer-actions"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; +import { + splitPaneHorizontal as splitPaneHorizontalAction, + splitPaneVertical as splitPaneVerticalAction, +} from "./actions/split-actions"; import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, @@ -11,6 +15,7 @@ import { createPane, createTabWithPane, extractPaneIdsFromLayout, + findNextTab, getAdjacentPaneId, getFirstPaneId, getPaneIdsForTab, @@ -19,58 +24,6 @@ import { } from "./utils"; import { killTerminalForPane } from "./utils/terminal-cleanup"; -/** - * Finds the next best tab to activate when closing a tab. - * Priority order: - * 1. Most recently used tab from history stack - * 2. Next/previous tab by position - * 3. Any remaining tab in the workspace - */ -const findNextTab = (state: TabsState, tabIdToClose: string): string | null => { - const tabToClose = state.tabs.find((t) => t.id === tabIdToClose); - if (!tabToClose) return null; - - const workspaceId = tabToClose.workspaceId; - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && t.id !== tabIdToClose, - ); - - if (workspaceTabs.length === 0) return null; - - // Try history first - const historyStack = state.tabHistoryStacks[workspaceId] || []; - for (const historyTabId of historyStack) { - if (historyTabId === tabIdToClose) continue; - if (workspaceTabs.some((t) => t.id === historyTabId)) { - return historyTabId; - } - } - - // Try position-based (next, then previous) - const allWorkspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId, - ); - const currentIndex = allWorkspaceTabs.findIndex((t) => t.id === tabIdToClose); - - if (currentIndex !== -1) { - const nextIndex = currentIndex + 1; - const prevIndex = currentIndex - 1; - - if ( - nextIndex < allWorkspaceTabs.length && - allWorkspaceTabs[nextIndex].id !== tabIdToClose - ) { - return allWorkspaceTabs[nextIndex].id; - } - if (prevIndex >= 0 && allWorkspaceTabs[prevIndex].id !== tabIdToClose) { - return allWorkspaceTabs[prevIndex].id; - } - } - - // Fallback to first available - return workspaceTabs[0]?.id || null; -}; - export const useTabsStore = create()( devtools( persist( @@ -148,7 +101,11 @@ export const useTabsStore = create()( ).filter((id) => id !== tabId); if (state.activeTabIds[workspaceId] === tabId) { - newActiveTabIds[workspaceId] = findNextTab(state, tabId); + newActiveTabIds[workspaceId] = findNextTab( + state.tabs, + state.tabHistoryStacks, + tabId, + ); } const newFocusedPaneIds = { ...state.focusedPaneIds }; @@ -387,143 +344,13 @@ export const useTabsStore = create()( return paneId; } - const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); - - // First, check if the file is already open in a pinned pane - if so, just focus it - const existingPinnedPane = tabPaneIds - .map((id) => state.panes[id]) - .find( - (p) => - p?.type === "file-viewer" && - p.fileViewer?.isPinned && - p.fileViewer.filePath === options.filePath && - p.fileViewer.diffCategory === options.diffCategory && - p.fileViewer.commitHash === options.commitHash, - ); - - if (existingPinnedPane) { - // File is already open in a pinned pane, just focus it - set({ - focusedPaneIds: { - ...state.focusedPaneIds, - [activeTab.id]: existingPinnedPane.id, - }, - }); - return existingPinnedPane.id; - } - - // Look for an existing unpinned (preview) file-viewer pane in the active tab - const fileViewerPanes = tabPaneIds - .map((id) => state.panes[id]) - .filter( - (p) => - p?.type === "file-viewer" && - p.fileViewer && - !p.fileViewer.isPinned, - ); - - // If we found an unpinned (preview) file-viewer pane, check if it's the same file - if (fileViewerPanes.length > 0) { - const paneToReuse = fileViewerPanes[0]; - const existingFileViewer = paneToReuse.fileViewer; - if (!existingFileViewer) { - // Should not happen due to filter above, but satisfy type checker - return ""; - } - - // If clicking the same file that's already in preview, pin it - const isSameFile = - existingFileViewer.filePath === options.filePath && - existingFileViewer.diffCategory === options.diffCategory && - existingFileViewer.commitHash === options.commitHash; - - if (isSameFile) { - // Pin the preview pane - set({ - panes: { - ...state.panes, - [paneToReuse.id]: { - ...paneToReuse, - fileViewer: { - ...existingFileViewer, - isPinned: true, - }, - }, - }, - focusedPaneIds: { - ...state.focusedPaneIds, - [activeTab.id]: paneToReuse.id, - }, - }); - return paneToReuse.id; - } - - // Different file - replace the preview pane content - const fileName = - options.filePath.split("/").pop() || options.filePath; - - // Determine default view mode - let viewMode: "raw" | "rendered" | "diff" = "raw"; - if (options.diffCategory) { - viewMode = "diff"; - } else if ( - options.filePath.endsWith(".md") || - options.filePath.endsWith(".markdown") || - options.filePath.endsWith(".mdx") - ) { - viewMode = "rendered"; - } - - set({ - panes: { - ...state.panes, - [paneToReuse.id]: { - ...paneToReuse, - name: fileName, - fileViewer: { - filePath: options.filePath, - viewMode, - isPinned: options.isPinned ?? false, - diffLayout: "inline", - diffCategory: options.diffCategory, - commitHash: options.commitHash, - oldPath: options.oldPath, - initialLine: options.line, - initialColumn: options.column, - }, - }, - }, - focusedPaneIds: { - ...state.focusedPaneIds, - [activeTab.id]: paneToReuse.id, - }, - }); - - return paneToReuse.id; - } - - // No reusable pane found, create a new one - const newPane = createFileViewerPane(activeTab.id, options); - - const newLayout: MosaicNode = { - direction: "row", - first: activeTab.layout, - second: newPane.id, - splitPercentage: 50, - }; - - set({ - tabs: state.tabs.map((t) => - t.id === activeTab.id ? { ...t, layout: newLayout } : t, - ), - panes: { ...state.panes, [newPane.id]: newPane }, - focusedPaneIds: { - ...state.focusedPaneIds, - [activeTab.id]: newPane.id, - }, - }); - - return newPane.id; + const { result, paneId } = addFileViewerPaneAction( + state, + activeTab, + options, + ); + set(result); + return paneId; }, removePane: (paneId) => { @@ -719,101 +546,25 @@ export const useTabsStore = create()( // Split operations splitPaneVertical: (tabId, sourcePaneId, path, options) => { - const state = get(); - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab) return; - - const sourcePane = state.panes[sourcePaneId]; - if (!sourcePane || sourcePane.tabId !== tabId) return; - - // Always create a new terminal when splitting - const newPane = createPane(tabId, "terminal", options); - - let newLayout: MosaicNode; - if (path && path.length > 0) { - // Split at a specific path in the layout - newLayout = updateTree(tab.layout, [ - { - path, - spec: { - $set: { - direction: "row", - first: sourcePaneId, - second: newPane.id, - splitPercentage: 50, - }, - }, - }, - ]); - } else { - // Split the pane directly - newLayout = { - direction: "row", - first: tab.layout, - second: newPane.id, - splitPercentage: 50, - }; - } - - set({ - tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, layout: newLayout } : t, - ), - panes: { ...state.panes, [newPane.id]: newPane }, - focusedPaneIds: { - ...state.focusedPaneIds, - [tabId]: newPane.id, - }, - }); + const result = splitPaneVerticalAction( + get(), + tabId, + sourcePaneId, + path, + options, + ); + if (result) set(result); }, splitPaneHorizontal: (tabId, sourcePaneId, path, options) => { - const state = get(); - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab) return; - - const sourcePane = state.panes[sourcePaneId]; - if (!sourcePane || sourcePane.tabId !== tabId) return; - - // Always create a new terminal when splitting - const newPane = createPane(tabId, "terminal", options); - - let newLayout: MosaicNode; - if (path && path.length > 0) { - // Split at a specific path in the layout - newLayout = updateTree(tab.layout, [ - { - path, - spec: { - $set: { - direction: "column", - first: sourcePaneId, - second: newPane.id, - splitPercentage: 50, - }, - }, - }, - ]); - } else { - // Split the pane directly - newLayout = { - direction: "column", - first: tab.layout, - second: newPane.id, - splitPercentage: 50, - }; - } - - set({ - tabs: state.tabs.map((t) => - t.id === tabId ? { ...t, layout: newLayout } : t, - ), - panes: { ...state.panes, [newPane.id]: newPane }, - focusedPaneIds: { - ...state.focusedPaneIds, - [tabId]: newPane.id, - }, - }); + const result = splitPaneHorizontalAction( + get(), + tabId, + sourcePaneId, + path, + options, + ); + if (result) set(result); }, splitPaneAuto: (tabId, sourcePaneId, dimensions, path, options) => { diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 3bf9049bf..925cf2c09 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -421,3 +421,57 @@ export const updateHistoryStack = ( return newStack; }; + +/** + * Finds the next best tab to activate when closing a tab. + * Priority order: + * 1. Most recently used tab from history stack + * 2. Next/previous tab by position + * 3. Any remaining tab in the workspace + */ +export const findNextTab = ( + tabs: Tab[], + tabHistoryStacks: Record, + tabIdToClose: string, +): string | null => { + const tabToClose = tabs.find((t) => t.id === tabIdToClose); + if (!tabToClose) return null; + + const workspaceId = tabToClose.workspaceId; + const workspaceTabs = tabs.filter( + (t) => t.workspaceId === workspaceId && t.id !== tabIdToClose, + ); + + if (workspaceTabs.length === 0) return null; + + // Try history first + const historyStack = tabHistoryStacks[workspaceId] || []; + for (const historyTabId of historyStack) { + if (historyTabId === tabIdToClose) continue; + if (workspaceTabs.some((t) => t.id === historyTabId)) { + return historyTabId; + } + } + + // Try position-based (next, then previous) + const allWorkspaceTabs = tabs.filter((t) => t.workspaceId === workspaceId); + const currentIndex = allWorkspaceTabs.findIndex((t) => t.id === tabIdToClose); + + if (currentIndex !== -1) { + const nextIndex = currentIndex + 1; + const prevIndex = currentIndex - 1; + + if ( + nextIndex < allWorkspaceTabs.length && + allWorkspaceTabs[nextIndex].id !== tabIdToClose + ) { + return allWorkspaceTabs[nextIndex].id; + } + if (prevIndex >= 0 && allWorkspaceTabs[prevIndex].id !== tabIdToClose) { + return allWorkspaceTabs[prevIndex].id; + } + } + + // Fallback to first available + return workspaceTabs[0]?.id || null; +}; diff --git a/packages/ui/src/components/ai-elements/hooks/index.ts b/packages/ui/src/components/ai-elements/hooks/index.ts new file mode 100644 index 000000000..4599698e9 --- /dev/null +++ b/packages/ui/src/components/ai-elements/hooks/index.ts @@ -0,0 +1,12 @@ +export { + type AttachmentError, + type AttachmentFile, + type UseAttachmentsOptions, + type UseAttachmentsResult, + useAttachments, +} from "./use-attachments"; +export { + type UseSpeechRecognitionOptions, + type UseSpeechRecognitionResult, + useSpeechRecognition, +} from "./use-speech-recognition"; diff --git a/packages/ui/src/components/ai-elements/hooks/use-attachments.ts b/packages/ui/src/components/ai-elements/hooks/use-attachments.ts new file mode 100644 index 000000000..11f9cf5df --- /dev/null +++ b/packages/ui/src/components/ai-elements/hooks/use-attachments.ts @@ -0,0 +1,183 @@ +"use client"; + +import type { FileUIPart } from "ai"; +import { nanoid } from "nanoid"; +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AttachmentFile = FileUIPart & { id: string }; + +export interface AttachmentError { + code: "max_files" | "max_file_size" | "accept"; + message: string; +} + +export interface UseAttachmentsOptions { + accept?: string; + maxFiles?: number; + maxFileSize?: number; + onError?: (err: AttachmentError) => void; +} + +export interface UseAttachmentsResult { + files: AttachmentFile[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +} + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Hook for managing file attachments with validation. + * Handles file state, validation, and blob URL lifecycle. + */ +export function useAttachments({ + accept, + maxFiles, + maxFileSize, + onError, +}: UseAttachmentsOptions = {}): UseAttachmentsResult { + const [files, setFiles] = useState([]); + const fileInputRef = useRef(null); + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept], + ); + + const add = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setFiles((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: AttachmentFile[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError], + ); + + const remove = useCallback( + (id: string) => + setFiles((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [], + ); + + const clear = useCallback( + () => + setFiles((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [], + ); + + const openFileDialog = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + // Cleanup blob URLs on unmount + useEffect( + () => () => { + for (const f of filesRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }, + [], + ); + + return { + files, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }; +} diff --git a/packages/ui/src/components/ai-elements/hooks/use-speech-recognition.ts b/packages/ui/src/components/ai-elements/hooks/use-speech-recognition.ts new file mode 100644 index 000000000..776970f89 --- /dev/null +++ b/packages/ui/src/components/ai-elements/hooks/use-speech-recognition.ts @@ -0,0 +1,174 @@ +"use client"; + +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Speech Recognition Type Definitions +// ============================================================================ + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => void) | null; + onend: ((this: SpeechRecognition, ev: Event) => void) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) + | null; +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +type SpeechRecognitionResultList = { + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +}; + +type SpeechRecognitionResult = { + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +}; + +type SpeechRecognitionAlternative = { + transcript: string; + confidence: number; +}; + +interface SpeechRecognitionErrorEvent extends Event { + error: string; +} + +declare global { + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } +} + +// ============================================================================ +// Hook +// ============================================================================ + +export interface UseSpeechRecognitionOptions { + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; +} + +export interface UseSpeechRecognitionResult { + isListening: boolean; + isSupported: boolean; + toggleListening: () => void; +} + +/** + * Hook for managing speech recognition functionality. + * Handles browser API setup, listening state, and transcription. + */ +export function useSpeechRecognition({ + textareaRef, + onTranscriptionChange, +}: UseSpeechRecognitionOptions): UseSpeechRecognitionResult { + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState( + null, + ); + const recognitionRef = useRef(null); + + useEffect(() => { + if ( + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) + ) { + const SpeechRecognitionAPI = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognitionAPI(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = "en-US"; + + speechRecognition.onstart = () => { + setIsListening(true); + }; + + speechRecognition.onend = () => { + setIsListening(false); + }; + + speechRecognition.onresult = (event) => { + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result?.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } + + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; + + textarea.value = newValue; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; + + speechRecognition.onerror = (event) => { + console.error("[speech-recognition] Error:", event.error); + setIsListening(false); + }; + + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); + + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); + + return { + isListening, + isSupported: recognition !== null, + toggleListening, + }; +} diff --git a/packages/ui/src/components/ai-elements/prompt-input.tsx b/packages/ui/src/components/ai-elements/prompt-input.tsx index e6372b157..dabeee9d9 100644 --- a/packages/ui/src/components/ai-elements/prompt-input.tsx +++ b/packages/ui/src/components/ai-elements/prompt-input.tsx @@ -69,6 +69,8 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; +import { useSpeechRecognition } from "./hooks/use-speech-recognition"; +import { convertBlobUrlToDataUrl } from "./utils/blob-to-data-url"; // ============================================================================ // Provider Context & Types @@ -678,23 +680,6 @@ export const PromptInput = ({ event.currentTarget.value = ""; }; - const convertBlobUrlToDataUrl = async ( - url: string, - ): Promise => { - try { - const response = await fetch(url); - const blob = await response.blob(); - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.onerror = () => resolve(null); - reader.readAsDataURL(blob); - }); - } catch { - return null; - } - }; - const ctx = useMemo( () => ({ files: files.map((item) => ({ ...item, id: item.id })), @@ -1054,60 +1039,6 @@ export const PromptInputSubmit = ({ ); }; -interface SpeechRecognition extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - start(): void; - stop(): void; - onstart: ((this: SpeechRecognition, ev: Event) => void) | null; - onend: ((this: SpeechRecognition, ev: Event) => void) | null; - onresult: - | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) - | null; - onerror: - | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) - | null; -} - -interface SpeechRecognitionEvent extends Event { - results: SpeechRecognitionResultList; - resultIndex: number; -} - -type SpeechRecognitionResultList = { - readonly length: number; - item(index: number): SpeechRecognitionResult; - [index: number]: SpeechRecognitionResult; -}; - -type SpeechRecognitionResult = { - readonly length: number; - item(index: number): SpeechRecognitionAlternative; - [index: number]: SpeechRecognitionAlternative; - isFinal: boolean; -}; - -type SpeechRecognitionAlternative = { - transcript: string; - confidence: number; -}; - -interface SpeechRecognitionErrorEvent extends Event { - error: string; -} - -declare global { - interface Window { - SpeechRecognition: { - new (): SpeechRecognition; - }; - webkitSpeechRecognition: { - new (): SpeechRecognition; - }; - } -} - export type PromptInputSpeechButtonProps = ComponentProps< typeof PromptInputButton > & { @@ -1121,82 +1052,10 @@ export const PromptInputSpeechButton = ({ onTranscriptionChange, ...props }: PromptInputSpeechButtonProps) => { - const [isListening, setIsListening] = useState(false); - const [recognition, setRecognition] = useState( - null, - ); - const recognitionRef = useRef(null); - - useEffect(() => { - if ( - typeof window !== "undefined" && - ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) - ) { - const SpeechRecognition = - window.SpeechRecognition || window.webkitSpeechRecognition; - const speechRecognition = new SpeechRecognition(); - - speechRecognition.continuous = true; - speechRecognition.interimResults = true; - speechRecognition.lang = "en-US"; - - speechRecognition.onstart = () => { - setIsListening(true); - }; - - speechRecognition.onend = () => { - setIsListening(false); - }; - - speechRecognition.onresult = (event) => { - let finalTranscript = ""; - - for (let i = event.resultIndex; i < event.results.length; i++) { - const result = event.results[i]; - if (result?.isFinal) { - finalTranscript += result[0]?.transcript ?? ""; - } - } - - if (finalTranscript && textareaRef?.current) { - const textarea = textareaRef.current; - const currentValue = textarea.value; - const newValue = - currentValue + (currentValue ? " " : "") + finalTranscript; - - textarea.value = newValue; - textarea.dispatchEvent(new Event("input", { bubbles: true })); - onTranscriptionChange?.(newValue); - } - }; - - speechRecognition.onerror = (event) => { - console.error("Speech recognition error:", event.error); - setIsListening(false); - }; - - recognitionRef.current = speechRecognition; - setRecognition(speechRecognition); - } - - return () => { - if (recognitionRef.current) { - recognitionRef.current.stop(); - } - }; - }, [textareaRef, onTranscriptionChange]); - - const toggleListening = useCallback(() => { - if (!recognition) { - return; - } - - if (isListening) { - recognition.stop(); - } else { - recognition.start(); - } - }, [recognition, isListening]); + const { isListening, isSupported, toggleListening } = useSpeechRecognition({ + textareaRef, + onTranscriptionChange, + }); return ( diff --git a/packages/ui/src/components/ai-elements/utils/blob-to-data-url.ts b/packages/ui/src/components/ai-elements/utils/blob-to-data-url.ts new file mode 100644 index 000000000..84d8171d5 --- /dev/null +++ b/packages/ui/src/components/ai-elements/utils/blob-to-data-url.ts @@ -0,0 +1,21 @@ +/** + * Converts a blob URL to a data URL + * @param url - The blob URL to convert + * @returns The data URL, or null if conversion fails + */ +export async function convertBlobUrlToDataUrl( + url: string, +): Promise { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } +} diff --git a/packages/ui/src/components/ai-elements/utils/index.ts b/packages/ui/src/components/ai-elements/utils/index.ts new file mode 100644 index 000000000..68ba723f9 --- /dev/null +++ b/packages/ui/src/components/ai-elements/utils/index.ts @@ -0,0 +1 @@ +export { convertBlobUrlToDataUrl } from "./blob-to-data-url";