feat: workspace folder selection (#238)

* feat: v1 ui for the file selector

* feat: integrate with browseros.choosePath API

* feat: gate workspace folder for 0.36.0.4 as requires new browserOS.choosePath API

* fix: add default folder option

* fix: clean-up old code
This commit is contained in:
Nikhil
2026-01-15 15:06:13 -08:00
committed by GitHub
parent f977257e3e
commit 5cfd0a7511
9 changed files with 367 additions and 97 deletions

View File

@@ -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<WorkspaceSelectorProps>
> = ({ 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{children || (
<Button
variant="ghost"
size="sm"
className={cn(
'flex items-center gap-1.5 text-muted-foreground hover:text-foreground',
selectedFolder && 'text-foreground',
)}
>
<Folder className="h-4 w-4" />
<span>
{selectedFolder ? selectedFolder.name : 'Work in a folder'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
)}
</PopoverTrigger>
<PopoverContent
side={side}
align="start"
className="w-80 p-0"
role="dialog"
aria-label="Select workspace folder"
>
<div
onClick={handleUseDefault}
onKeyDown={(e) => {
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',
)}
>
<Home className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm">Use default</span>
{!selectedFolder && (
<Check className="h-4 w-4 text-[var(--accent-orange)]" />
)}
</div>
{recentFolders.length > 0 && (
<>
<div className="border-t" />
<div className="px-3 py-2 font-medium text-muted-foreground text-xs uppercase tracking-wide">
Recent
</div>
<div className="max-h-64 overflow-y-auto">
{recentFolders.map((folder) => (
<div
key={folder.id}
onClick={() => 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 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{folder.name}
</div>
<div className="truncate text-muted-foreground text-xs">
{folder.path}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{selectedFolder?.id === folder.id && (
<Check className="h-4 w-4 text-[var(--accent-orange)]" />
)}
<button
type="button"
onClick={(e) => handleRemoveFolder(e, folder.id)}
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-muted-foreground/20 group-hover:opacity-100"
aria-label={`Remove ${folder.name} from recents`}
>
<X className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
<div className="border-t" />
</>
)}
<button
type="button"
onClick={handleChooseFolder}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted"
>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">Choose a different folder</span>
</button>
</PopoverContent>
</Popover>
)
}

View File

@@ -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<SelectedFile[]>([])
const inputRef = useRef<HTMLInputElement>(null)
// const fileInputRef = useRef<HTMLInputElement>(null)
const tabsDropdownRef = useRef<HTMLDivElement>(null)
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
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<HTMLInputElement>) => {
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 (
<div className="pt-[max(25vh,16px)]">
{/* Main content */}
@@ -221,9 +192,7 @@ export const NewTab = () => {
<div
className={cn(
'relative overflow-hidden bg-border/50 p-[2px]',
isSuggestionsVisible ||
selectedTabs.length > 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 = () => {
<div
className={cn(
'relative bg-card shadow-lg',
isSuggestionsVisible ||
selectedTabs.length > 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 = () => {
</div>
<AnimatePresence>
{(selectedTabs.length > 0 || selectedFiles.length > 0) && (
{selectedTabs.length > 0 && (
<motion.div
className="overflow-clip px-5 pb-4"
transition={{ duration: 0.2 }}
@@ -324,44 +291,6 @@ export const NewTab = () => {
</motion.div>
)
})}
{selectedFiles.map((file, index) => (
<div
key={index.toString()}
className="group w-48 flex-shrink-0 rounded-lg border border-border bg-accent/50 p-3 transition-colors hover:bg-accent"
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
{file.preview ? (
<img
src={file.preview || '/placeholder.svg'}
alt={file.name}
className="h-full w-full object-cover"
/>
) : file.type.startsWith('image/') ? (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
) : (
<File className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 truncate font-medium text-foreground text-sm">
{file.name}
</div>
<div className="text-muted-foreground text-xs">
{(file.size / 1024).toFixed(1)} KB
</div>
</div>
<button
type="button"
onClick={() => removeFile(index)}
className="cursor-pointer rounded p-1 opacity-0 transition-opacity hover:bg-background group-hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
))}
</AnimatePresence>
</div>
</motion.div>
@@ -382,6 +311,25 @@ export const NewTab = () => {
{mounted && (
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
<div className="flex items-center gap-1">
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) && (
<WorkspaceSelector>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'data-[state=open]:bg-accent',
)}
>
<Folder className="h-4 w-4" />
<span>
{selectedFolder?.name || 'Work in a folder'}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</WorkspaceSelector>
)}
<div className="relative" ref={tabsDropdownRef}>
<TabSelector
selectedTabs={selectedTabs}
@@ -401,19 +349,6 @@ export const NewTab = () => {
</Button>
</TabSelector>
</div>
{/*<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all ${
selectedFiles.length > 0
? 'bg-[var(--accent-orange)] text-white shadow-sm'
: 'bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Upload className="h-4 w-4" />
<span>Files</span>
</button>*/}
</div>
</div>
)}

View File

@@ -138,6 +138,7 @@ export const useChatSession = () => {
const modeRef = useRef<ChatMode>(mode)
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
const workingDirRef = useRef<string | undefined>(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 })
}
})

View File

@@ -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<SelectedPath | null> {
if (typeof chrome.browserOS?.choosePath !== 'function') {
throw new Error('choosePath API not available')
}
return new Promise<SelectedPath | null>((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
}

View File

@@ -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[] {

View File

@@ -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
}

View File

@@ -8,6 +8,7 @@ export interface SearchActionStorage {
query: string
mode: 'chat' | 'agent'
action?: ChatAction
workingDir?: string
}
/**

View File

@@ -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<WorkspaceFolder[]>([])
const [selectedFolder, setSelectedFolder] = useState<WorkspaceFolder | null>(
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,
}
}

View File

@@ -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<WorkspaceFolder[]>(
'local:workspaceFolders',
{ fallback: [] },
)
export const selectedWorkspaceStorage =
storage.defineItem<WorkspaceFolder | null>('local:selectedWorkspace', {
fallback: null,
})