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:
gagan 2026-05-27 03:09:56 +05:30 committed by GitHub
parent 45bdbfcbbc
commit d981fa9206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 133 additions and 79 deletions

View file

@ -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}

View file

@ -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', { 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(() => {
@ -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>
<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">
type="button" <button
onClick={handleSetWorkDir} type="button"
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" onClick={handleSetWorkDir}
> className="flex min-w-0 items-center gap-1.5"
<FolderCog className="h-3.5 w-3.5" /> >
<span className="truncate">{workDir.split('/').pop() || workDir}</span> <FolderCog className="h-3.5 w-3.5 shrink-0" />
</button> <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> </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((v) => !v)}
onClick={() => setSearchEnabled(false)} aria-label="Search"
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" 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" /> Search
<span className="text-xs font-medium">Search</span> </span>
<X className="h-3 w-3" /> </button>
</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>
)
)} )}
<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>
) )

View file

@ -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}

View file

@ -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