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
18 changes: 10 additions & 8 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,14 +394,16 @@ continues to own selection, keyboard navigation, label overflow, and end-action
anatomy. Product wrappers own document states and layout, without replacing the
tab's control styles or creating another tablist.

`TabGroupItem.endAction` reserves space inside the tab surface. On pointer devices,
the icon, label, and non-interactive suffix are centered together at rest, then
shift toward the leading edge as the action fades in on hover. The tab and action
hit areas keep fixed positions and widths. Keyboard focus reveals the action and
moves the content immediately; touch keeps the action visible and the content
clear of it. Reduced motion disables both transitions. Tabs without an end action
keep their ordinary centered layout. Label overflow and rolling-text replacement
remain inside the label slot, independent of this movement.
`TabGroupItem.endAction` keeps the resting pointer-device capsule compact, with
symmetrical padding around its centered content. Hover grows the capsule at its
inline end, then fades in the action after expansion finishes. The icon, label
slot, and non-interactive suffix retain their positions and widths; following
tabs make room for the growing capsule. Keyboard focus expands and reveals the
action immediately; touch keeps it visible. Reduced motion makes both steps
immediate. Constrain the item's content width rather than a product wrapper's
outer width so the action can grow beyond a capped label. Tabs without an end
action keep their ordinary centered layout. Label overflow and rolling-text
replacement remain inside the label slot, independent of action visibility.

## Mobile controls

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types";

export const tabGroupMeta = {
category: "navigation",
description: "A compact single-selection tab list with leading icons, a fixed internal slot for glass end actions, and automatic keyboard activation. Content is centered at rest and shifts aside when an action appears without resizing the tab.",
description: "A compact single-selection tab list with leading icons, glass end actions, and automatic keyboard activation. Content stays centered at rest; hover grows the capsule at its inline end before revealing the action, while the content stays in place.",
maturity: "stable",
name: "TabGroup",
props: [
Expand Down
115 changes: 85 additions & 30 deletions design-system/packages/ui/src/components/TabGroup/TabGroup.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
--_tab-group-item-padding-inline: var(--openbitfun-control-tab-group-item-padding-inline);
--_tab-group-item-gap: var(--openbitfun-control-tab-group-item-gap);
--_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size);
--_tab-group-inline-direction: 1;
--_tab-group-action-padding: max(
var(--_tab-group-item-padding-inline),
calc((var(--_tab-group-item-height) + var(--openbitfun-control-tab-group-item-action-size)) / 2 + var(--_tab-group-item-gap))
Expand All @@ -24,11 +23,11 @@
--_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size-sm);
}

.tabGroup:dir(rtl) { --_tab-group-inline-direction: -1; }

.item {
--_floating-action-host-height: var(--_tab-group-item-height);
--_floating-action-size: var(--openbitfun-control-tab-group-item-action-size);
box-sizing: content-box;
isolation: isolate;
position: relative;
display: inline-flex;
flex: 0 0 auto;
Expand All @@ -51,21 +50,35 @@
max-inline-size: 100%;
padding-block: var(--_tab-group-item-padding-block);
padding-inline: var(--_tab-group-item-padding-inline);
border: var(--openbitfun-border-width-default) solid var(--_tab-border);
border: var(--openbitfun-border-width-default) solid transparent;
border-radius: var(--openbitfun-control-tab-group-item-radius);
color: var(--_tab-content);
background: var(--_tab-background);
background: transparent;
outline: none;
font-family: var(--openbitfun-type-label-md-font-family);
font-size: var(--openbitfun-type-label-md-font-size);
font-weight: var(--openbitfun-type-label-md-font-weight);
letter-spacing: var(--openbitfun-type-label-md-letter-spacing);
line-height: var(--openbitfun-type-label-md-line-height);
white-space: nowrap;
cursor: pointer;
transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
}

/* The surface follows the item's growing action area; the tab content stays put. */
.tab::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
box-sizing: border-box;
border: var(--openbitfun-border-width-default) solid var(--_tab-border);
border-radius: inherit;
background: var(--_tab-background);
pointer-events: none;
transition:
background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard),
border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard),
color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
}

.tab[aria-selected="true"] {
Expand All @@ -76,21 +89,25 @@
font-weight: var(--openbitfun-type-label-selected-font-weight);
}

.tab:hover:not(:disabled),
.item:hover .tab:not(:disabled),
.tabGroup[data-openbitfun-preview-state="hover"] .tab[aria-selected="false"]:not(:disabled) {
.tab:hover:not(:disabled)::before,
.item:hover .tab:not(:disabled)::before,
.tabGroup[data-openbitfun-preview-state="hover"] .tab[aria-selected="false"]:not(:disabled)::before {
background: var(--_tab-background-hover);
border-color: var(--openbitfun-component-button-outline-border-interactive);
}

.tab:active:not(:disabled),
.tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled) {
.tab:active:not(:disabled)::before,
.tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled)::before {
background: var(--_tab-background-active);
border-color: var(--openbitfun-component-button-outline-border-interactive);
}

.tab:active:not(:disabled),
.tabGroup[data-openbitfun-preview-state="active"] .tab[aria-selected="false"]:not(:disabled) {
font-weight: var(--openbitfun-type-label-selected-font-weight);
}

.tab:focus-visible {
.tab:focus-visible::before {
outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring);
outline-offset: var(--openbitfun-focus-offset);
}
Expand All @@ -100,27 +117,61 @@
cursor: not-allowed;
}

/* The action owns a fixed slot inside the tab; hover never resizes either hit area. */
.item[data-has-end-action="true"] > .tab {
padding-inline-end: var(--_tab-group-action-padding);
/* Touch keeps the action exposed; desktop opens this area only on interaction.
Content-box padding lets capped labels keep their width while the pill grows. */
.item[data-has-end-action="true"] {
padding-inline-end: calc(var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline));
}

@media (hover: hover) and (pointer: fine) {
.item[data-has-end-action="true"] > .tab > :is(.icon, .label, .labelSuffix) {
transform: translateX(calc(
(var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline)) / 2
* var(--_tab-group-inline-direction)
));
transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
.item[data-has-end-action="true"] {
padding-inline-end: 0;
transition: padding-inline-end var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
}

.item[data-has-end-action="true"]:is(:hover, :focus-visible, :has(:focus-visible)),
.tabGroup:is([data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) .item[data-has-end-action="true"] {
padding-inline-end: calc(var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline));
}

.endAction {
visibility: hidden;
transition: visibility 0s;
}

.item:hover > .endAction,
.tabGroup[data-openbitfun-preview-state="hover"] .endAction {
visibility: visible;
transition-delay: var(--openbitfun-motion-duration-fast);
}

.item:hover > .endAction > button,
.tabGroup[data-openbitfun-preview-state="hover"] .endAction > button {
transition-delay: var(--openbitfun-motion-duration-fast), 0s, 0s;
}

.item[data-has-end-action="true"]:is(:hover, :focus-visible, :has(:focus-visible)) > .tab > :is(.icon, .label, .labelSuffix) {
transform: none;
.tabGroup:is([data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) .endAction {
pointer-events: auto;
}

.tabGroup:is([data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) .endAction > button {
opacity: 1;
}
}

.item[data-has-end-action="true"]:is(:focus-visible, :has(:focus-visible)) > .tab > :is(.icon, .label, .labelSuffix),
.item:is(:focus-visible, :has(:focus-visible)) > .endAction > button {
.item:is(:focus-visible, :has(:focus-visible)),
.tabGroup[data-openbitfun-preview-state="focus-visible"] .item {
transition: none;
}

.item:is(:focus-visible, :has(:focus-visible)) > .endAction,
.tabGroup[data-openbitfun-preview-state="focus-visible"] .endAction {
visibility: visible;
transition: none;
}

.item:is(:focus-visible, :has(:focus-visible)) > .endAction > button,
.tabGroup[data-openbitfun-preview-state="focus-visible"] .endAction > button {
transition: none;
}

Expand Down Expand Up @@ -194,26 +245,30 @@
}

@media (prefers-reduced-motion: reduce) {
.item[data-has-end-action="true"],
.tab,
.item[data-has-end-action="true"] > .tab > :is(.icon, .label, .labelSuffix),
.tab::before,
.endAction,
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])) {
transition: none;
}
}

@media (forced-colors: active) {
.tab {
.tab::before {
border-color: ButtonText;
}

.tab[aria-selected="true"] {
border-color: Highlight;
color: HighlightText;
}

.tab[aria-selected="true"]::before {
border-color: Highlight;
background: Highlight;
}

.tab:focus-visible {
.tab:focus-visible::before {
outline-color: Highlight;
}

Expand Down
14 changes: 8 additions & 6 deletions design-system/packages/ui/tests/tab-group.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,16 @@ test("end actions are rendered beside tabs instead of nesting interactive contro
assert.equal((markup.match(/type="button"/g) ?? []).length, 3);
});

test("end actions reserve a fixed slot and move content without resizing hit areas", async () => {
test("end actions grow the capsule outside the centered content before appearing", async () => {
const styles = await readFile(new URL("../src/components/TabGroup/TabGroup.module.css", import.meta.url), "utf8");
assert.match(styles, /\.item\[data-has-end-action="true"\] > \.tab\s*\{\s*padding-inline-end:/);
assert.match(styles, /\.item\s*\{[^}]*box-sizing:\s*content-box;/s);
assert.match(styles, /\.tab\s*\{[^}]*padding-inline:\s*var\(--_tab-group-item-padding-inline\);/s);
assert.match(styles, /\.tab::before\s*\{[^}]*inset:\s*0;/s);
assert.match(styles, /\.item\[data-has-end-action="true"\]\s*\{\s*padding-inline-end:\s*0;/);
assert.match(styles, /transition:\s*padding-inline-end\s+var\(--openbitfun-motion-duration-fast\)/);
assert.match(styles, /\.item:hover > \.endAction,[^{]*\{[^}]*visibility:\s*visible;[^}]*transition-delay:\s*var\(--openbitfun-motion-duration-fast\);/s);
assert.doesNotMatch(styles, /clip-path|mask-image/);
const hoverRules = [...styles.matchAll(/([^{}]*:hover[^{}]*)\{([^{}]*)\}/g)];
for (const [, , declarations] of hoverRules) {
assert.doesNotMatch(declarations, /(?:padding|margin|(?:inline|block)-size|width|height)[\w-]*\s*:/);
}
assert.doesNotMatch(styles, /translateX/);
});

test("TabGroup styling uses its geometry contract and Button semantic colors", async () => {
Expand Down
2 changes: 1 addition & 1 deletion scripts/i18n-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ test('web-ui runtime keeps locale namespaces lazy outside its bootstrap set', ()
const webI18nSource = readText('src/web-ui/src/infrastructure/i18n/core/I18nService.ts');

assert.match(webI18nSource, /WEB_UI_BOOTSTRAP_NAMESPACES/, 'Web UI should declare the small eager namespace set');
assert.match(webI18nSource, /import\.meta\.glob\('\.\.\/\.\.\/\.\.\/locales\/\*\*\/\*\.json'/, 'Web UI should keep a lazy namespace glob for non-bootstrap resources');
assert.match(webI18nSource, /import\.meta\.glob\(\[\s*'\.\.\/\.\.\/\.\.\/locales\/\*\*\/\*\.json'/, 'Web UI should keep a lazy namespace glob for non-bootstrap resources');
const lazyGlobBlock = webI18nSource.match(/const lazyLocaleModules = import\.meta\.glob\([\s\S]*?\) as/)?.[0] ?? '';
assert.doesNotMatch(
lazyGlobBlock,
Expand Down
13 changes: 3 additions & 10 deletions src/apps/desktop/src/computer_use/provider_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,9 @@ impl Request {
}

fn abandon(&self, reason: &str) -> String {
let previous = self
.state
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
Some(if state == INPUT_SUBMITTED || state == INPUT_UNKNOWN {
INPUT_UNKNOWN
} else {
ABANDONED
})
})
.unwrap();
// The abandonment bit maps READING to ABANDONED and INPUT_SUBMITTED
// to INPUT_UNKNOWN, preserving both terminal states on repeated calls.
let previous = self.state.fetch_or(ABANDONED, Ordering::AcqRel);
self.failure_message(
reason,
previous == INPUT_SUBMITTED || previous == INPUT_UNKNOWN,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1848,6 +1848,7 @@ async fn upsert_internal(
}

/// Removes one provider from both the credential vault and metadata index.
#[cfg(test)]
pub(crate) async fn remove(provider: &str) -> Result<RemoveOutcome> {
remove_internal(provider, false).await
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ impl RouterAdmission {
Ok(gate)
}

/// Compatibility for callers without a task hierarchy, including old probes.
/// Each anonymous request is an independent fair-share participant.
/// Give each anonymous test request an independent fair-share participant.
#[cfg(test)]
pub(super) async fn acquire(
self: &Arc<Self>,
deadline: Instant,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4377,6 +4377,7 @@ impl SessionManager {
.map(|_| ())
}

#[cfg(test)]
pub(crate) async fn update_session_model_selection_in_storage(
&self,
storage_path: Option<&Path>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,16 +222,20 @@ impl ExecCommandTool {
}

fn command_env() -> HashMap<String, String> {
let mut environment = exec_command_noninteractive_env();
let environment = exec_command_noninteractive_env();
// The terminal executor applies this map on top of the host
// environment, which a Linux GUI or `.desktop` launch starts without a
// login shell's PATH. Setting it here is what lets the shell resolve a
// toolchain-managed `node`/`bun` the way a terminal does.
// Windows and macOS keep the inherited environment unchanged.
#[cfg(target_os = "linux")]
if let Some(path) = openbitfun_services_core::process_manager::platform_path_env() {
environment.insert("PATH".to_string(), path.to_string_lossy().into_owned());
}
let environment = {
let mut environment = environment;
if let Some(path) = openbitfun_services_core::process_manager::platform_path_env() {
environment.insert("PATH".to_string(), path.to_string_lossy().into_owned());
}
environment
};
environment
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ fn local_plugin_workspace_scope(workspace: &WorkspaceBinding) -> Option<String>
/// Convert framework::ToolResult to core::ToolResult
///
/// Ensure always has result_for_assistant, avoid tool message content being empty
#[cfg(test)]
fn convert_tool_result(
framework_result: FrameworkToolResult,
tool_id: &str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ impl AIClientFactory {
})
}

#[cfg(test)]
pub(crate) async fn account_client_source(
self: &Arc<Self>,
model_id: &str,
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/app/components/SceneBar/SceneBar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
}

&__tabs [data-openbitfun-part='item'] {
// Cap the whole tab, including its label padding and close-action reserve.
// Cap the resting capsule; TabGroup grows its action area beyond this width.
max-inline-size: 220px;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
.canvas-tab {
display: flex;
min-inline-size: 80px;
max-inline-size: 240px;
position: relative;
user-select: none;

> [data-openbitfun-part='item'] {
max-inline-size: 100%;
// Let the capsule grow its action area without compressing a capped label.
max-inline-size: 240px;
}

&.is-preview:not(.is-task-detail) &__title {
Expand Down
Loading
Loading