Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { i18n } from '@/i18n';

import { WorkingDirectoryOverride } from './WorkingDirectoryOverride';

let container: HTMLDivElement;
let root: Root;

async function render(
props: React.ComponentProps<typeof WorkingDirectoryOverride>,
) {
await act(async () => root.render(<WorkingDirectoryOverride {...props} />));
}

beforeEach(async () => {
await i18n.changeLanguage('en');
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});

describe('WorkingDirectoryOverride', () => {
it('shows Profile inheritance and updates it without persisting a copy', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await render({
profileWorkingDirPath: '/profiles/first',
editable: true,
saving: false,
onSave,
});

const input = container.querySelector('input')!;
expect(input.placeholder).toBe('/profiles/first');
expect(container.textContent).toContain(
'Inheriting Profile directory: /profiles/first',
);

await render({
profileWorkingDirPath: '/profiles/second',
editable: true,
saving: false,
onSave,
});
expect(container.querySelector('input')?.value).toBe('');
expect(container.querySelector('input')?.placeholder).toBe(
'/profiles/second',
);
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({
profileWorkingDirPath: '/profiles/first',
workingDirPath: '/node/work',
editable: true,
saving: false,
onSave,
});
await render({
profileWorkingDirPath: '/profiles/second',
workingDirPath: '/node/work',
editable: true,
saving: false,
onSave,
});
const input = container.querySelector('input')!;
expect(input.value).toBe('/node/work');

await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!.call(input, '');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
});
expect(onSave).toHaveBeenCalledWith(null);
});

it('keeps a locked explicit override visible but hides locked inheritance', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
await render({
profileWorkingDirPath: '/profiles/default',
workingDirPath: '/node/work',
editable: false,
saving: false,
onSave,
});
expect(container.textContent).toContain('/node/work');
expect(container.querySelector('input')).toBeNull();

await render({
profileWorkingDirPath: '/profiles/default',
editable: false,
saving: false,
onSave,
});
expect(container.textContent).toBe('');
});

it('surfaces server validation errors without replacing the draft', async () => {
const onSave = vi.fn().mockRejectedValue(new Error('Must be absolute'));
await render({
profileWorkingDirPath: '/profiles/default',
editable: true,
saving: false,
onSave,
});
const input = container.querySelector('input')!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!.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(
'Must be absolute',
);
expect(input.value).toBe('relative/path');
});
});
105 changes: 105 additions & 0 deletions apps/web/src/components/Panels/ChatPanel/WorkingDirectoryOverride.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { TextInput } from '@/components/Common/TextInput';

interface WorkingDirectoryOverrideProps {
profileWorkingDirPath: string;
workingDirPath?: string;
editable: boolean;
saving: boolean;
onSave: (workingDirPath: string | null) => Promise<void>;
}

export function WorkingDirectoryOverride({
profileWorkingDirPath,
workingDirPath,
editable,
saving,
onSave,
}: WorkingDirectoryOverrideProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState(workingDirPath ?? '');
const [error, setError] = useState<string | null>(null);

useEffect(() => {
setDraft(workingDirPath ?? '');
setError(null);
}, [workingDirPath]);

if (!editable && !workingDirPath) return null;

const commit = async () => {
const trimmed = draft.trim();
if (trimmed === (workingDirPath ?? '')) return;
setError(null);
try {
await onSave(trimmed || null);
} catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: t('chat.workingDirectoryOverrideSaveFailed'),
);
}
};

if (!editable) {
return (
<div className="border-edge-default mt-1 border-t pt-1.5">
<div className="text-fg-subtle text-[10px]">
{t('chat.workingDirectoryOverride')}
</div>
<div className="text-fg-muted truncate font-mono text-xs">
{workingDirPath}
</div>
<div className="text-fg-subtle text-[10px]">
{t('chat.workingDirectoryNodeOnlyLocked')}
</div>
</div>
);
}

return (
<div className="border-edge-default mt-1 border-t pt-1.5">
<label className="text-fg-muted mb-1 block text-[10px] font-medium">
{t('chat.workingDirectoryOverride')}
</label>
<TextInput
value={draft}
onChange={(event) => 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"
/>
<div className="text-fg-subtle mt-1 text-[10px]">
{draft.trim()
? t('chat.workingDirectoryNodeOnly')
: t('chat.workingDirectoryInherited', {
path: profileWorkingDirPath,
})}
</div>
{error && (
<div role="alert" className="text-danger mt-1 text-[10px]">
{error}
</div>
)}
</div>
);
}
69 changes: 58 additions & 11 deletions apps/web/src/components/Panels/ChatPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
acknowledgeConversationResult,
awaitConversationDraft,
saveConversationDraft,
saveConversationWorkingDirectoryOverride,
resolveConversationAgentBinding,
resolveConversationOwnerSource,
} from '@/store/conversationOwner';
Expand Down Expand Up @@ -78,6 +79,7 @@ import { ChangeReviewCard } from './ChangeReviewCard';
import { parseSlashInvocations } from './parseSlashInvocations';
import { saveChatAsQuestion } from './saveChatAsQuestion';
import { ThreadChatInput } from './ThreadChatInput';
import { WorkingDirectoryOverride } from './WorkingDirectoryOverride';
import { useAgentStream } from '../../../hooks/useAgentStream';
import { useChatHistory } from '../../../hooks/useChatHistory';
import { MessageList } from '../../Messages/MessageList';
Expand Down Expand Up @@ -169,6 +171,7 @@ export const ChatPanel = ({
conversationOwnerSource?.agentBindingPolicy === 'fixed' ||
conversationOwnerSource?.bindingState === 'bound';
const [savingAgentDraft, setSavingAgentDraft] = useState(false);
const [savingWorkingDirectory, setSavingWorkingDirectory] = useState(false);
const activelyViewingOwner = useActivelyViewingQuestionNode(
activeConversationView?.presentationAnchor.nodeId ?? '',
);
Expand Down Expand Up @@ -811,13 +814,40 @@ export const ChatPanel = ({
// read-only. Picking an agent rebinds the *current* (empty) thread in
// place; it never mints a new thread.
const threadHasUserMessage = messages.some((m) => m.role === 'user');
const agentSelectorEditable =
const preparationEditable =
!viewingQuestionBindingIsFixed &&
(activeConversationView
? conversationOwnerSource?.bindingState !== 'bound'
: !threadHasUserMessage) &&
!savingAgentDraft &&
!isLoading;
const agentSelectorEditable = preparationEditable && !savingWorkingDirectory;
const selectedExternalProfile =
agentBinding.kind === 'external'
? acpProfiles.find((profile) => profile.id === agentBinding.profileId)
: undefined;
const workingDirectoryOverride =
conversationOwnerSource?.agentLaunchOverrides?.workingDirPath;
const showWorkingDirectoryOverride =
!!activeConversationView &&
((!!selectedExternalProfile && preparationEditable) ||
!!workingDirectoryOverride);
const handleSaveWorkingDirectory = useCallback(
async (workingDirPath: string | null) => {
if (!activeConversationView) return;
setSavingWorkingDirectory(true);
try {
await saveConversationWorkingDirectoryOverride(
activeConversationView,
workingDirPath,
);
onCommit?.();
} finally {
setSavingWorkingDirectory(false);
}
},
[activeConversationView, onCommit],
);
const handleSelectAgent = useCallback(
async (choice: AgentChoice) => {
// Agent binding is immutable once a turn starts (1 thread = 1 binding).
Expand Down Expand Up @@ -1060,16 +1090,33 @@ export const ChatPanel = ({
slashLoading={slashLoading}
onSlashMenuIntent={refreshSlashCommands}
agentSelectorSlot={
<AgentSelector
currentBinding={agentBinding}
currentMode={mode}
profiles={acpProfiles}
editable={agentSelectorEditable}
onSelect={handleSelectAgent}
onRefreshProfiles={refreshAcpProfiles}
disabled={!isHistoryLoaded}
fallbackIcon={viewingQuestionAgentIcon}
/>
<div className="w-full min-w-0">
<div className="flex min-w-0 items-center">
<AgentSelector
currentBinding={agentBinding}
currentMode={mode}
profiles={acpProfiles}
editable={agentSelectorEditable}
onSelect={handleSelectAgent}
onRefreshProfiles={refreshAcpProfiles}
disabled={!isHistoryLoaded}
fallbackIcon={viewingQuestionAgentIcon}
/>
</div>
{showWorkingDirectoryOverride && (
<WorkingDirectoryOverride
profileWorkingDirPath={
selectedExternalProfile?.workingDirPath ?? ''
}
workingDirPath={workingDirectoryOverride}
editable={
preparationEditable && !!selectedExternalProfile
}
saving={savingWorkingDirectory}
onSave={handleSaveWorkingDirectory}
/>
)}
</div>
}
acpSelectorsSlot={
agentBinding.kind === 'external' ? (
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/i18n/resources/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,11 @@
}
},
"chat": {
"workingDirectoryOverride": "Working directory override",
"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",
"showEarlierTurns": "Show {{count}} earlier turns",
"loadingEarlierTurns": "Loading earlier turns…",
"backToBottom": "Back to bottom",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/i18n/resources/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,11 @@
}
},
"chat": {
"workingDirectoryOverride": "工作目录覆盖",
"workingDirectoryInherited": "继承 Profile 目录:{{path}}",
"workingDirectoryNodeOnly": "仅应用于此 Agent 节点。",
"workingDirectoryNodeOnlyLocked": "此执行已固定使用该节点专属覆盖。",
"workingDirectoryOverrideSaveFailed": "保存工作目录覆盖失败",
"showEarlierTurns": "显示更早的 {{count}} 个轮次",
"loadingEarlierTurns": "正在加载更早的轮次…",
"backToBottom": "回到底部",
Expand Down
Loading
Loading