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
31 changes: 21 additions & 10 deletions src/web-ui/src/flow_chat/components/ChatContextPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,13 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
onClose();
}, [onAddImage, onClose, onRetrySkills, onSelectContext, onSelectSkill, onSelectMcp, openSource]);

const handleItemClick = useCallback((selection: ContextPickerItem) => {
if (selection.kind === 'file' && selection.item.isDirectory && !isSearchMode) {
const handleItemClick = useCallback((selection: ContextPickerItem, event: React.MouseEvent<HTMLElement>) => {
// Browsing keeps its own affordance: the trailing chevron opens a directory,
// while a row click accepts the row exactly like Enter. Both input devices
// therefore select a directory without leaving the current level.
const clickedEnterAffordance = event.target instanceof Element
&& Boolean(event.target.closest('[data-openbitfun-part="indicator"]'));
if (selection.kind === 'file' && selection.item.isDirectory && !isSearchMode && clickedEnterAffordance) {
enterDirectory(selection.item);
return;
}
Expand Down Expand Up @@ -712,13 +717,13 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
}
case 'Enter':
case 'Tab': {
// Accepting a row always selects it, including a directory. Browsing a
// directory stays on the explicitly navigational keys (ArrowRight in,
// ArrowLeft out) so acceptance and navigation never share one key.
event.preventDefault();
event.stopPropagation();
const selected = displayItems[selectedIndex];
if (selected) {
if (event.key === 'Tab' && selected.kind !== 'source') handleSelect(selected);
else handleItemClick(selected);
}
if (selected) handleSelect(selected);
break;
}
case 'Escape':
Expand All @@ -727,7 +732,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
onClose();
break;
}
}, [canNavigateBack, displayItems, enterDirectory, goBack, handleItemClick, handleSelect, isOpen, isSearchMode, onClose, openSource, selectedIndex]);
}, [canNavigateBack, displayItems, enterDirectory, goBack, handleSelect, isOpen, isSearchMode, onClose, openSource, selectedIndex]);

useEffect(() => {
if (!isOpen) return;
Expand Down Expand Up @@ -893,6 +898,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
: 'chatInput.boostSkillsLoadFailed')
: session?.sessionName ?? skill?.name ?? mcp?.label ?? file?.name;
const skillDescription = skill?.description?.trim() || undefined;
const isEnterableDirectory = Boolean(file?.isDirectory) && !isSearchMode;
return (
<ListboxOption data-overflow-trigger
active={index === selectedIndex}
Expand All @@ -903,8 +909,13 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
data-openbitfun-context-kind={selection.kind === 'source' || selection.kind === 'action'
? selection.id
: selection.kind === 'mcp' ? `mcp-${selection.item.kind}` : selection.kind}
indicator={selection.kind === 'source' || (file?.isDirectory && !isSearchMode)
? <Icon name="chevron-right" size="lg" aria-hidden="true" />
indicator={selection.kind === 'source' || isEnterableDirectory
? <Icon
aria-hidden="true"
name="chevron-right"
size="lg"
title={isEnterableDirectory ? t('contextPicker.openDirectory') : undefined}
/>
: undefined}
leading={selection.kind === 'source'
? selection.id === 'mcp'
Expand Down Expand Up @@ -943,7 +954,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
?? (file?.referenceStableKey
? file.referenceDescription || file.path
: undefined)}
onClick={() => handleItemClick(selection)}
onClick={(event) => handleItemClick(selection, event)}
onContextMenu={(event) => {
event.preventDefault();
if (file?.isDirectory) enterDirectory(file);
Expand Down
121 changes: 120 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ const option = (kind: string) => document.querySelector<HTMLElement>(
`[data-openbitfun-context-kind="${kind}"]`,
);

/** Row click accepts a directory; this trailing region is what opens it. */
const optionEnterAffordance = (row: Element | null | undefined) => row
?.querySelector<HTMLElement>('[data-openbitfun-part="indicator"]');

describe('ChatContextPicker overlay', () => {
it('separates unavailable skill capabilities from scan errors in the picker', async () => {
const unsupported = {
Expand Down Expand Up @@ -355,7 +359,7 @@ describe('ChatContextPicker overlay', () => {
]);

await act(async () => {
item?.click();
optionEnterAffordance(item)?.click();
await Promise.resolve();
});

Expand All @@ -369,6 +373,121 @@ describe('ChatContextPicker overlay', () => {
expect(nestedItem?.querySelector('[data-openbitfun-part="metadata"]')).toBeNull();
});

it('selects a directory on Enter and keeps directory browsing on the arrow keys', async () => {
const onSelectContext = vi.fn();
const onClose = vi.fn();
vi.mocked(workspaceAPI.explorerGetChildren).mockResolvedValueOnce([
{
path: '/workspace/src',
name: 'src',
isDirectory: true,
},
]);

await act(async () => {
root.render(<Harness onSelectContext={onSelectContext} onClose={onClose} />);
await Promise.resolve();
});

await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}));
});

expect(onSelectContext).toHaveBeenCalledWith(expect.objectContaining({
type: 'directory',
directoryPath: '/workspace/src',
directoryName: 'src',
recursive: true,
}));
expect(onClose).toHaveBeenCalledOnce();
expect(workspaceAPI.explorerGetChildren).toHaveBeenCalledOnce();
expect(document.querySelector('[data-openbitfun-part="currentDirectoryPath"]')?.textContent)
.toBe('workspace');

vi.mocked(workspaceAPI.explorerGetChildren).mockResolvedValueOnce([
{
path: '/workspace/src/App.tsx',
name: 'App.tsx',
isDirectory: false,
},
]);

await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
}));
await Promise.resolve();
});

expect(workspaceAPI.explorerGetChildren).toHaveBeenLastCalledWith('workspace-id', '/workspace/src');
expect(document.querySelector('[data-openbitfun-part="currentDirectoryPath"]')?.textContent)
.toBe('workspace/src');
expect(document.querySelector<HTMLElement>('[data-openbitfun-part="option"]')?.textContent)
.toContain('App.tsx');
expect(onSelectContext).toHaveBeenCalledOnce();
});

it('selects a directory on a row click and enters it from the trailing chevron', async () => {
const onSelectContext = vi.fn();
const onClose = vi.fn();
vi.mocked(workspaceAPI.explorerGetChildren).mockResolvedValueOnce([
{
path: '/workspace/src',
name: 'src',
isDirectory: true,
},
]);

await act(async () => {
root.render(<Harness onSelectContext={onSelectContext} onClose={onClose} />);
await Promise.resolve();
});

const row = document.querySelector<HTMLElement>('[data-openbitfun-part="option"]');
const enterAffordance = optionEnterAffordance(row);
expect(enterAffordance?.querySelector('[data-openbitfun-name="chevron-right"]')?.getAttribute('title'))
.toBe('contextPicker.openDirectory');

await act(async () => {
row?.click();
await Promise.resolve();
});

expect(onSelectContext).toHaveBeenCalledWith(expect.objectContaining({
type: 'directory',
directoryPath: '/workspace/src',
directoryName: 'src',
}));
expect(onClose).toHaveBeenCalledOnce();
expect(workspaceAPI.explorerGetChildren).toHaveBeenCalledOnce();
expect(document.querySelector('[data-openbitfun-part="currentDirectoryPath"]')?.textContent)
.toBe('workspace');

vi.mocked(workspaceAPI.explorerGetChildren).mockResolvedValueOnce([
{
path: '/workspace/src/App.tsx',
name: 'App.tsx',
isDirectory: false,
},
]);

await act(async () => {
enterAffordance?.click();
await Promise.resolve();
});

expect(workspaceAPI.explorerGetChildren).toHaveBeenLastCalledWith('workspace-id', '/workspace/src');
expect(document.querySelector('[data-openbitfun-part="currentDirectoryPath"]')?.textContent)
.toBe('workspace/src');
expect(onSelectContext).toHaveBeenCalledOnce();
});

it('enters the Skill source and returns the selected Skill', async () => {
const skill = {
key: 'pdf-skill',
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@
"emptyDirectory": "Empty directory",
"rootDirectory": "Root",
"goBack": "Go back",
"openDirectory": "Open folder",
"navHint": "Navigate",
"enterHint": "Enter",
"backHint": "Back",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@
"emptyDirectory": "空目录",
"rootDirectory": "根目录",
"goBack": "返回上一级",
"openDirectory": "进入目录",
"navHint": "导航",
"enterHint": "进入",
"backHint": "返回",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@
"emptyDirectory": "空目錄",
"rootDirectory": "根目錄",
"goBack": "返回上一級",
"openDirectory": "進入目錄",
"navHint": "導航",
"enterHint": "進入",
"backHint": "返回",
Expand Down
Loading