diff --git a/apps/agent/components/elements/workspace-selector.tsx b/apps/agent/components/elements/workspace-selector.tsx new file mode 100644 index 00000000..232efa72 --- /dev/null +++ b/apps/agent/components/elements/workspace-selector.tsx @@ -0,0 +1,188 @@ +import { Check, ChevronDown, Folder, FolderOpen, Home, X } from 'lucide-react' +import type { FC, PropsWithChildren } from 'react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { getBrowserOSAdapter } from '@/lib/browseros/adapter' +import { cn } from '@/lib/utils' +import { useWorkspace } from '@/lib/workspace/use-workspace' +import type { WorkspaceFolder } from '@/lib/workspace/workspace-storage' + +interface WorkspaceSelectorProps { + side?: 'top' | 'bottom' | 'left' | 'right' +} + +export const WorkspaceSelector: FC< + PropsWithChildren +> = ({ children, side = 'top' }) => { + const [open, setOpen] = useState(false) + const { + recentFolders, + selectedFolder, + selectFolder, + addFolder, + removeFolder, + clearSelection, + } = useWorkspace() + + const handleChooseFolder = async () => { + try { + const adapter = getBrowserOSAdapter() + const result = await adapter.choosePath({ type: 'folder' }) + + if (!result) { + return + } + + const folder: WorkspaceFolder = { + id: crypto.randomUUID(), + name: result.name, + path: result.path, + addedAt: Date.now(), + } + + await addFolder(folder) + setOpen(false) + } catch { + // User cancelled or API not available - silently ignore + } + } + + const handleSelectFolder = async (folder: WorkspaceFolder) => { + if (selectedFolder?.id === folder.id) { + await clearSelection() + } else { + await selectFolder(folder) + } + setOpen(false) + } + + const handleRemoveFolder = async (e: React.MouseEvent, folderId: string) => { + e.stopPropagation() + await removeFolder(folderId) + } + + const handleUseDefault = async () => { + await clearSelection() + setOpen(false) + } + + return ( + + + {children || ( + + )} + + + +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleUseDefault() + } + }} + role="option" + aria-selected={!selectedFolder} + tabIndex={0} + className={cn( + 'flex cursor-pointer items-center gap-3 px-3 py-2.5 transition-colors hover:bg-muted', + !selectedFolder && 'bg-muted', + )} + > + + Use default + {!selectedFolder && ( + + )} +
+ + {recentFolders.length > 0 && ( + <> +
+
+ Recent +
+
+ {recentFolders.map((folder) => ( +
handleSelectFolder(folder)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleSelectFolder(folder) + } + }} + role="option" + aria-selected={selectedFolder?.id === folder.id} + tabIndex={0} + className={cn( + 'group flex cursor-pointer items-start gap-3 px-3 py-2 transition-colors hover:bg-muted', + selectedFolder?.id === folder.id && 'bg-muted', + )} + > + +
+
+ {folder.name} +
+
+ {folder.path} +
+
+
+ {selectedFolder?.id === folder.id && ( + + )} + +
+
+ ))} +
+
+ + )} + + + + + ) +} diff --git a/apps/agent/entrypoints/newtab/index/NewTab.tsx b/apps/agent/entrypoints/newtab/index/NewTab.tsx index 967a638d..45d2b3ee 100644 --- a/apps/agent/entrypoints/newtab/index/NewTab.tsx +++ b/apps/agent/entrypoints/newtab/index/NewTab.tsx @@ -1,22 +1,24 @@ import { useCombobox } from 'downshift' import { ArrowRight, - File, + ChevronDown, + Folder, Globe, - ImageIcon, Layers, Search, X, } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' -import type React from 'react' import { useEffect, useRef, useState } from 'react' import { GlowingBorder, GlowingElement, } from '@/components/elements/glowing-border' import { TabSelector } from '@/components/elements/tab-selector' +import { WorkspaceSelector } from '@/components/elements/workspace-selector' import { Button } from '@/components/ui/button' +import { Feature } from '@/lib/browseros/capabilities' +import { useCapabilities } from '@/lib/browseros/useCapabilities' import { createAITabAction, createBrowserOSAction, @@ -28,6 +30,7 @@ import { import { openSidePanelWithSearch } from '@/lib/messaging/sidepanel/openSidepanelWithSearch' import { track } from '@/lib/metrics/track' import { cn } from '@/lib/utils' +import { useWorkspace } from '@/lib/workspace/use-workspace' import { FooterLinks } from './FooterLinks' import type { SuggestionItem } from './lib/suggestions/types' import { @@ -40,25 +43,18 @@ import { SearchSuggestions } from './SearchSuggestions' import { ShortcutsDialog } from './ShortcutsDialog' import { TopSites } from './TopSites' -interface SelectedFile { - name: string - size: number - type: string - preview?: string -} - /** * @public */ export const NewTab = () => { const [inputValue, setInputValue] = useState('') const [mounted, setMounted] = useState(false) - const [selectedFiles, setSelectedFiles] = useState([]) const inputRef = useRef(null) - // const fileInputRef = useRef(null) const tabsDropdownRef = useRef(null) const [selectedTabs, setSelectedTabs] = useState([]) const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false) + const { selectedFolder } = useWorkspace() + const { supports } = useCapabilities() const toggleTab = (tab: chrome.tabs.Tab) => { setSelectedTabs((prev) => { @@ -147,6 +143,7 @@ export const NewTab = () => { query: searchQuery, mode: 'agent', action, + workingDir: selectedFolder?.path, }) break } @@ -164,6 +161,7 @@ export const NewTab = () => { query: item.message, mode: item.mode, action, + workingDir: selectedFolder?.path, }) break } @@ -184,33 +182,6 @@ export const NewTab = () => { setMounted(true) }, []) - const _handleFileSelect = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []).slice(0, 2) // Limit to 2 files - const fileData = files.map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - preview: file.type.startsWith('image/') - ? URL.createObjectURL(file) - : undefined, - })) - setSelectedFiles(fileData) - } - - useEffect(() => { - return () => { - selectedFiles.forEach((file) => { - if (file.preview) { - URL.revokeObjectURL(file.preview) - } - }) - } - }, [selectedFiles]) - - const removeFile = (index: number) => { - setSelectedFiles((prev) => prev.filter((_, i) => i !== index)) - } - return (
{/* Main content */} @@ -221,9 +192,7 @@ export const NewTab = () => {
0 || - selectedFiles.length > 0 + isSuggestionsVisible || selectedTabs.length > 0 ? 'bg-[var(--accent-orange)]/30 shadow-[var(--accent-orange)]/10' : 'bg-border/50 hover:border-border', )} @@ -242,9 +211,7 @@ export const NewTab = () => {
0 || - selectedFiles.length > 0 + isSuggestionsVisible || selectedTabs.length > 0 ? 'border-[var(--accent-orange)]/30 shadow-[var(--accent-orange)]/10' : 'border-border/50 hover:border-border', )} @@ -272,7 +239,7 @@ export const NewTab = () => {
- {(selectedTabs.length > 0 || selectedFiles.length > 0) && ( + {selectedTabs.length > 0 && ( { ) })} - - {selectedFiles.map((file, index) => ( -
-
-
- {file.preview ? ( - {file.name} - ) : file.type.startsWith('image/') ? ( - - ) : ( - - )} -
-
-
- {file.name} -
-
- {(file.size / 1024).toFixed(1)} KB -
-
- -
-
- ))}
@@ -382,6 +311,25 @@ export const NewTab = () => { {mounted && (
+ {supports(Feature.WORKSPACE_FOLDER_SUPPORT) && ( + + + + )} +
{
- - {/**/}
)} diff --git a/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/apps/agent/entrypoints/sidepanel/index/useChatSession.ts index e16aaa4c..377c537e 100644 --- a/apps/agent/entrypoints/sidepanel/index/useChatSession.ts +++ b/apps/agent/entrypoints/sidepanel/index/useChatSession.ts @@ -138,6 +138,7 @@ export const useChatSession = () => { const modeRef = useRef(mode) const textToActionRef = useRef>(textToAction) + const workingDirRef = useRef(undefined) useDeepCompareEffect(() => { modeRef.current = mode @@ -253,6 +254,7 @@ export const useChatSession = () => { sessionToken: provider?.sessionToken, browserContext, userSystemPrompt: personalizationRef.current, + userWorkingDir: workingDirRef.current, supportsImages: provider?.supportsImages, }, } @@ -317,6 +319,7 @@ export const useChatSession = () => { const unwatch = searchActionsStorage.watch((storageAction) => { if (storageAction) { setMode(storageAction.mode) + workingDirRef.current = storageAction.workingDir sendMessage({ text: storageAction.query, action: storageAction.action }) } }) diff --git a/apps/agent/lib/browseros/adapter.ts b/apps/agent/lib/browseros/adapter.ts index 0ef9eab8..37fea760 100644 --- a/apps/agent/lib/browseros/adapter.ts +++ b/apps/agent/lib/browseros/adapter.ts @@ -15,6 +15,9 @@ export type Snapshot = chrome.browserOS.Snapshot export type SnapshotOptions = chrome.browserOS.SnapshotOptions export type SnapshotContext = chrome.browserOS.SnapshotContext export type PrefObject = chrome.browserOS.PrefObject +export type SelectionType = chrome.browserOS.SelectionType +export type ChoosePathOptions = chrome.browserOS.ChoosePathOptions +export type SelectedPath = chrome.browserOS.SelectedPath export const SCREENSHOT_SIZES = { small: 512, @@ -376,6 +379,28 @@ export class BrowserOSAdapter { }) } + async choosePath(options?: ChoosePathOptions): Promise { + if (typeof chrome.browserOS?.choosePath !== 'function') { + throw new Error('choosePath API not available') + } + + return new Promise((resolve, reject) => { + const callback = (result: SelectedPath | null) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message || 'Unknown error')) + } else { + resolve(result) + } + } + + if (options) { + chrome.browserOS.choosePath(options, callback) + } else { + chrome.browserOS.choosePath(callback) + } + }) + } + isAPIAvailable(method: string): boolean { return method in chrome.browserOS } diff --git a/apps/agent/lib/browseros/capabilities.ts b/apps/agent/lib/browseros/capabilities.ts index 3e41c086..a9c6c8b2 100644 --- a/apps/agent/lib/browseros/capabilities.ts +++ b/apps/agent/lib/browseros/capabilities.ts @@ -27,6 +27,8 @@ export enum Feature { UNIFIED_PORT_SUPPORT = 'UNIFIED_PORT_SUPPORT', // Toolbar customization settings CUSTOMIZATION_SUPPORT = 'CUSTOMIZATION_SUPPORT', + // Workspace folder selection with full path support requires new browserOS.choosePath API + WORKSPACE_FOLDER_SUPPORT = 'WORKSPACE_FOLDER_SUPPORT', } /** @@ -45,6 +47,7 @@ const FEATURE_CONFIG: { [K in Feature]: FeatureConfig } = { [Feature.PERSONALIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' }, [Feature.UNIFIED_PORT_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' }, [Feature.CUSTOMIZATION_SUPPORT]: { minBrowserOSVersion: '0.36.1.0' }, + [Feature.WORKSPACE_FOLDER_SUPPORT]: { minBrowserOSVersion: '0.36.4.0' }, } function parseVersion(version: string): number[] { diff --git a/apps/agent/lib/browseros/chrome-browser-os.d.ts b/apps/agent/lib/browseros/chrome-browser-os.d.ts index e573b09e..a3b669ea 100644 --- a/apps/agent/lib/browseros/chrome-browser-os.d.ts +++ b/apps/agent/lib/browseros/chrome-browser-os.d.ts @@ -253,4 +253,24 @@ declare namespace chrome.browserOS { ): void function getAllPrefs(callback: (prefs: PrefObject[]) => void): void + + // choosePath types + type SelectionType = 'file' | 'folder' + + interface ChoosePathOptions { + type?: SelectionType + title?: string + startingDirectory?: string + } + + interface SelectedPath { + path: string + name: string + } + + function choosePath( + options: ChoosePathOptions, + callback: (result: SelectedPath | null) => void, + ): void + function choosePath(callback: (result: SelectedPath | null) => void): void } diff --git a/apps/agent/lib/search-actions/searchActionsStorage.ts b/apps/agent/lib/search-actions/searchActionsStorage.ts index ef7ce2b1..93378e2d 100644 --- a/apps/agent/lib/search-actions/searchActionsStorage.ts +++ b/apps/agent/lib/search-actions/searchActionsStorage.ts @@ -8,6 +8,7 @@ export interface SearchActionStorage { query: string mode: 'chat' | 'agent' action?: ChatAction + workingDir?: string } /** diff --git a/apps/agent/lib/workspace/use-workspace.ts b/apps/agent/lib/workspace/use-workspace.ts new file mode 100644 index 00000000..bc1e8503 --- /dev/null +++ b/apps/agent/lib/workspace/use-workspace.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { + selectedWorkspaceStorage, + type WorkspaceFolder, + workspaceFoldersStorage, +} from './workspace-storage' + +const MAX_RECENT_FOLDERS = 10 + +/** + * @public + */ +export function useWorkspace() { + const [recentFolders, setRecentFolders] = useState([]) + const [selectedFolder, setSelectedFolder] = useState( + null, + ) + + useEffect(() => { + workspaceFoldersStorage.getValue().then(setRecentFolders) + selectedWorkspaceStorage.getValue().then(setSelectedFolder) + + const unwatchRecent = workspaceFoldersStorage.watch((value) => { + setRecentFolders(value ?? []) + }) + const unwatchSelected = selectedWorkspaceStorage.watch((value) => { + setSelectedFolder(value) + }) + + return () => { + unwatchRecent() + unwatchSelected() + } + }, []) + + const selectFolder = async (folder: WorkspaceFolder) => { + await selectedWorkspaceStorage.setValue(folder) + + const current = (await workspaceFoldersStorage.getValue()) ?? [] + const filtered = current.filter((f) => f.path !== folder.path) + const updated = [{ ...folder, addedAt: Date.now() }, ...filtered].slice( + 0, + MAX_RECENT_FOLDERS, + ) + await workspaceFoldersStorage.setValue(updated) + } + + const addFolder = async (folder: WorkspaceFolder) => { + await selectFolder(folder) + } + + const removeFolder = async (id: string) => { + const current = (await workspaceFoldersStorage.getValue()) ?? [] + await workspaceFoldersStorage.setValue(current.filter((f) => f.id !== id)) + + const selected = await selectedWorkspaceStorage.getValue() + if (selected?.id === id) { + await selectedWorkspaceStorage.setValue(null) + } + } + + const clearSelection = async () => { + await selectedWorkspaceStorage.setValue(null) + } + + return { + recentFolders, + selectedFolder, + selectFolder, + addFolder, + removeFolder, + clearSelection, + } +} diff --git a/apps/agent/lib/workspace/workspace-storage.ts b/apps/agent/lib/workspace/workspace-storage.ts new file mode 100644 index 00000000..d0f72fc6 --- /dev/null +++ b/apps/agent/lib/workspace/workspace-storage.ts @@ -0,0 +1,21 @@ +import { storage } from '@wxt-dev/storage' + +/** + * @public + */ +export interface WorkspaceFolder { + id: string + name: string + path: string + addedAt: number +} + +export const workspaceFoldersStorage = storage.defineItem( + 'local:workspaceFolders', + { fallback: [] }, +) + +export const selectedWorkspaceStorage = + storage.defineItem('local:selectedWorkspace', { + fallback: null, + })