show last working directories

This commit is contained in:
Arjun 2026-06-05 00:04:21 +05:30 committed by arkml
parent 81cc4e10b7
commit 05a93c98ae

View file

@ -10,7 +10,10 @@ import {
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
FileVideo, FileVideo,
FolderCheck,
FolderClock,
FolderCog, FolderCog,
FolderOpen,
Globe, Globe,
Headphones, Headphones,
ImagePlus, ImagePlus,
@ -30,6 +33,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import {
@ -61,6 +67,12 @@ export type StagedAttachment = {
} }
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
const MAX_VISIBLE_RECENT_WORK_DIRS = 3
const MAX_STORED_RECENT_WORK_DIRS = 8
// Stored in the workspace (~/.rowboat/config) so it travels with the workspace and
// stays consistent with the other config/*.json files (e.g. coding-agents.json).
const RECENT_WORK_DIRS_CONFIG_PATH = 'config/recent-work-dirs.json'
const RECENT_WORK_DIRS_CHANGED_EVENT = 'rowboat-chat-recent-work-dirs-changed'
const providerDisplayNames: Record<string, string> = { const providerDisplayNames: Record<string, string> = {
@ -81,6 +93,11 @@ interface ConfiguredModel {
model: string model: string
} }
type RecentWorkDir = {
path: string
lastUsedAt: number
}
export interface SelectedModel { export interface SelectedModel {
provider: string provider: string
model: string model: string
@ -111,6 +128,84 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
} }
} }
function normalizeRecentWorkDir(value: unknown): RecentWorkDir | null {
if (typeof value === 'string') {
const path = value.trim()
return path ? { path, lastUsedAt: 0 } : null
}
if (!value || typeof value !== 'object') return null
const entry = value as Record<string, unknown>
const path = typeof entry.path === 'string' ? entry.path.trim() : ''
const lastUsedAt = typeof entry.lastUsedAt === 'number' && Number.isFinite(entry.lastUsedAt)
? entry.lastUsedAt
: 0
return path ? { path, lastUsedAt } : null
}
async function readRecentWorkDirs(): Promise<RecentWorkDir[]> {
try {
const result = await window.ipc.invoke('workspace:readFile', { path: RECENT_WORK_DIRS_CONFIG_PATH })
const parsed = JSON.parse(result.data)
if (!Array.isArray(parsed)) return []
const seen = new Set<string>()
const dirs: RecentWorkDir[] = []
for (const value of parsed) {
const entry = normalizeRecentWorkDir(value)
if (!entry || seen.has(entry.path)) continue
seen.add(entry.path)
dirs.push(entry)
if (dirs.length >= MAX_STORED_RECENT_WORK_DIRS) break
}
return dirs
} catch {
// File missing or invalid — no recents yet.
return []
}
}
async function writeRecentWorkDirs(dirs: RecentWorkDir[]) {
try {
await window.ipc.invoke('workspace:writeFile', {
path: RECENT_WORK_DIRS_CONFIG_PATH,
data: JSON.stringify(dirs.slice(0, MAX_STORED_RECENT_WORK_DIRS), null, 2),
})
} catch (err) {
console.error('Failed to persist recent work directories', err)
}
// Notify other mounted chat inputs in this window to re-read.
window.dispatchEvent(new CustomEvent(RECENT_WORK_DIRS_CHANGED_EVENT))
}
function formatRecentWorkDirTime(lastUsedAt: number) {
if (!lastUsedAt) return ''
const now = Date.now()
const diffMs = Math.max(0, now - lastUsedAt)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diffMs < minute) return 'now'
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))}m ago`
if (diffMs < day) return `${Math.floor(diffMs / hour)}h ago`
const used = new Date(lastUsedAt)
const yesterday = new Date(now - day)
if (
used.getFullYear() === yesterday.getFullYear() &&
used.getMonth() === yesterday.getMonth() &&
used.getDate() === yesterday.getDate()
) {
return 'Yesterday'
}
if (diffMs < 7 * day) {
return used.toLocaleDateString(undefined, { weekday: 'short' })
}
return used.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
function compactWorkDirPath(path: string) {
return path.replace(/^\/Users\/[^/]+/, '~')
}
interface ChatInputInnerProps { interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
onStop?: () => void onStop?: () => void
@ -186,6 +281,7 @@ function ChatInputInner({
const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto') const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
const [recentWorkDirs, setRecentWorkDirs] = useState<RecentWorkDir[]>([])
// When a run exists, freeze the dropdown to the run's resolved model+provider. // When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => { useEffect(() => {
@ -205,6 +301,15 @@ function ChatInputInner({
return () => { cancelled = true } return () => { cancelled = true }
}, [runId]) }, [runId])
useEffect(() => {
const syncRecentWorkDirs = () => { void readRecentWorkDirs().then(setRecentWorkDirs) }
syncRecentWorkDirs()
window.addEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
return () => {
window.removeEventListener(RECENT_WORK_DIRS_CHANGED_EVENT, syncRecentWorkDirs)
}
}, [])
// Check Rowboat sign-in state // Check Rowboat sign-in state
useEffect(() => { useEffect(() => {
window.ipc.invoke('oauth:getState', null).then((result) => { window.ipc.invoke('oauth:getState', null).then((result) => {
@ -311,6 +416,17 @@ function ChatInputInner({
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
}, []) }, [])
const rememberWorkDir = useCallback(async (dir: string) => {
const trimmed = dir.trim()
if (!trimmed) return
const next = [
{ path: trimmed, lastUsedAt: Date.now() },
...(await readRecentWorkDirs()).filter((item) => item.path !== trimmed),
].slice(0, MAX_STORED_RECENT_WORK_DIRS)
setRecentWorkDirs(next)
await writeRecentWorkDirs(next)
}, [])
// Load coding-agent preference for a given workdir. // Load coding-agent preference for a given workdir.
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' } // Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => { const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
@ -327,7 +443,7 @@ function ChatInputInner({
}, []) }, [])
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => { const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
let existing: Record<string, 'claude' | 'codex'> = {} const existing: Record<string, 'claude' | 'codex'> = {}
try { try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown> const parsed = JSON.parse(result.data) as Record<string, unknown>
@ -353,6 +469,10 @@ function ChatInputInner({
return () => { cancelled = true } return () => { cancelled = true }
}, [workDir, loadCodingAgentFor]) }, [workDir, loadCodingAgentFor])
useEffect(() => {
if (isActive && workDir) void rememberWorkDir(workDir)
}, [isActive, workDir, rememberWorkDir])
const handleSetWorkDir = useCallback(async () => { const handleSetWorkDir = useCallback(async () => {
try { try {
let defaultPath: string | undefined = workDir ?? undefined let defaultPath: string | undefined = workDir ?? undefined
@ -373,13 +493,21 @@ function ChatInputInner({
}) })
if (!chosen) return if (!chosen) return
onWorkDirChange?.(chosen) onWorkDirChange?.(chosen)
await rememberWorkDir(chosen)
setCodingAgent(await loadCodingAgentFor(chosen)) setCodingAgent(await loadCodingAgentFor(chosen))
toast.success(`Work directory set: ${chosen}`) toast.success(`Work directory set: ${chosen}`)
} catch (err) { } catch (err) {
console.error('Failed to set work directory', err) console.error('Failed to set work directory', err)
toast.error('Failed to set work directory') toast.error('Failed to set work directory')
} }
}, [workDir, onWorkDirChange, loadCodingAgentFor]) }, [workDir, onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
const handleSelectRecentWorkDir = useCallback(async (dir: string) => {
onWorkDirChange?.(dir)
await rememberWorkDir(dir)
setCodingAgent(await loadCodingAgentFor(dir))
toast.success(`Work directory set: ${dir}`)
}, [onWorkDirChange, rememberWorkDir, loadCodingAgentFor])
const handleClearWorkDir = useCallback(() => { const handleClearWorkDir = useCallback(() => {
onWorkDirChange?.(null) onWorkDirChange?.(null)
@ -533,6 +661,12 @@ function ChatInputInner({
} }
}, [addFiles, isActive]) }, [addFiles, isActive])
const visibleRecentWorkDirs = recentWorkDirs
.filter((entry) => entry.path !== workDir)
.slice(0, MAX_VISIBLE_RECENT_WORK_DIRS)
const currentWorkDirLabel = workDir ? basename(workDir) || workDir : 'Not set'
const currentWorkDirPath = workDir ? compactWorkDirPath(workDir) : ''
return ( return (
<div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none"> <div className="rowboat-chat-input rounded-lg border border-border bg-background shadow-none">
{attachments.length > 0 && ( {attachments.length > 0 && (
@ -651,17 +785,95 @@ function ChatInputInner({
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Add files or set work directory</TooltipContent> <TooltipContent side="top">
{workDir ? 'Add files or change work directory' : 'Add files or set work directory'}
</TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent align="start" className="min-w-56"> <DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-2">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}> <div className="rounded-[14px] border border-border/80 bg-background p-1">
<ImagePlus className="size-4" /> <DropdownMenuItem onSelect={() => fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5">
<span>Add files or photos</span> <ImagePlus className="size-4" />
</DropdownMenuItem> <span>Add files or photos</span>
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}> </DropdownMenuItem>
<FolderCog className="size-4" />
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span> {/* Working directory lives behind a submenu so the main menu stays to two
</DropdownMenuItem> items. One hover/click away for power users; out of the way otherwise. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="h-9 rounded-[9px] px-2.5">
<FolderCog className="size-4" />
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>Set working directory</span>
<span className="min-w-0 max-w-[110px] truncate text-xs text-muted-foreground">
{currentWorkDirLabel}
</span>
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-72 max-w-[calc(100vw-2rem)] p-1">
{/* Current selection — shown for context only when one is set. */}
{workDir && (
<div
title={workDir}
className="mb-1 flex items-center gap-2 rounded-[9px] bg-blue-50/80 px-2.5 py-2 text-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
>
<FolderCheck className="size-4 shrink-0 text-blue-600 dark:text-blue-300" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium">{currentWorkDirLabel}</span>
<span className="truncate text-xs text-blue-700/70 dark:text-blue-300/70">
{currentWorkDirPath}
</span>
</span>
</div>
)}
{/* Primary action: choose when unset, change when set. Always on top. */}
<DropdownMenuItem
onSelect={() => { void handleSetWorkDir() }}
className="h-9 rounded-[9px] px-2.5"
>
<FolderOpen className="size-4" />
<span>{workDir ? 'Change folder…' : 'Choose a folder…'}</span>
</DropdownMenuItem>
{visibleRecentWorkDirs.length > 0 && (
<>
<div className="px-2.5 pb-1 pt-2 text-[10.5px] font-semibold uppercase tracking-wider text-muted-foreground">
Recent
</div>
{visibleRecentWorkDirs.map((entry) => {
const name = basename(entry.path) || entry.path
const when = formatRecentWorkDirTime(entry.lastUsedAt)
return (
<DropdownMenuItem
key={entry.path}
title={entry.path}
onSelect={() => { void handleSelectRecentWorkDir(entry.path) }}
className="h-8 rounded-[9px] px-2.5"
>
<FolderClock className="size-4" />
<span className="min-w-0 flex-1 truncate">{name}</span>
{when && <span className="shrink-0 text-xs text-muted-foreground">{when}</span>}
</DropdownMenuItem>
)
})}
</>
)}
{/* Clear — only meaningful once a directory is set. Kept at the bottom. */}
{workDir && (
<>
<div className="my-1 h-px bg-border/60" />
<DropdownMenuItem
onSelect={handleClearWorkDir}
className="h-8 rounded-[9px] px-2.5 text-red-600 focus:bg-red-50 focus:text-red-600 dark:text-red-400 dark:focus:bg-red-950/30"
>
<X className="size-4" />
<span>Clear folder</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{workDir && ( {workDir && (