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

View file

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

View file

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

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";
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