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
3 changes: 1 addition & 2 deletions src/web-ui/src/features/dispatch/DispatchTargetPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,7 @@
width: 18px;
padding: 0;

> span,
> .dispatch-target-picker__chevron {
> span {
display: none;
}
}
Expand Down
77 changes: 77 additions & 0 deletions src/web-ui/src/features/dispatch/DispatchTargetPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('DispatchTargetPicker overlay', () => {
const trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="chat-input-dispatch-trigger"]',
);
expect(trigger?.querySelectorAll('svg')).toHaveLength(1);
await act(async () => trigger?.click());

const menu = document.querySelector<HTMLElement>('[data-testid="dispatch-target-menu"]');
Expand All @@ -105,4 +106,80 @@ describe('DispatchTargetPicker overlay', () => {
expect(menu?.style.left).toBe('240px');
expect(menu?.style.top).toBe('293px');
});

it('offers New Worktree inside the local target and reflects the selected local mode', async () => {
const onSelectLocal = vi.fn();
const onWorktreeChange = vi.fn();
const localWorktreeControl = {
enabled: false,
locked: false,
label: 'New Worktree',
description: 'Run in an isolated worktree.',
onChange: onWorktreeChange,
};

await act(async () => {
root.render(
<DispatchTargetPicker
target={{ kind: 'local' }}
locked={false}
localWorktreeControl={localWorktreeControl}
onSelectLocal={onSelectLocal}
onSelectTarget={vi.fn()}
/>,
);
});

let trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="chat-input-dispatch-trigger"]',
);
expect(trigger?.textContent).toBe('chatInput.dispatch.local');
expect(trigger?.querySelectorAll('svg')).toHaveLength(1);

await act(async () => trigger?.click());

const localOption = document.querySelector<HTMLButtonElement>(
'[data-testid="dispatch-target-local-option"]',
);
const worktreeOption = document.querySelector<HTMLButtonElement>(
'[data-testid="dispatch-target-new-worktree-option"]',
);
expect(localOption?.getAttribute('aria-checked')).toBe('true');
expect(worktreeOption?.textContent).toContain('New Worktree');
expect(worktreeOption?.getAttribute('aria-checked')).toBe('false');

await act(async () => worktreeOption?.click());
expect(onSelectLocal).toHaveBeenCalledTimes(1);
expect(onWorktreeChange).toHaveBeenLastCalledWith(true);
expect(document.querySelector('[data-testid="dispatch-target-menu"]')).toBeNull();

await act(async () => {
root.render(
<DispatchTargetPicker
target={{ kind: 'local' }}
locked={false}
localWorktreeControl={{ ...localWorktreeControl, enabled: true }}
onSelectLocal={onSelectLocal}
onSelectTarget={vi.fn()}
/>,
);
});

trigger = container.querySelector<HTMLButtonElement>(
'[data-testid="chat-input-dispatch-trigger"]',
);
expect(trigger?.textContent).toBe('New Worktree');
expect(trigger?.querySelectorAll('svg')).toHaveLength(1);

await act(async () => trigger?.click());
expect(document.querySelector(
'[data-testid="dispatch-target-new-worktree-option"]',
)?.getAttribute('aria-checked')).toBe('true');

await act(async () => document.querySelector<HTMLButtonElement>(
'[data-testid="dispatch-target-local-option"]',
)?.click());
expect(onSelectLocal).toHaveBeenCalledTimes(2);
expect(onWorktreeChange).toHaveBeenLastCalledWith(false);
});
});
68 changes: 57 additions & 11 deletions src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
} from 'react';
import { createPortal } from 'react-dom';
import {
FolderGit2,
Laptop,
Loader2,
MonitorSmartphone,
Expand Down Expand Up @@ -36,6 +37,13 @@ interface DispatchTargetPickerProps {
sourceWorkspacePath?: string;
locked: boolean;
disabled?: boolean;
localWorktreeControl?: {
enabled: boolean;
locked: boolean;
label: string;
description: string;
onChange: (enabled: boolean) => void;
};
onSelectLocal?: () => void;
onSelectTarget: (selection: DispatchSelection) => void;
}
Expand All @@ -49,6 +57,7 @@ export const DispatchTargetPicker: React.FC<DispatchTargetPickerProps> = ({
sourceWorkspacePath,
locked,
disabled = false,
localWorktreeControl,
onSelectLocal,
onSelectTarget,
}) => {
Expand All @@ -72,8 +81,11 @@ export const DispatchTargetPicker: React.FC<DispatchTargetPickerProps> = ({
layoutRevision: `${targets.length}:${loading}:${error ?? ''}`,
});

const localDisplayLabel = localWorktreeControl?.enabled
? localWorktreeControl.label
: t('chatInput.dispatch.local');
const displayLabel = target.kind === 'local'
? t('chatInput.dispatch.local')
? localDisplayLabel
: target.displayName;
const tooltip = locked
? t('chatInput.dispatch.locked', { target: displayLabel })
Expand Down Expand Up @@ -116,6 +128,22 @@ export const DispatchTargetPicker: React.FC<DispatchTargetPickerProps> = ({
[targets],
);

const selectLocalMode = (worktreeEnabled: boolean) => {
setOpen(false);
onSelectLocal?.();
if (
localWorktreeControl
&& localWorktreeControl.enabled !== worktreeEnabled
) {
localWorktreeControl.onChange(worktreeEnabled);
}
};

const localDirectorySelected =
target.kind === 'local' && !localWorktreeControl?.enabled;
const localWorktreeSelected =
target.kind === 'local' && !!localWorktreeControl?.enabled;

const menu = open ? (
<ScrollArea
ref={menuRef}
Expand Down Expand Up @@ -143,22 +171,41 @@ export const DispatchTargetPicker: React.FC<DispatchTargetPickerProps> = ({
<button
type="button"
role="menuitemradio"
aria-checked={target.kind === 'local'}
aria-checked={localDirectorySelected}
className="dispatch-target-picker__option"
data-bf-component="dispatch-target-picker"
data-bf-part="option"
onClick={() => {
setOpen(false);
onSelectLocal?.();
}}
data-testid="dispatch-target-local-option"
disabled={localWorktreeControl?.locked}
onClick={() => selectLocalMode(false)}
>
<Laptop size={15} aria-hidden />
<span>
<strong>{t('chatInput.dispatch.local')}</strong>
<small>{t('chatInput.dispatch.localDescription')}</small>
</span>
{target.kind === 'local' ? <Icon name="check-line" size="sm" aria-hidden /> : null}
{localDirectorySelected ? <Icon name="check-line" size="sm" aria-hidden /> : null}
</button>
{localWorktreeControl ? (
<button
type="button"
role="menuitemradio"
aria-checked={localWorktreeSelected}
className="dispatch-target-picker__option"
data-bf-component="dispatch-target-picker"
data-bf-part="option"
data-testid="dispatch-target-new-worktree-option"
disabled={localWorktreeControl.locked}
onClick={() => selectLocalMode(true)}
>
<FolderGit2 size={15} aria-hidden />
<span>
<strong>{localWorktreeControl.label}</strong>
<small>{localWorktreeControl.description}</small>
</span>
{localWorktreeSelected ? <Icon name="check-line" size="sm" aria-hidden /> : null}
</button>
) : null}
</div>

<div className="dispatch-target-picker__divider" role="separator" />
Expand Down Expand Up @@ -312,14 +359,13 @@ export const DispatchTargetPicker: React.FC<DispatchTargetPickerProps> = ({
}}
>
{target.kind === 'local'
? <Laptop size={12} />
? localWorktreeControl?.enabled
? <FolderGit2 size={12} />
: <Laptop size={12} />
: target.kind === 'device'
? <MonitorSmartphone size={12} />
: <Server size={12} />}
<span>{displayLabel}</span>
{!locked ? (
<Icon name="chevron-down" size="2xs" className="dispatch-target-picker__chevron" aria-hidden />
) : null}
</button>
</Tooltip>
{menu && createPortal(menu, getAppearanceOverlayHost())}
Expand Down
19 changes: 2 additions & 17 deletions src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,8 @@ $track-item-gap: 10px;
opacity: 0.55;
}

// One glyph size for the whole track, enforced here so a component that
// ships its own `size` prop (the dispatch picker's chevron) still lands on
// it without the strip reaching into that component.
// One glyph size for the whole track, enforced here so component-owned
// icons land on the same rhythm without the strip reaching into them.
> svg {
flex: none;
width: 12px;
Expand Down Expand Up @@ -139,15 +138,6 @@ $track-item-gap: 10px;
padding-bottom: 1px;
text-overflow: ellipsis;
}

// The chevron is punctuation, not one of the track's semantic glyphs.
// At the shared 12px it outweighed the mark that says which host this
// is, which is the thing worth reading.
.dispatch-target-picker__chevron {
width: 10px;
height: 10px;
opacity: 0.55;
}
}
}

Expand Down Expand Up @@ -688,11 +678,6 @@ $track-item-gap: 10px;
padding-bottom: 1px;
}

&__workspace-chevron {
flex: none;
opacity: 0.55;
}

// Only the interactive form answers a click — the hover fill is the whole
// difference between a fact and a control here.
&__workspace--switchable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ vi.mock('react-i18next', () => ({
'deepReviewConsent.strategyLabels.normal': 'Standard',
'reasoningSelector.auto': 'Auto',
'chatInput.permissionMode.ask.label': 'Ask',
'strip.newWorktree': 'New Worktree',
} as Record<string, string>)[key] ?? options?.defaultValue ?? key,
}),
}));
Expand Down Expand Up @@ -67,10 +68,38 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({
}));

// The real picker pulls in account state, SSH dialogs and a lazy remote-connect
// route. This suite only asserts whether the strip mounts it at all.
// route. This suite observes the strip-to-picker contract through a lightweight
// stand-in; picker behavior itself stays covered in its focused suite.
vi.mock('@/features/dispatch/DispatchTargetPicker', () => ({
DispatchTargetPicker: ({ locked }: { locked: boolean }) => (
<div data-testid="chat-input-dispatch-trigger" data-locked={locked ? 'true' : 'false'} />
DispatchTargetPicker: ({
locked,
localWorktreeControl,
}: {
locked: boolean;
localWorktreeControl?: {
enabled: boolean;
locked: boolean;
label: string;
onChange: (enabled: boolean) => void;
};
}) => (
<div
data-testid="chat-input-dispatch-trigger"
data-locked={locked ? 'true' : 'false'}
data-worktree-enabled={localWorktreeControl?.enabled ? 'true' : 'false'}
data-worktree-label={localWorktreeControl?.label}
>
{localWorktreeControl ? (
<button
type="button"
data-testid="dispatch-target-new-worktree-option"
disabled={localWorktreeControl.locked}
onClick={() => localWorktreeControl.onChange(true)}
>
{localWorktreeControl.label}
</button>
) : null}
</div>
),
}));

Expand Down Expand Up @@ -881,13 +910,14 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {
expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull();
});

it('shows the dispatch picker and the worktree toggle together in a Git workspace', async () => {
it('orders workspace and branch before the target and nests worktree under local', async () => {
const onChange = vi.fn();
await act(async () => {
root.render(
<ChatInputWorkspaceStrip
repositoryPath="/repo"
workspaceLabel="repo"
worktreeControl={{ enabled: false, locked: false, onChange: vi.fn() }}
worktreeControl={{ enabled: false, locked: false, onChange }}
dispatchControl={{
target: { kind: 'local' },
locked: false,
Expand All @@ -897,8 +927,24 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {
);
});

expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).not.toBeNull();
expect(container.querySelector('[data-testid="chat-input-dispatch-trigger"]')).not.toBeNull();
const context = container.querySelector<HTMLElement>('[data-bf-part="context"]');
const location = context?.querySelector('.bitfun-chat-input-workspace-strip__location');
const dispatchTrigger = context?.querySelector<HTMLElement>(
'[data-testid="chat-input-dispatch-trigger"]',
);
expect(context).not.toBeNull();
expect(location).not.toBeNull();
expect(dispatchTrigger).not.toBeNull();
expect(Array.from(context?.children ?? []).indexOf(location as Element))
.toBeLessThan(Array.from(context?.children ?? []).indexOf(dispatchTrigger as Element));
expect(container.querySelector('[data-testid="chat-input-worktree-toggle"]')).toBeNull();
expect(dispatchTrigger?.dataset.worktreeEnabled).toBe('false');
expect(dispatchTrigger?.dataset.worktreeLabel).toBe('New Worktree');

await act(async () => container.querySelector<HTMLButtonElement>(
'[data-testid="dispatch-target-new-worktree-option"]',
)?.click());
expect(onChange).toHaveBeenCalledWith(true);
});

it('shows the dispatched branch instead of the source branch once dispatch is locked', async () => {
Expand Down
Loading
Loading