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 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}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
|
|
@ -133,6 +132,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 +162,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 +178,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 +260,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 +281,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', {
|
|
||||||
path: 'config/workdir.json',
|
|
||||||
data: JSON.stringify({}, null, 2),
|
|
||||||
})
|
|
||||||
setWorkDir(null)
|
|
||||||
toast.success('Work directory cleared')
|
toast.success('Work directory cleared')
|
||||||
} catch (err) {
|
}, [onWorkDirChange])
|
||||||
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(() => {
|
||||||
|
|
@ -569,28 +546,29 @@ function ChatInputInner({
|
||||||
<FolderCog className="size-4" />
|
<FolderCog className="size-4" />
|
||||||
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{workDir && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}>
|
|
||||||
<X className="size-4" />
|
|
||||||
<span>Clear work directory</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{workDir && (
|
{workDir && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSetWorkDir}
|
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"
|
className="flex min-w-0 items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<FolderCog className="h-3.5 w-3.5" />
|
<FolderCog className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
||||||
</button>
|
</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>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
Work directory: {workDir}
|
Work directory: {workDir}
|
||||||
|
|
@ -598,26 +576,28 @@ function ChatInputInner({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{searchAvailable && (
|
{searchAvailable && (
|
||||||
searchEnabled ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchEnabled(false)}
|
onClick={() => setSearchEnabled((v) => !v)}
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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"
|
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" />
|
<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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{lockedModel ? (
|
{lockedModel ? (
|
||||||
|
|
@ -802,6 +782,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 +813,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 +842,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