can set a work directory in assistant chats (#534)

This commit is contained in:
arkml 2026-05-06 23:14:00 +05:30 committed by GitHub
parent d6651c4bf8
commit a48887da61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 159 additions and 8 deletions

View file

@ -685,6 +685,19 @@ export function setupIpcHandlers() {
const mimeType = mimeMap[ext] || 'application/octet-stream'; const mimeType = mimeMap[ext] || 'application/octet-stream';
return { data: buffer.toString('base64'), mimeType, size: stat.size }; return { data: buffer.toString('base64'), mimeType, size: stat.size };
}, },
'dialog:openDirectory': async (event, args) => {
const win = BrowserWindow.fromWebContents(event.sender);
const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir();
const result = await dialog.showOpenDialog(win!, {
title: args.title ?? 'Choose work directory',
defaultPath,
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return { path: null };
}
return { path: result.filePaths[0] ?? null };
},
// Knowledge version history handlers // Knowledge version history handlers
'knowledge:history': async (_event, args) => { 'knowledge:history': async (_event, args) => {
const commits = await versionHistory.getFileHistory(args.path); const commits = await versionHistory.getFileHistory(args.path);

View file

@ -10,8 +10,10 @@ import {
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
FileVideo, FileVideo,
FolderCog,
Globe, Globe,
Headphones, Headphones,
ImagePlus,
LoaderIcon, LoaderIcon,
Mic, Mic,
Plus, Plus,
@ -23,8 +25,10 @@ import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { import {
@ -169,6 +173,7 @@ 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(() => {
@ -251,6 +256,55 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler) return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig]) }, [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])
const handleSetWorkDir = useCallback(async () => {
try {
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
title: 'Choose work directory',
defaultPath: workDir ?? undefined,
})
if (!chosen) return
await window.ipc.invoke('workspace:writeFile', {
path: 'config/workdir.json',
data: JSON.stringify({ path: chosen }, null, 2),
})
setWorkDir(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])
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')
}
}, [])
// Check search tool availability (exa or signed-in via gateway) // Check search tool availability (exa or signed-in via gateway)
useEffect(() => { useEffect(() => {
const checkSearch = async () => { const checkSearch = async () => {
@ -484,14 +538,53 @@ function ChatInputInner({
/> />
</div> </div>
<div className="flex items-center gap-2 px-4 pb-3"> <div className="flex items-center gap-2 px-4 pb-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()}
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" 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="Attach files" aria-label="Add"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</button> </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
<ImagePlus className="size-4" />
<span>Add files or photos</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
<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>
</TooltipTrigger>
<TooltipContent side="top">
Work directory: {workDir}
</TooltipContent>
</Tooltip>
)}
{searchAvailable && ( {searchAvailable && (
searchEnabled ? ( searchEnabled ? (
<button <button

View file

@ -35,6 +35,19 @@ 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');
function loadUserWorkDir(): string | null {
try {
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
const parsed = JSON.parse(raw) as { path?: unknown };
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
return value || null;
} catch {
return null;
}
}
function loadAgentNotesContext(): string | null { function loadAgentNotesContext(): string | null {
const sections: string[] = []; const sections: string[] = [];
@ -1094,6 +1107,28 @@ export async function* streamAgent({
if (agentNotesContext) { if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`; instructionsWithDateTime += `\n\n${agentNotesContext}`;
} }
const userWorkDir = loadUserWorkDir();
if (userWorkDir) {
loopLogger.log('injecting user work directory', userWorkDir);
instructionsWithDateTime += `\n\n# User Work Directory
The user has chosen the following directory as their current **work directory**:
\`${userWorkDir}\`
Treat this as the **default location** for file operations whenever the user refers to files generically:
- "list the files", "show me what's in here", "what's the latest report" list or look in the work directory.
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
Do not announce the work directory unless it's relevant. Just use it.`;
}
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal // Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
// that supersedes any earlier middle-pane mention in the conversation history. // that supersedes any earlier middle-pane mention in the conversation history.
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`; const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;

View file

@ -483,6 +483,16 @@ const ipcSchemas = {
req: z.object({ path: z.string() }), req: z.object({ path: z.string() }),
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }), res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
}, },
// Native dialog channels
'dialog:openDirectory': {
req: z.object({
defaultPath: z.string().optional(),
title: z.string().optional(),
}),
res: z.object({
path: z.string().nullable(),
}),
},
// Knowledge version history channels // Knowledge version history channels
'knowledge:history': { 'knowledge:history': {
req: z.object({ path: RelPath }), req: z.object({ path: RelPath }),