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
13 changes: 13 additions & 0 deletions src/web-ui/src/features/dispatch/DispatchTargetPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@

&__option-row {
min-height: var(--bf-control-height-md);

// The two local execution modes keep matching title ink regardless of
// which row is selected. Every option detail shares the quieter treatment
// declared on `small` below.
&[data-bf-part='option'] {
.dispatch-target-picker__option-copy {
strong {
color: var(--bf-color-content-secondary);
opacity: var(--bf-opacity-focus);
}
}
}
}

&__option-copy {
Expand All @@ -99,6 +111,7 @@

small {
color: var(--bf-color-content-muted);
opacity: 0.5;
font-family: var(--bf-type-meta-font-family);
font-size: var(--bf-type-meta-font-size);
font-weight: var(--bf-type-meta-font-weight);
Expand Down
6 changes: 0 additions & 6 deletions src/web-ui/src/features/dispatch/DispatchTargetPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,6 @@ export const DispatchTargetPicker: React.FC<DispatchTargetPickerProps> = ({
data-testid="dispatch-target-menu"
autoFocusFirstItem
>
<MenuSection title={t('chatInput.dispatch.menuLabel')}>
<div className="dispatch-target-picker__status">
{t('chatInput.dispatch.sessionScope')}
</div>
</MenuSection>
<MenuSeparator />
<MenuSection title={t('chatInput.dispatch.localSection')}>
<MenuItem
role="menuitemradio"
Expand Down
28 changes: 28 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,34 @@ $track-item-gap: 10px;
z-index: var(--bf-layer-popover);
}

&__workspace-option-copy {
display: flex;
overflow: hidden;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 0;
padding-block: calc(var(--bf-space-1) / 2);
}

&__workspace-option-name,
&__workspace-option-detail {
overflow: hidden;
min-width: 0;
text-overflow: ellipsis;
white-space: nowrap;
}

&__workspace-option-detail {
color: var(--bf-color-content-muted);
opacity: 0.5;
font-family: var(--bf-type-meta-font-family);
font-size: var(--bf-type-meta-font-size);
font-weight: var(--bf-type-meta-font-weight);
line-height: var(--bf-type-meta-line-height);
letter-spacing: var(--bf-type-meta-letter-spacing);
}

}

// Degradation is ordered by what a reader loses: prose first, then labels, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ vi.mock('react-i18next', () => ({
'reasoningSelector.auto': 'Auto',
'chatInput.permissionMode.ask.label': 'Ask',
'strip.newWorktree': 'New Worktree',
'workspaceStrip.primaryAssistant': 'Primary assistant',
'workspaceStrip.personalAssistant': 'Personal assistant',
} as Record<string, string>)[key] ?? options?.defaultValue ?? key,
}),
}));
Expand Down Expand Up @@ -243,10 +245,39 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {
it('switches the active workspace from the strip menu when several are open', async () => {
mocks.useOptionalWorkspaceContext.mockReturnValue({
openedWorkspacesList: [
{ id: 'ws-1', name: 'BitFun', path: 'D:/workspace/BitFun' },
{ id: 'ws-2', name: 'Other', path: 'D:/workspace/Other' },
{
id: 'ws-1',
name: 'BitFun',
rootPath: 'D:/workspace/BitFun',
workspaceKind: 'normal',
},
{
id: 'ws-2',
name: 'Other',
rootPath: 'D:/workspace/Other',
workspaceKind: 'normal',
},
{
id: 'ws-3',
name: 'Primary',
rootPath: 'D:/internal/assistants/ws-3',
workspaceKind: 'assistant',
},
{
id: 'ws-4',
name: 'Personal',
rootPath: 'D:/internal/assistants/ws-4',
workspaceKind: 'assistant',
assistantId: 'assistant-4',
},
],
activeWorkspace: { id: 'ws-1', name: 'BitFun', path: 'D:/workspace/BitFun' },
activeWorkspace: {
id: 'ws-1',
name: 'BitFun',
rootPath: 'D:/workspace/BitFun',
workspaceKind: 'normal',
},
primaryAssistantWorkspaceId: 'ws-3',
setActiveWorkspace: mocks.setActiveWorkspace,
});

Expand Down Expand Up @@ -275,6 +306,19 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => {
expect(
menu?.querySelector('[data-testid="chat-input-workspace-option-ws-1"]')?.getAttribute('aria-checked'),
).toBe('true');
expect(
menu?.querySelector('[data-testid="chat-input-workspace-option-ws-1"]')?.textContent,
).toContain('D:/workspace/BitFun');
expect(
menu?.querySelector('[data-testid="chat-input-workspace-option-ws-2"]')?.textContent,
).toContain('D:/workspace/Other');
expect(
menu?.querySelector('[data-testid="chat-input-workspace-option-ws-3"]')?.textContent,
).toContain('Primary assistant');
expect(
menu?.querySelector('[data-testid="chat-input-workspace-option-ws-4"]')?.textContent,
).toContain('Personal assistant');
expect(menu?.textContent).not.toContain('D:/internal/assistants/');

const other = menu?.querySelector<HTMLButtonElement>(
'[data-testid="chat-input-workspace-option-ws-2"]',
Expand Down
31 changes: 30 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
useOptionalWorkspaceContext,
} from '@/infrastructure/contexts/WorkspaceContext';
import { useI18n } from '@/infrastructure/i18n';
import { WorkspaceKind } from '@/shared/types';
import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition';
import { DispatchResultDialog } from '@/features/dispatch/DispatchResultDialog';
import { DispatchTargetPicker } from '@/features/dispatch/DispatchTargetPicker';
Expand Down Expand Up @@ -597,6 +598,21 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
>
{switchableWorkspaces.map(workspace => {
const isActive = workspace.id === workspaceContext.activeWorkspace?.id;
const workspaceName = getWorkspaceDisplayName(workspace);
const workspacePath = workspace.rootPath?.trim();
const isAssistantWorkspace = workspace.workspaceKind === WorkspaceKind.Assistant;
const isPrimaryAssistantWorkspace = (
isAssistantWorkspace
&& (
workspace.id === workspaceContext.primaryAssistantWorkspaceId
|| (!workspaceContext.primaryAssistantWorkspaceId && !workspace.assistantId)
)
);
const workspaceDetail = isAssistantWorkspace
? t(isPrimaryAssistantWorkspace
? 'workspaceStrip.primaryAssistant'
: 'workspaceStrip.personalAssistant')
: workspacePath;
return (
<MenuItem
key={workspace.id}
Expand All @@ -606,6 +622,10 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
data-bf-part="workspaceOption"
data-bf-state={isActive ? 'active' : undefined}
data-testid={`chat-input-workspace-option-${workspace.id}`}
aria-label={workspaceDetail
? `${workspaceName}, ${workspaceDetail}`
: workspaceName}
title={workspaceDetail || workspaceName}
metadata={isActive ? <Icon name="check-line" size="lg" style={{ width: 13, height: 13 }} aria-hidden /> : null}
onClick={event => {
event.stopPropagation();
Expand All @@ -615,7 +635,16 @@ export const ChatInputWorkspaceStrip: React.FC<ChatInputWorkspaceStripProps> = (
}
}}
>
{getWorkspaceDisplayName(workspace)}
<span className="bitfun-chat-input-workspace-strip__workspace-option-copy">
<span className="bitfun-chat-input-workspace-strip__workspace-option-name">
{workspaceName}
</span>
{workspaceDetail ? (
<span className="bitfun-chat-input-workspace-strip__workspace-option-detail">
{workspaceDetail}
</span>
) : null}
</span>
</MenuItem>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ describe('composer context track layout', () => {
expect(branchPicker).toMatch(
/branch-quick-switch__list \[data-bf-part='list'\] \{\n gap: calc\(var\(--bf-space-1\) \/ 2\);/,
);
expect(targetPicker).toContain(
'&__option-row {\n min-height: var(--bf-control-height-md);\n }',
expect(targetPicker).toMatch(
/&__option-row \{[\s\S]*?min-height: var\(--bf-control-height-md\);/,
);
expect(targetPicker).toMatch(
/&__menu \{[\s\S]*?--bf-overlay-menu-section-gap: var\(--bf-space-1\);/,
Expand All @@ -88,6 +88,12 @@ describe('composer context track layout', () => {
expect(targetPicker).toMatch(
/small \{[\s\S]*?font-size: var\(--bf-type-meta-font-size\);/,
);
expect(targetPicker).toMatch(
/\&\[data-bf-part='option'\] \{[\s\S]*?strong \{[\s\S]*?color: var\(--bf-color-content-secondary\);[\s\S]*?opacity: var\(--bf-opacity-focus\);/,
);
expect(targetPicker).toMatch(
/small \{[\s\S]*?color: var\(--bf-color-content-muted\);[\s\S]*?opacity: 0\.5;/,
);
});

it('keeps passive context aligned and promotes consequential controls', () => {
Expand Down Expand Up @@ -236,6 +242,23 @@ describe('composer context track layout', () => {
expect(component).toContain('data-testid="chat-input-workspace-trigger"');
expect(component).toContain('data-testid="chat-input-workspace-menu"');
expect(component).toContain('data-bf-part="workspaceOption"');
// Every entry carries one compact secondary detail line: repositories use
// their real path, while assistants expose their product role instead of
// leaking the internal assistant workspace directory.
expect(component).toContain('workspace.rootPath?.trim()');
expect(component).toContain('workspaceContext.primaryAssistantWorkspaceId');
expect(component).toContain("'workspaceStrip.primaryAssistant'");
expect(component).toContain("'workspaceStrip.personalAssistant'");
expect(component).toContain('__workspace-option-detail');
expect(stylesheet).toMatch(
/&__workspace-option-copy \{[\s\S]*?padding-block: calc\(var\(--bf-space-1\) \/ 2\);/,
);
expect(stylesheet).toMatch(
/&__workspace-option-detail \{[\s\S]*?color: var\(--bf-color-content-muted\);/,
);
expect(stylesheet).toMatch(
/&__workspace-option-detail \{[\s\S]*?opacity: 0\.5;/,
);
// Segments part on a hairline rule, never on a slash: a slash claimed a
// path that a host, a workspace and a branch do not form.
expect(component).toContain('__divider');
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,9 @@
"workspaceStrip": {
"branchSwitchLabel": "Switch branch. Current branch: {{branch}}",
"branchTooltipUnavailable": "Not a git repository or no current branch",
"branchTooltipUntrusted": "Git will not read this repository because the folder is owned by another user. Trust the folder to see the branch."
"branchTooltipUntrusted": "Git will not read this repository because the folder is owned by another user. Trust the folder to see the branch.",
"primaryAssistant": "Primary assistant",
"personalAssistant": "Personal assistant"
},
"context": {
"title": "Context",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,9 @@
"workspaceStrip": {
"branchSwitchLabel": "切换分支。当前分支:{{branch}}",
"branchTooltipUnavailable": "非 Git 仓库或当前无分支",
"branchTooltipUntrusted": "该目录属于其他用户,Git 拒绝读取此仓库。信任该目录后即可看到分支。"
"branchTooltipUntrusted": "该目录属于其他用户,Git 拒绝读取此仓库。信任该目录后即可看到分支。",
"primaryAssistant": "主助理",
"personalAssistant": "个人助理"
},
"context": {
"title": "上下文",
Expand Down
4 changes: 3 additions & 1 deletion src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,9 @@
"workspaceStrip": {
"branchSwitchLabel": "切換分支。目前分支:{{branch}}",
"branchTooltipUnavailable": "非 Git 存放庫或目前無分支",
"branchTooltipUntrusted": "該目錄屬於其他使用者,Git 拒絕讀取此存放庫。信任該目錄後即可看到分支。"
"branchTooltipUntrusted": "該目錄屬於其他使用者,Git 拒絕讀取此存放庫。信任該目錄後即可看到分支。",
"primaryAssistant": "主助理",
"personalAssistant": "個人助理"
},
"context": {
"title": "上下文",
Expand Down
16 changes: 16 additions & 0 deletions src/web-ui/src/tools/git/components/BranchQuickSwitch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ describe('BranchQuickSwitch', () => {
expect(input?.classList.contains('branch-quick-switch__input')).toBe(false);
});

it('keeps the selected current branch readable instead of applying disabled colors', async () => {
await act(async () => {
root.render(<Harness onSwitchSuccess={vi.fn()} />);
});
await vi.waitFor(() => expect(
document.querySelector('[data-testid="branch-quick-switch-option-main"]'),
).not.toBeNull());

const currentBranch = document.querySelector<HTMLButtonElement>(
'[data-testid="branch-quick-switch-option-main"]',
);
expect(currentBranch?.getAttribute('aria-selected')).toBe('true');
expect(currentBranch?.dataset.bfState).toBe('current');
expect(currentBranch?.disabled).toBe(false);
});

it('checks out a selected branch and publishes the shared branch-change event', async () => {
const onSwitchSuccess = vi.fn();
mocks.checkoutBranch.mockResolvedValue({ success: true });
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/tools/git/components/BranchQuickSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,9 @@ export const BranchQuickSwitch: React.FC<BranchQuickSwitchProps> = ({
active={index === selectedIndex}
className="branch-quick-switch__item"
data-index={index}
data-bf-state={branch.current ? 'current' : undefined}
data-testid={`branch-quick-switch-option-${branch.name}`}
disabled={branch.current || isSwitching}
disabled={isSwitching}
indicator={switchingBranch === branch.name
? <Loader2 size={14} className="branch-quick-switch__spinner" aria-hidden />
: undefined}
Expand Down
Loading