mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix: scope chat work directory per-run instead of globally
This commit is contained in:
parent
c6d6ad7665
commit
811813c17c
4 changed files with 94 additions and 42 deletions
|
|
@ -1034,6 +1034,10 @@ function App() {
|
||||||
const chatViewStateByTabRef = useRef(chatViewStateByTab)
|
const chatViewStateByTabRef = useRef(chatViewStateByTab)
|
||||||
const chatDraftsRef = useRef(new Map<string, string>())
|
const chatDraftsRef = useRef(new Map<string, string>())
|
||||||
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: 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 chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||||
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
||||||
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
||||||
|
|
@ -1046,6 +1050,36 @@ function App() {
|
||||||
chatDraftsRef.current.delete(tabId)
|
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 => {
|
const isToolOpenForTab = useCallback((tabId: string, toolId: string): boolean => {
|
||||||
return toolOpenByTab[tabId]?.[toolId] ?? false
|
return toolOpenByTab[tabId]?.[toolId] ?? false
|
||||||
}, [toolOpenByTab])
|
}, [toolOpenByTab])
|
||||||
|
|
@ -2023,10 +2057,16 @@ function App() {
|
||||||
setPendingAskHumanRequests(pendingAsks)
|
setPendingAskHumanRequests(pendingAsks)
|
||||||
setAllPermissionRequests(allPermissionRequests)
|
setAllPermissionRequests(allPermissionRequests)
|
||||||
setPermissionResponses(permResponseMap)
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to load run:', err)
|
console.error('Failed to load run:', err)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [loadRunWorkDir])
|
||||||
|
|
||||||
const getStreamingBuffer = useCallback((id: string) => {
|
const getStreamingBuffer = useCallback((id: string) => {
|
||||||
const existing = streamingBuffersRef.current.get(id)
|
const existing = streamingBuffersRef.current.get(id)
|
||||||
|
|
@ -2478,6 +2518,10 @@ function App() {
|
||||||
? { ...tab, runId: currentRunId }
|
? { ...tab, runId: currentRunId }
|
||||||
: tab
|
: 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
|
isNewRun = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2671,6 +2715,8 @@ function App() {
|
||||||
...prev,
|
...prev,
|
||||||
[activeChatTabIdRef.current]: createEmptyChatTabViewState(),
|
[activeChatTabIdRef.current]: createEmptyChatTabViewState(),
|
||||||
}))
|
}))
|
||||||
|
// A brand-new chat starts with no work directory.
|
||||||
|
setWorkDirByTab(prev => ({ ...prev, [activeChatTabIdRef.current]: null }))
|
||||||
}, [setChatViewportAnchor])
|
}, [setChatViewportAnchor])
|
||||||
|
|
||||||
// Chat tab operations
|
// Chat tab operations
|
||||||
|
|
@ -2758,6 +2804,12 @@ function App() {
|
||||||
chatDraftsRef.current.delete(tabId)
|
chatDraftsRef.current.delete(tabId)
|
||||||
selectedModelByTabRef.current.delete(tabId)
|
selectedModelByTabRef.current.delete(tabId)
|
||||||
chatScrollTopByTabRef.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) => {
|
setToolOpenByTab((prev) => {
|
||||||
if (!(tabId in prev)) return prev
|
if (!(tabId in prev)) return prev
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
|
|
@ -5800,6 +5852,8 @@ function App() {
|
||||||
selectedModelByTabRef.current.delete(tab.id)
|
selectedModelByTabRef.current.delete(tab.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
workDir={workDirByTab[tab.id] ?? null}
|
||||||
|
onWorkDirChange={(v) => setTabWorkDir(tab.id, v)}
|
||||||
isRecording={isActive && isRecording}
|
isRecording={isActive && isRecording}
|
||||||
recordingText={isActive ? voice.interimText : undefined}
|
recordingText={isActive ? voice.interimText : undefined}
|
||||||
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
||||||
|
|
@ -5869,6 +5923,8 @@ function App() {
|
||||||
selectedModelByTabRef.current.delete(tabId)
|
selectedModelByTabRef.current.delete(tabId)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
workDirByTab={workDirByTab}
|
||||||
|
onWorkDirChangeForTab={setTabWorkDir}
|
||||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||||
allPermissionRequests={allPermissionRequests}
|
allPermissionRequests={allPermissionRequests}
|
||||||
permissionResponses={permissionResponses}
|
permissionResponses={permissionResponses}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,10 @@ interface ChatInputInnerProps {
|
||||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
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({
|
function ChatInputInner({
|
||||||
|
|
@ -159,6 +163,8 @@ function ChatInputInner({
|
||||||
onToggleTts,
|
onToggleTts,
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
|
workDir = null,
|
||||||
|
onWorkDirChange,
|
||||||
}: ChatInputInnerProps) {
|
}: ChatInputInnerProps) {
|
||||||
const controller = usePromptInputController()
|
const controller = usePromptInputController()
|
||||||
const message = controller.textInput.value
|
const message = controller.textInput.value
|
||||||
|
|
@ -173,7 +179,6 @@ function ChatInputInner({
|
||||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = 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.
|
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -256,22 +261,8 @@ function ChatInputInner({
|
||||||
return () => window.removeEventListener('models-config-changed', handler)
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
}, [loadModelConfig])
|
}, [loadModelConfig])
|
||||||
|
|
||||||
// Load currently configured work directory
|
// Work directory is owned per-chat by the parent (App). This component only
|
||||||
const loadWorkDir = useCallback(async () => {
|
// drives the picker dialog and reports changes up via onWorkDirChange.
|
||||||
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])
|
|
||||||
|
|
||||||
const handleSetWorkDir = useCallback(async () => {
|
const handleSetWorkDir = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
let defaultPath: string | undefined = workDir ?? undefined
|
let defaultPath: string | undefined = workDir ?? undefined
|
||||||
|
|
@ -291,31 +282,18 @@ function ChatInputInner({
|
||||||
defaultPath,
|
defaultPath,
|
||||||
})
|
})
|
||||||
if (!chosen) return
|
if (!chosen) return
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
onWorkDirChange?.(chosen)
|
||||||
path: 'config/workdir.json',
|
|
||||||
data: JSON.stringify({ path: chosen }, null, 2),
|
|
||||||
})
|
|
||||||
setWorkDir(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])
|
}, [workDir, onWorkDirChange])
|
||||||
|
|
||||||
const handleClearWorkDir = useCallback(async () => {
|
const handleClearWorkDir = useCallback(() => {
|
||||||
try {
|
onWorkDirChange?.(null)
|
||||||
await window.ipc.invoke('workspace:writeFile', {
|
toast.success('Work directory cleared')
|
||||||
path: 'config/workdir.json',
|
}, [onWorkDirChange])
|
||||||
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')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Check search tool availability (exa or signed-in via gateway)
|
// Check search tool availability (exa or signed-in via gateway)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -802,6 +780,8 @@ export interface ChatInputWithMentionsProps {
|
||||||
onToggleTts?: () => void
|
onToggleTts?: () => void
|
||||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||||
|
workDir?: string | null
|
||||||
|
onWorkDirChange?: (value: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInputWithMentions({
|
export function ChatInputWithMentions({
|
||||||
|
|
@ -831,6 +811,8 @@ export function ChatInputWithMentions({
|
||||||
onToggleTts,
|
onToggleTts,
|
||||||
onTtsModeChange,
|
onTtsModeChange,
|
||||||
onSelectedModelChange,
|
onSelectedModelChange,
|
||||||
|
workDir,
|
||||||
|
onWorkDirChange,
|
||||||
}: ChatInputWithMentionsProps) {
|
}: ChatInputWithMentionsProps) {
|
||||||
return (
|
return (
|
||||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||||
|
|
@ -858,6 +840,8 @@ export function ChatInputWithMentions({
|
||||||
onToggleTts={onToggleTts}
|
onToggleTts={onToggleTts}
|
||||||
onTtsModeChange={onTtsModeChange}
|
onTtsModeChange={onTtsModeChange}
|
||||||
onSelectedModelChange={onSelectedModelChange}
|
onSelectedModelChange={onSelectedModelChange}
|
||||||
|
workDir={workDir}
|
||||||
|
onWorkDirChange={onWorkDirChange}
|
||||||
/>
|
/>
|
||||||
</PromptInputProvider>
|
</PromptInputProvider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,8 @@ interface ChatSidebarProps {
|
||||||
getInitialDraft?: (tabId: string) => string | undefined
|
getInitialDraft?: (tabId: string) => string | undefined
|
||||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||||
|
workDirByTab?: Record<string, string | null>
|
||||||
|
onWorkDirChangeForTab?: (tabId: string, value: string | null) => void
|
||||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||||
|
|
@ -199,6 +201,8 @@ export function ChatSidebar({
|
||||||
getInitialDraft,
|
getInitialDraft,
|
||||||
onDraftChangeForTab,
|
onDraftChangeForTab,
|
||||||
onSelectedModelChangeForTab,
|
onSelectedModelChangeForTab,
|
||||||
|
workDirByTab = {},
|
||||||
|
onWorkDirChangeForTab,
|
||||||
pendingAskHumanRequests = new Map(),
|
pendingAskHumanRequests = new Map(),
|
||||||
allPermissionRequests = new Map(),
|
allPermissionRequests = new Map(),
|
||||||
permissionResponses = new Map(),
|
permissionResponses = new Map(),
|
||||||
|
|
@ -690,6 +694,8 @@ export function ChatSidebar({
|
||||||
initialDraft={getInitialDraft?.(tab.id)}
|
initialDraft={getInitialDraft?.(tab.id)}
|
||||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : 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}
|
isRecording={isActive && isRecording}
|
||||||
recordingText={isActive ? recordingText : undefined}
|
recordingText={isActive ? recordingText : undefined}
|
||||||
recordingState={isActive ? recordingState : 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";
|
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||||
|
|
||||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
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>;
|
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
|
||||||
|
|
||||||
|
|
@ -165,10 +170,11 @@ async function getToolPermissionMetadata(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadUserWorkDir(): string | null {
|
function loadUserWorkDir(runId: string): string | null {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
const file = workDirConfigFile(runId);
|
||||||
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
if (!fs.existsSync(file)) return null;
|
||||||
|
const raw = fs.readFileSync(file, 'utf-8');
|
||||||
const parsed = JSON.parse(raw) as { path?: unknown };
|
const parsed = JSON.parse(raw) as { path?: unknown };
|
||||||
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
||||||
return value || null;
|
return value || null;
|
||||||
|
|
@ -1259,7 +1265,7 @@ export async function* streamAgent({
|
||||||
if (agentNotesContext) {
|
if (agentNotesContext) {
|
||||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||||
}
|
}
|
||||||
const userWorkDir = loadUserWorkDir();
|
const userWorkDir = loadUserWorkDir(runId);
|
||||||
if (userWorkDir) {
|
if (userWorkDir) {
|
||||||
loopLogger.log('injecting user work directory', userWorkDir);
|
loopLogger.log('injecting user work directory', userWorkDir);
|
||||||
instructionsWithDateTime += `\n\n# User Work Directory
|
instructionsWithDateTime += `\n\n# User Work Directory
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue