mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
fix: scope chat work directory per-run instead of globally (#578)
* fix: scope chat work directory per-run instead of globally * fix: refine work directory pill and search button controls * feat: animate web search button toggle
This commit is contained in:
parent
45bdbfcbbc
commit
d981fa9206
4 changed files with 133 additions and 79 deletions
|
|
@ -1034,6 +1034,10 @@ function App() {
|
|||
const chatViewStateByTabRef = useRef(chatViewStateByTab)
|
||||
const chatDraftsRef = useRef(new Map<string, string>())
|
||||
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
|
||||
// Work directory is per-chat. Keyed by tab id; null/absent means none set.
|
||||
const [workDirByTab, setWorkDirByTab] = useState<Record<string, string | null>>({})
|
||||
const workDirByTabRef = useRef(workDirByTab)
|
||||
workDirByTabRef.current = workDirByTab
|
||||
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
||||
|
|
@ -1046,6 +1050,36 @@ function App() {
|
|||
chatDraftsRef.current.delete(tabId)
|
||||
}
|
||||
}, [])
|
||||
// Persist a run's work directory to its per-run sidecar config file. The agent
|
||||
// runtime reads this same file (config/workdir-<runId>.json) on each turn.
|
||||
const persistRunWorkDir = useCallback(async (runId: string, value: string | null) => {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: `config/workdir-${runId}.json`,
|
||||
data: JSON.stringify(value ? { path: value } : {}, null, 2),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to persist work directory for run', runId, err)
|
||||
}
|
||||
}, [])
|
||||
// Read a run's persisted work directory (used when (re)opening a run into a tab).
|
||||
const loadRunWorkDir = useCallback(async (runId: string): Promise<string | null> => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: `config/workdir-${runId}.json` })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
|
||||
return value || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
const setTabWorkDir = useCallback((tabId: string, value: string | null) => {
|
||||
setWorkDirByTab((prev) => ({ ...prev, [tabId]: value }))
|
||||
// If the tab is already bound to a run, persist immediately so the change
|
||||
// applies to that chat's subsequent messages.
|
||||
const runId = chatTabsRef.current.find((t) => t.id === tabId)?.runId
|
||||
if (runId) void persistRunWorkDir(runId, value)
|
||||
}, [persistRunWorkDir])
|
||||
const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
|
||||
return toolOpenByTab[tabId]?.[toolId] ?? false
|
||||
}, [toolOpenByTab])
|
||||
|
|
@ -2023,10 +2057,16 @@ function App() {
|
|||
setPendingAskHumanRequests(pendingAsks)
|
||||
setAllPermissionRequests(allPermissionRequests)
|
||||
setPermissionResponses(permResponseMap)
|
||||
|
||||
// Restore the run's per-chat work directory into the tab it was loaded into.
|
||||
const tabId = activeChatTabIdRef.current
|
||||
const wd = await loadRunWorkDir(id)
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
setWorkDirByTab((prev) => ({ ...prev, [tabId]: wd }))
|
||||
} catch (err) {
|
||||
console.error('Failed to load run:', err)
|
||||
}
|
||||
}, [])
|
||||
}, [loadRunWorkDir])
|
||||
|
||||
const getStreamingBuffer = useCallback((id: string) => {
|
||||
const existing = streamingBuffersRef.current.get(id)
|
||||
|
|
@ -2478,6 +2518,10 @@ function App() {
|
|||
? { ...tab, runId: currentRunId }
|
||||
: tab
|
||||
)))
|
||||
// Flush this tab's pending work directory onto the freshly created run so
|
||||
// the agent picks it up on the first turn. Done before createMessage below.
|
||||
const pendingWorkDir = workDirByTabRef.current[submitTabId] ?? null
|
||||
if (pendingWorkDir) await persistRunWorkDir(currentRunId, pendingWorkDir)
|
||||
isNewRun = true
|
||||
}
|
||||
|
||||
|
|
@ -2671,6 +2715,8 @@ function App() {
|
|||
...prev,
|
||||
[activeChatTabIdRef.current]: createEmptyChatTabViewState(),
|
||||
}))
|
||||
// A brand-new chat starts with no work directory.
|
||||
setWorkDirByTab(prev => ({ ...prev, [activeChatTabIdRef.current]: null }))
|
||||
}, [setChatViewportAnchor])
|
||||
|
||||
// Chat tab operations
|
||||
|
|
@ -2758,6 +2804,12 @@ function App() {
|
|||
chatDraftsRef.current.delete(tabId)
|
||||
selectedModelByTabRef.current.delete(tabId)
|
||||
chatScrollTopByTabRef.current.delete(tabId)
|
||||
setWorkDirByTab((prev) => {
|
||||
if (!(tabId in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[tabId]
|
||||
return next
|
||||
})
|
||||
setToolOpenByTab((prev) => {
|
||||
if (!(tabId in prev)) return prev
|
||||
const next = { ...prev }
|
||||
|
|
@ -5800,6 +5852,8 @@ function App() {
|
|||
selectedModelByTabRef.current.delete(tab.id)
|
||||
}
|
||||
}}
|
||||
workDir={workDirByTab[tab.id] ?? null}
|
||||
onWorkDirChange={(v) => setTabWorkDir(tab.id, v)}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? voice.interimText : undefined}
|
||||
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
||||
|
|
@ -5869,6 +5923,8 @@ function App() {
|
|||
selectedModelByTabRef.current.delete(tabId)
|
||||
}
|
||||
}}
|
||||
workDirByTab={workDirByTab}
|
||||
onWorkDirChangeForTab={setTabWorkDir}
|
||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||
allPermissionRequests={allPermissionRequests}
|
||||
permissionResponses={permissionResponses}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
|
|
@ -133,6 +132,10 @@ interface ChatInputInnerProps {
|
|||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
/** Work directory for this chat (per-chat). Null when none is set. */
|
||||
workDir?: string | null
|
||||
/** Fired when the user sets/changes/clears the work directory for this chat. */
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
|
|
@ -159,6 +162,8 @@ function ChatInputInner({
|
|||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
workDir = null,
|
||||
onWorkDirChange,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -173,7 +178,6 @@ function ChatInputInner({
|
|||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
const [workDir, setWorkDir] = useState<string | null>(null)
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
|
|
@ -256,22 +260,8 @@ function ChatInputInner({
|
|||
return () => window.removeEventListener('models-config-changed', handler)
|
||||
}, [loadModelConfig])
|
||||
|
||||
// Load currently configured work directory
|
||||
const loadWorkDir = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
|
||||
setWorkDir(value || null)
|
||||
} catch {
|
||||
setWorkDir(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkDir()
|
||||
}, [isActive, loadWorkDir])
|
||||
|
||||
// Work directory is owned per-chat by the parent (App). This component only
|
||||
// drives the picker dialog and reports changes up via onWorkDirChange.
|
||||
const handleSetWorkDir = useCallback(async () => {
|
||||
try {
|
||||
let defaultPath: string | undefined = workDir ?? undefined
|
||||
|
|
@ -291,31 +281,18 @@ function ChatInputInner({
|
|||
defaultPath,
|
||||
})
|
||||
if (!chosen) return
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: 'config/workdir.json',
|
||||
data: JSON.stringify({ path: chosen }, null, 2),
|
||||
})
|
||||
setWorkDir(chosen)
|
||||
onWorkDirChange?.(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])
|
||||
}, [workDir, onWorkDirChange])
|
||||
|
||||
const handleClearWorkDir = useCallback(async () => {
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: 'config/workdir.json',
|
||||
data: JSON.stringify({}, null, 2),
|
||||
})
|
||||
setWorkDir(null)
|
||||
toast.success('Work directory cleared')
|
||||
} catch (err) {
|
||||
console.error('Failed to clear work directory', err)
|
||||
toast.error('Failed to clear work directory')
|
||||
}
|
||||
}, [])
|
||||
const handleClearWorkDir = useCallback(() => {
|
||||
onWorkDirChange?.(null)
|
||||
toast.success('Work directory cleared')
|
||||
}, [onWorkDirChange])
|
||||
|
||||
// Check search tool availability (exa or signed-in via gateway)
|
||||
useEffect(() => {
|
||||
|
|
@ -569,28 +546,29 @@ function ChatInputInner({
|
|||
<FolderCog className="size-4" />
|
||||
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
||||
</DropdownMenuItem>
|
||||
{workDir && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}>
|
||||
<X className="size-4" />
|
||||
<span>Clear work directory</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{workDir && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex h-7 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
||||
</button>
|
||||
<div className="group flex h-7 max-w-[180px] shrink-0 items-center rounded-full border border-border bg-muted/40 pl-2.5 pr-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetWorkDir}
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
>
|
||||
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWorkDir}
|
||||
aria-label="Remove work directory"
|
||||
className="flex h-3.5 w-0 shrink-0 items-center justify-center overflow-hidden opacity-0 transition-all duration-150 ease-out hover:text-red-500 group-hover:ml-1 group-hover:w-3.5 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Work directory: {workDir}
|
||||
|
|
@ -598,26 +576,28 @@ function ChatInputInner({
|
|||
</Tooltip>
|
||||
)}
|
||||
{searchAvailable && (
|
||||
searchEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled(false)}
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 text-blue-600 transition-colors hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled((v) => !v)}
|
||||
aria-label="Search"
|
||||
aria-pressed={searchEnabled}
|
||||
className={cn(
|
||||
'flex h-7 shrink-0 items-center rounded-full border px-1.5 transition-colors duration-150 ease-out',
|
||||
searchEnabled
|
||||
? 'border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-400 dark:hover:bg-blue-900'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden whitespace-nowrap text-xs font-medium transition-all duration-150 ease-out',
|
||||
searchEnabled ? 'ml-1.5 max-w-[60px] opacity-100' : 'max-w-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">Search</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchEnabled(true)}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
Search
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{lockedModel ? (
|
||||
|
|
@ -802,6 +782,8 @@ export interface ChatInputWithMentionsProps {
|
|||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
workDir?: string | null
|
||||
onWorkDirChange?: (value: string | null) => void
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -831,6 +813,8 @@ export function ChatInputWithMentions({
|
|||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -858,6 +842,8 @@ export function ChatInputWithMentions({
|
|||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
workDir={workDir}
|
||||
onWorkDirChange={onWorkDirChange}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ interface ChatSidebarProps {
|
|||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
workDirByTab?: Record<string, string | null>
|
||||
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
|
|
@ -199,6 +201,8 @@ export function ChatSidebar({
|
|||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
workDirByTab = {},
|
||||
onWorkDirChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
|
|
@ -690,6 +694,8 @@ export function ChatSidebar({
|
|||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
workDir={workDirByTab[tab.id] ?? null}
|
||||
onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.
|
|||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||
|
||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
|
||||
|
||||
// Work directory is scoped per run (per chat). Each run gets its own sidecar
|
||||
// config file so setting it in one chat does not leak into others.
|
||||
function workDirConfigFile(runId: string): string {
|
||||
return path.join(WorkDir, 'config', `workdir-${runId}.json`);
|
||||
}
|
||||
|
||||
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
||||
|
||||
|
|
@ -165,10 +170,11 @@ async function getToolPermissionMetadata(
|
|||
};
|
||||
}
|
||||
|
||||
function loadUserWorkDir(): string | null {
|
||||
function loadUserWorkDir(runId: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
||||
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
||||
const file = workDirConfigFile(runId);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
const raw = fs.readFileSync(file, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { path?: unknown };
|
||||
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
||||
return value || null;
|
||||
|
|
@ -1259,7 +1265,7 @@ export async function* streamAgent({
|
|||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
const userWorkDir = loadUserWorkDir();
|
||||
const userWorkDir = loadUserWorkDir(runId);
|
||||
if (userWorkDir) {
|
||||
loopLogger.log('injecting user work directory', userWorkDir);
|
||||
instructionsWithDateTime += `\n\n# User Work Directory
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue