mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
show last working directories
This commit is contained in:
parent
81cc4e10b7
commit
05a93c98ae
1 changed files with 224 additions and 12 deletions
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue