diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx index 73ec530b3..319144233 100644 --- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.test.tsx @@ -3,7 +3,15 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + afterEach, + assert, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import { i18n } from '@/i18n'; @@ -18,6 +26,20 @@ async function render( await act(async () => root.render()); } +async function openPopover() { + const trigger = container.querySelector('button'); + assert(trigger); + await act(async () => trigger.click()); +} + +function nodeOverrideInput(): HTMLInputElement { + const input = document.body.querySelector( + 'input[aria-label="Node override"]', + ); + assert(input); + return input; +} + beforeEach(async () => { await i18n.changeLanguage('en'); container = document.createElement('div'); @@ -34,55 +56,69 @@ describe('WorkingDirectoryOverride', () => { it('shows Profile inheritance and updates it without persisting a copy', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await render({ + profileAlias: 'First Profile', profileWorkingDirPath: '/profiles/first', editable: true, saving: false, onSave, }); + expect(document.body.textContent).not.toContain( + 'Inheriting Profile directory', + ); + await openPopover(); - const input = container.querySelector('input')!; + const input = nodeOverrideInput(); expect(input.placeholder).toBe('/profiles/first'); - expect(container.textContent).toContain( + expect(document.body.textContent).toContain( 'Inheriting Profile directory: /profiles/first', ); + expect(document.body.textContent).toContain('First Profile'); await render({ + profileAlias: 'Second Profile', profileWorkingDirPath: '/profiles/second', editable: true, saving: false, onSave, }); - expect(container.querySelector('input')?.value).toBe(''); - expect(container.querySelector('input')?.placeholder).toBe( - '/profiles/second', + const updatedInput = document.body.querySelector( + 'input[aria-label="Node override"]', ); + expect(updatedInput?.value).toBe(''); + expect(updatedInput?.placeholder).toBe('/profiles/second'); + expect(document.body.textContent).toContain('Second Profile'); expect(onSave).not.toHaveBeenCalled(); }); it('preserves an explicit override across Profile changes and clears to null', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await render({ + profileAlias: 'First Profile', profileWorkingDirPath: '/profiles/first', workingDirPath: '/node/work', editable: true, saving: false, onSave, }); + await openPopover(); await render({ + profileAlias: 'Second Profile', profileWorkingDirPath: '/profiles/second', workingDirPath: '/node/work', editable: true, saving: false, onSave, }); - const input = container.querySelector('input')!; + const input = nodeOverrideInput(); expect(input.value).toBe('/node/work'); await act(async () => { - Object.getOwnPropertyDescriptor( + const valueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', - )!.set!.call(input, ''); + )?.set; + assert(valueSetter); + valueSetter.call(input, ''); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); @@ -92,16 +128,20 @@ describe('WorkingDirectoryOverride', () => { it('keeps a locked explicit override visible but hides locked inheritance', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await render({ + profileAlias: 'Default Profile', profileWorkingDirPath: '/profiles/default', workingDirPath: '/node/work', editable: false, saving: false, onSave, }); - expect(container.textContent).toContain('/node/work'); - expect(container.querySelector('input')).toBeNull(); + expect(container.textContent).not.toContain('/node/work'); + await openPopover(); + expect(document.body.textContent).toContain('/node/work'); + expect(document.body.querySelector('input')).toBeNull(); await render({ + profileAlias: 'Default Profile', profileWorkingDirPath: '/profiles/default', editable: false, saving: false, @@ -113,23 +153,51 @@ describe('WorkingDirectoryOverride', () => { it('surfaces server validation errors without replacing the draft', async () => { const onSave = vi.fn().mockRejectedValue(new Error('Must be absolute')); await render({ + profileAlias: 'Default Profile', profileWorkingDirPath: '/profiles/default', editable: true, saving: false, onSave, }); - const input = container.querySelector('input')!; + await openPopover(); + const input = nodeOverrideInput(); await act(async () => { - Object.getOwnPropertyDescriptor( + const valueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', - )!.set!.call(input, 'relative/path'); + )?.set; + assert(valueSetter); + valueSetter.call(input, 'relative/path'); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); - expect(container.querySelector('[role="alert"]')?.textContent).toBe( + expect(document.body.querySelector('[role="alert"]')?.textContent).toBe( 'Must be absolute', ); expect(input.value).toBe('relative/path'); }); + + it('marks an explicit override and restores inheritance explicitly', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + await render({ + profileAlias: 'Default Profile', + profileWorkingDirPath: '/profiles/default', + workingDirPath: '/node/work', + editable: true, + saving: false, + onSave, + }); + expect( + container.querySelector( + '[aria-label="Node working directory: /node/work"]', + ), + ).not.toBeNull(); + await openPopover(); + const restore = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent === 'Restore Profile inheritance', + ); + assert(restore); + await act(async () => restore.click()); + expect(onSave).toHaveBeenCalledWith(null); + }); }); diff --git a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx index 32c25bdd8..588c76975 100644 --- a/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx +++ b/apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx @@ -1,12 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { useEffect, useState } from 'react'; +import { Folder } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/Common/Button'; +import { cn } from '@/components/Common/cn'; +import { Popover } from '@/components/Common/Popover'; import { TextInput } from '@/components/Common/TextInput'; interface WorkingDirectoryOverrideProps { + profileAlias: string; profileWorkingDirPath: string; workingDirPath?: string; editable: boolean; @@ -15,6 +20,7 @@ interface WorkingDirectoryOverrideProps { } export function WorkingDirectoryOverride({ + profileAlias, profileWorkingDirPath, workingDirPath, editable, @@ -22,6 +28,9 @@ export function WorkingDirectoryOverride({ onSave, }: WorkingDirectoryOverrideProps) { const { t } = useTranslation(); + const triggerRef = useRef(null); + const inputRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); const [draft, setDraft] = useState(workingDirPath ?? ''); const [error, setError] = useState(null); @@ -32,6 +41,14 @@ export function WorkingDirectoryOverride({ if (!editable && !workingDirPath) return null; + const hasOverride = Boolean(workingDirPath); + const effectivePath = workingDirPath ?? profileWorkingDirPath; + const triggerTitle = hasOverride + ? t('chat.workingDirectoryOverrideActive', { path: workingDirPath }) + : t('chat.workingDirectoryOverrideInherited', { + path: profileWorkingDirPath, + }); + const commit = async () => { const trimmed = draft.trim(); if (trimmed === (workingDirPath ?? '')) return; @@ -47,59 +64,127 @@ export function WorkingDirectoryOverride({ } }; - if (!editable) { - return ( -
-
- {t('chat.workingDirectoryOverride')} -
-
- {workingDirPath} -
-
- {t('chat.workingDirectoryNodeOnlyLocked')} -
-
- ); - } - return ( -
- - setDraft(event.target.value)} - onBlur={() => void commit()} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - event.currentTarget.blur(); - } else if (event.key === 'Escape') { - setDraft(workingDirPath ?? ''); - setError(null); - event.currentTarget.blur(); - } - }} - placeholder={profileWorkingDirPath} - aria-label={t('chat.workingDirectoryOverride')} - mono - disabled={saving} - className="w-full" - /> -
- {draft.trim() - ? t('chat.workingDirectoryNodeOnly') - : t('chat.workingDirectoryInherited', { - path: profileWorkingDirPath, - })} -
- {error && ( -
- {error} -
- )} -
+ <> + + {isOpen ? ( + setIsOpen(false)} + onOpenAutoFocus={() => inputRef.current?.focus()} + className="w-[min(24rem,var(--popover-available-width))] p-3" + > +
+
+
+ {t('chat.workingDirectoryOverride')} +
+
+ {editable + ? t('chat.workingDirectoryOverrideDescription') + : t('chat.workingDirectoryNodeOnlyLocked')} +
+
+
+
+ {t('chat.workingDirectoryProfile')} +
+
{profileAlias}
+ {profileWorkingDirPath ? ( + <> +
+ {t('chat.workingDirectoryProfileDefault')} +
+
+ {profileWorkingDirPath} +
+ + ) : null} +
+ {t('chat.workingDirectoryEffective')} +
+
+ {effectivePath} +
+
+ {editable ? ( +
+ + setDraft(event.target.value)} + onBlur={() => void commit()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.currentTarget.blur(); + } else if (event.key === 'Escape') { + setDraft(workingDirPath ?? ''); + setError(null); + } + }} + placeholder={profileWorkingDirPath} + aria-label={t('chat.workingDirectoryNodeOverride')} + mono + disabled={saving} + className="w-full" + /> +
+ {draft.trim() + ? t('chat.workingDirectoryNodeOnly') + : t('chat.workingDirectoryInherited', { + path: profileWorkingDirPath, + })} +
+ {error ? ( +
+ {error} +
+ ) : null} + {workingDirPath ? ( + + ) : null} +
+ ) : null} +
+
+ ) : null} + ); } diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index ea01cfa19..da626fb18 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -1090,21 +1090,25 @@ export const ChatPanel = ({ slashLoading={slashLoading} onSlashMenuIntent={refreshSlashCommands} agentSelectorSlot={ -
-
- -
- {showWorkingDirectoryOverride && ( +
+ + {showWorkingDirectoryOverride ? ( - )} + ) : null}
} acpSelectorsSlot={ diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 85933262c..abfa140b1 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -772,10 +772,18 @@ }, "chat": { "workingDirectoryOverride": "Working directory override", + "workingDirectoryOverrideDescription": "Optionally use a different directory for this Agent Node.", + "workingDirectoryOverrideActive": "Node working directory: {{path}}", + "workingDirectoryOverrideInherited": "Working directory inherited from Profile: {{path}}", "workingDirectoryInherited": "Inheriting Profile directory: {{path}}", "workingDirectoryNodeOnly": "Applies only to this Agent Node.", "workingDirectoryNodeOnlyLocked": "Node-only override captured for this execution.", "workingDirectoryOverrideSaveFailed": "Failed to save working directory override", + "workingDirectoryProfile": "Profile", + "workingDirectoryProfileDefault": "Profile directory", + "workingDirectoryEffective": "Effective directory", + "workingDirectoryNodeOverride": "Node override", + "workingDirectoryRestoreInheritance": "Restore Profile inheritance", "showEarlierTurns": "Show {{count}} earlier turns", "loadingEarlierTurns": "Loading earlier turns…", "backToBottom": "Back to bottom", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 5e11167b0..b601f7285 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -772,10 +772,18 @@ }, "chat": { "workingDirectoryOverride": "工作目录覆盖", + "workingDirectoryOverrideDescription": "可选择仅为此 Agent 节点使用不同的目录。", + "workingDirectoryOverrideActive": "节点工作目录:{{path}}", + "workingDirectoryOverrideInherited": "工作目录继承自 Profile:{{path}}", "workingDirectoryInherited": "继承 Profile 目录:{{path}}", "workingDirectoryNodeOnly": "仅应用于此 Agent 节点。", "workingDirectoryNodeOnlyLocked": "此执行已固定使用该节点专属覆盖。", "workingDirectoryOverrideSaveFailed": "保存工作目录覆盖失败", + "workingDirectoryProfile": "Profile", + "workingDirectoryProfileDefault": "Profile 目录", + "workingDirectoryEffective": "有效目录", + "workingDirectoryNodeOverride": "节点覆盖", + "workingDirectoryRestoreInheritance": "恢复继承 Profile", "showEarlierTurns": "显示更早的 {{count}} 个轮次", "loadingEarlierTurns": "正在加载更早的轮次…", "backToBottom": "回到底部", diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md index 88dcfae85..d3ae49620 100644 --- a/docs/architecture/question-node.md +++ b/docs/architecture/question-node.md @@ -77,7 +77,7 @@ Created like any node via `CREATE_NODES` ([resolveAddNodes.ts](../../apps/web/sr - **Idle** → double-click opens compose (§5). - An idle node with `agentBindingPolicy: fixed` opens compose with its persisted binding and a read-only Agent selector. Ordinary Editing nodes retain the picker; Bound nodes cannot switch execution identity even when a first control created no messages. -- An Editing Node bound to an external Profile exposes an optional Node-specific working-directory override below the Agent selector. Empty means inherit the selected Profile without persisting a copy; an explicit value survives Profile changes and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains visible as a read-only summary. +- An Editing Node bound to an external Profile exposes a Folder button beside the Agent selector. Its Popover shows the Profile and effective directories and optionally edits a Node-specific working-directory override. Empty means inherit the selected Profile without persisting a copy; an explicit value marks the trigger, survives Profile changes, and applies only to that Node. The control has no server-local folder picker because the Profile's Agentlet may run on another machine. Binding locks the override with the rest of execution preparation; a locked explicit value remains available through the same Popover as read-only context. - After sending: **running → done / error**. - `AgentThreadService` owns lifecycle for every node-backed invocation, regardless of policy. Admission publishes a new token and `running` before dispatch, installs cancellation before slow preparation, and keeps the turn lease through settlement. `AgentNodeLifecycle` fills only freshly read empty, never-submitted content and projects the matching terminal result. Existing content, previous submission tokens, and legacy conversation history prevent follow-ups from replacing authored text. - Loading and reconnect observe server state; they never infer success from old history or repair status in the browser. A persisted running node without live tracking after restart does not establish an outcome, introduce a new badge, or trigger replay. Existing retry/admission behavior remains unchanged.