From 05a93c98ae7c3352ccf3222a38eb2cc3bb5d1cc2 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:04:21 +0530 Subject: [PATCH] show last working directories --- .../components/chat-input-with-mentions.tsx | 236 +++++++++++++++++- 1 file changed, 224 insertions(+), 12 deletions(-) diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 8c62054c..624b0e7c 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -10,7 +10,10 @@ import { FileSpreadsheet, FileText, FileVideo, + FolderCheck, + FolderClock, FolderCog, + FolderOpen, Globe, Headphones, ImagePlus, @@ -30,6 +33,9 @@ import { DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { @@ -61,6 +67,12 @@ export type StagedAttachment = { } 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 = { @@ -81,6 +93,11 @@ interface ConfiguredModel { model: string } +type RecentWorkDir = { + path: string + lastUsedAt: number +} + export interface SelectedModel { provider: 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 + 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 { + 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() + 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 { onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void onStop?: () => void @@ -186,6 +281,7 @@ function ChatInputInner({ const [codeModeEnabled, setCodeModeEnabled] = useState(false) const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false) const [permissionMode, setPermissionMode] = useState('auto') + const [recentWorkDirs, setRecentWorkDirs] = useState([]) // When a run exists, freeze the dropdown to the run's resolved model+provider. useEffect(() => { @@ -205,6 +301,15 @@ function ChatInputInner({ return () => { cancelled = true } }, [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 useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -311,6 +416,17 @@ function ChatInputInner({ 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. // Storage: config/coding-agents.json — { [workDirPath]: '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') => { - let existing: Record = {} + const existing: Record = {} try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' }) const parsed = JSON.parse(result.data) as Record @@ -353,6 +469,10 @@ function ChatInputInner({ return () => { cancelled = true } }, [workDir, loadCodingAgentFor]) + useEffect(() => { + if (isActive && workDir) void rememberWorkDir(workDir) + }, [isActive, workDir, rememberWorkDir]) + const handleSetWorkDir = useCallback(async () => { try { let defaultPath: string | undefined = workDir ?? undefined @@ -373,13 +493,21 @@ function ChatInputInner({ }) if (!chosen) return onWorkDirChange?.(chosen) + await rememberWorkDir(chosen) setCodingAgent(await loadCodingAgentFor(chosen)) toast.success(`Work directory set: ${chosen}`) } catch (err) { console.error('Failed to set work directory', err) 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(() => { onWorkDirChange?.(null) @@ -533,6 +661,12 @@ function ChatInputInner({ } }, [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 (
{attachments.length > 0 && ( @@ -651,17 +785,95 @@ function ChatInputInner({ - Add files or set work directory + + {workDir ? 'Add files or change work directory' : 'Add files or set work directory'} + - - fileInputRef.current?.click()}> - - Add files or photos - - { void handleSetWorkDir() }}> - - {workDir ? 'Change work directory' : 'Set work directory'} - + +
+ fileInputRef.current?.click()} className="h-9 rounded-[9px] px-2.5"> + + Add files or photos + + + {/* Working directory lives behind a submenu so the main menu stays to two + items. One hover/click away for power users; out of the way otherwise. */} + + + + + Set working directory + + {currentWorkDirLabel} + + + + + {/* Current selection — shown for context only when one is set. */} + {workDir && ( +
+ + + {currentWorkDirLabel} + + {currentWorkDirPath} + + +
+ )} + + {/* Primary action: choose when unset, change when set. Always on top. */} + { void handleSetWorkDir() }} + className="h-9 rounded-[9px] px-2.5" + > + + {workDir ? 'Change folder…' : 'Choose a folder…'} + + + {visibleRecentWorkDirs.length > 0 && ( + <> +
+ Recent +
+ {visibleRecentWorkDirs.map((entry) => { + const name = basename(entry.path) || entry.path + const when = formatRecentWorkDirTime(entry.lastUsedAt) + return ( + { void handleSelectRecentWorkDir(entry.path) }} + className="h-8 rounded-[9px] px-2.5" + > + + {name} + {when && {when}} + + ) + })} + + )} + + {/* Clear — only meaningful once a directory is set. Kept at the bottom. */} + {workDir && ( + <> +
+ + + Clear folder + + + )} + + +
{workDir && (