diff --git a/scripts/computer-use-runtime.py b/scripts/computer-use-runtime.py
index 996c8411d..5e5884f8d 100644
--- a/scripts/computer-use-runtime.py
+++ b/scripts/computer-use-runtime.py
@@ -190,18 +190,53 @@ def source_checkout(source):
def draw_cursor(path):
- # Original blue arrow geometry; no upstream/reference artwork is copied.
width = height = 126
- polygon = [(60, 56), (60, 80), (66, 74), (72, 86), (77, 83), (71, 71), (83, 71)]
+ # AppKit's (60, 70) tip anchor is (60, 56) in this top-origin PNG.
+ polygon = [(60, 56), (60.7, 77.8), (66.1, 73.1), (69.1, 78.2),
+ (72.4, 76.2), (69.4, 71.2), (77.8, 70.4)]
+ fill = (42, 37, 32)
+ edge = (244, 240, 230)
+ edge_width = 0.75
+ samples = 4
+ sides = list(zip(polygon, polygon[1:] + polygon[:1]))
+ left, right = min(int(x) for x, _ in polygon), max(int(x) for x, _ in polygon)
+ top, bottom = min(int(y) for _, y in polygon), max(int(y) for _, y in polygon)
+
def inside(x, y):
crossings = 0
- for i, (ax, ay) in enumerate(polygon):
- bx, by = polygon[(i + 1) % len(polygon)]
+ for (ax, ay), (bx, by) in sides:
if (ay > y) != (by > y) and x < (bx - ax) * (y - ay) / (by - ay) + ax:
crossings += 1
return crossings % 2
- raw = b"".join(b"\0" + b"".join(bytes((30, 112, 220, 255)) if inside(x, y) else bytes(4)
- for x in range(width)) for y in range(height))
+
+ def near_edge(x, y):
+ for (ax, ay), (bx, by) in sides:
+ dx, dy = bx - ax, by - ay
+ along = max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)))
+ if (x - ax - along * dx) ** 2 + (y - ay - along * dy) ** 2 < edge_width ** 2:
+ return True
+ return False
+
+ rows = []
+ for y in range(height):
+ row = bytearray(b"\0")
+ for x in range(width):
+ colors = []
+ if left <= x <= right and top <= y <= bottom:
+ for sy in range(samples):
+ for sx in range(samples):
+ px, py = x + (sx + 0.5) / samples, y + (sy + 0.5) / samples
+ if inside(px, py):
+ colors.append(edge if near_edge(px, py) else fill)
+ if colors:
+ row.extend(round(sum(color[channel] for color in colors) / len(colors))
+ for channel in range(3))
+ row.append(round(255 * len(colors) / (samples * samples)))
+ else:
+ row.extend(bytes(4))
+ rows.append(row)
+ raw = b"".join(rows)
+
def chunk(kind, data):
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
path.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
diff --git a/scripts/test-computer-use-packaging.py b/scripts/test-computer-use-packaging.py
index e9c9b9170..7a5341c99 100644
--- a/scripts/test-computer-use-packaging.py
+++ b/scripts/test-computer-use-packaging.py
@@ -3,11 +3,13 @@
import importlib.util
import json
from pathlib import Path
+import re
import struct
import subprocess
import tempfile
import unittest
import zipfile
+import zlib
spec = importlib.util.spec_from_file_location("runtime", Path(__file__).with_name("computer-use-runtime.py"))
runtime = importlib.util.module_from_spec(spec)
@@ -164,6 +166,56 @@ def test_boolean_is_not_a_numeric_schema_bound(self):
protocol.validate_tools(tools)
+class CursorArtworkTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ path = Path(self.temp.name) / "biorouter-cursor.png"
+ runtime.draw_cursor(path)
+ data = path.read_bytes()
+ self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
+ offset, compressed = 8, b""
+ while offset < len(data):
+ length = struct.unpack_from(">I", data, offset)[0]
+ kind, body = data[offset + 4:offset + 8], data[offset + 8:offset + 8 + length]
+ if kind == b"IHDR":
+ self.width, self.height, depth, color, _, _, _ = struct.unpack(">IIBBBBB", body)
+ self.assertEqual((depth, color), (8, 6))
+ elif kind == b"IDAT":
+ compressed += body
+ offset += length + 12
+ raw = zlib.decompress(compressed)
+ self.pixels = {}
+ for y in range(self.height):
+ row = raw[y * (self.width * 4 + 1):(y + 1) * (self.width * 4 + 1)]
+ self.assertEqual(row[0], 0)
+ for x in range(self.width):
+ self.pixels[x, y] = tuple(row[1 + x * 4:5 + x * 4])
+ self.visible = {point: color for point, color in self.pixels.items() if color[3] > 0}
+
+ def tearDown(self):
+ self.temp.cleanup()
+
+ def test_compact_artwork_keeps_the_native_tip_anchor(self):
+ self.assertEqual((self.width, self.height), (126, 126))
+ branding = (runtime.VENDOR / "patches/0001-branding.patch").read_text()
+ anchor = re.search(r"\+ static let tipAnchor = CGPoint\(x: ([\d.]+), y: ([\d.]+)\)", branding)
+ tip_x, tip_y = float(anchor[1]), self.height - float(anchor[2])
+ self.assertEqual(min(x for x, _ in self.visible), tip_x)
+ self.assertEqual(min(y for _, y in self.visible), tip_y)
+ self.assertGreater(self.pixels[int(tip_x), int(tip_y)][3], 0)
+ self.assertLessEqual(max(x for x, _ in self.visible) - tip_x + 1, 20)
+ self.assertLessEqual(max(y for _, y in self.visible) - tip_y + 1, 24)
+
+ def test_neutral_pointer_has_a_light_edge_and_antialiased_transparency(self):
+ opaque = [color for color in self.visible.values() if color[3] == 255]
+ self.assertGreater(sum(max(color[:3]) < 65 for color in opaque), len(opaque) / 2)
+ self.assertTrue(any(min(color[:3]) > 220 for color in self.visible.values()))
+ self.assertTrue(all(max(color[:3]) - min(color[:3]) <= 20 for color in self.visible.values()))
+ self.assertTrue(any(0 < color[3] < 255 for color in self.visible.values()))
+ self.assertEqual(self.pixels[0, 0], (0, 0, 0, 0))
+ self.assertEqual(self.pixels[125, 125], (0, 0, 0, 0))
+
+
class PayloadTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
diff --git a/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx b/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx
index 7c5878484..6f5190b61 100644
--- a/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx
+++ b/ui/desktop/src/components/ChatInput.firstSendRemount.test.tsx
@@ -253,13 +253,17 @@ function ChatPane({ clean, sessionId, composerKey = TAB }: PaneProps) {
);
}
+const toolCounts = (chip: HTMLElement | null) =>
+ chip?.getAttribute('aria-label')?.match(/\d+/g) ?? [];
+
/** What the composer states in one frame: the Tools chip, its name, the padlock, the folder. */
function frame(): string {
const tools = screen.queryByTestId('composer-tools-chip');
const model = screen.queryByTestId('model-chip');
const lock = model?.querySelector('[data-testid="privacy-badge"]') ? '+lock' : 'no lock';
const folder = screen.queryByTestId('footer-folder')?.textContent ?? '-';
- return `${tools?.textContent ?? '-'} | ${tools?.getAttribute('aria-label') ?? '-'} | ${lock} | ${folder}`;
+ const counts = toolCounts(tools).join(', ');
+ return `${tools?.textContent ?? '-'} | ${counts || '-'} | ${tools?.getAttribute('aria-label') ?? '-'} | ${lock} | ${folder}`;
}
/** Every distinct committed state from now on (the observer reports per commit). */
@@ -283,7 +287,7 @@ const settle = (ms: number) =>
await new Promise((resolve) => setTimeout(resolve, ms));
});
-const SETTLED = `Tools5 | Tools: 2 extensions, 2 skills, 1 knowledge base | +lock | ${CHOSEN_DIR}`;
+const SETTLED = `Tools | 2, 2, 1 | Tools: 2 extensions, 2 skills, 1 knowledge base | +lock | ${CHOSEN_DIR}`;
/** A new tab, answered, with a folder picked before the first message. */
async function openNewChatWithPickedFolder() {
@@ -294,7 +298,7 @@ async function openNewChatWithPickedFolder() {
}
describe('a new chat’s first send keeps what the composer states (V2-R8-3)', () => {
- it('keeps "Tools 5", its name, the padlock and the picked folder in every commit', async () => {
+ it('keeps all three tool counts, their name, the padlock and the picked folder in every commit', async () => {
const view = await openNewChatWithPickedFolder();
const fieldBefore = screen.getByTestId('chat-input');
const record = recordFrames();
@@ -321,6 +325,7 @@ describe('a new chat’s first send keeps what the composer states (V2-R8-3)', (
// The same tab, opened again: it asks for itself.
const again = render();
+ expect(toolCounts(screen.getByTestId('composer-tools-chip'))).toEqual([]);
expect(screen.getByTestId('composer-tools-chip')).toHaveTextContent(/^Tools$/);
expect(screen.getByTestId('composer-tools-chip')).toHaveAccessibleName('Tools');
expect(screen.getByTestId('footer-folder')).toHaveTextContent(DEFAULT_DIR);
@@ -331,13 +336,14 @@ describe('a new chat’s first send keeps what the composer states (V2-R8-3)', (
const chatA = render();
await waitFor(() => expect(frame()).toBe(SETTLED));
chatA.rerender();
+ expect(toolCounts(screen.getByTestId('composer-tools-chip'))).toEqual([]);
expect(screen.getByTestId('composer-tools-chip')).toHaveTextContent(/^Tools$/);
expect(screen.getByTestId('composer-tools-chip')).toHaveAccessibleName('Tools');
expect(screen.getByTestId('footer-folder')).toHaveTextContent(/^$/);
// Then its own figures and folder, once they are read.
await waitFor(() =>
expect(frame()).toBe(
- `Tools5 | Tools: 2 extensions, 2 skills, 1 knowledge base | +lock | ${OTHER_DIR}`
+ `Tools | 2, 2, 1 | Tools: 2 extensions, 2 skills, 1 knowledge base | +lock | ${OTHER_DIR}`
)
);
chatA.unmount();
diff --git a/ui/desktop/src/components/bottom_menu/EffortSection.tsx b/ui/desktop/src/components/bottom_menu/EffortSection.tsx
index fcf926c74..f076b3ac5 100644
--- a/ui/desktop/src/components/bottom_menu/EffortSection.tsx
+++ b/ui/desktop/src/components/bottom_menu/EffortSection.tsx
@@ -1,7 +1,7 @@
import './pickers.css';
import * as React from 'react';
import { InfoTip, useInfoTipId } from '../ui/info-tip';
-import { Slider, type SliderStep } from '../ui/slider';
+import { SegmentedControl, type SegmentedOption } from '../ui/segmented-control';
import {
REASONING_EFFORT_HELP,
REASONING_EFFORT_LABELS,
@@ -11,42 +11,43 @@ import {
} from '../../store/reasoningEffort';
import { MODEL_COPY } from './copy';
-const STEPS: ReadonlyArray> = REASONING_EFFORTS.map((effort) => ({
- value: effort,
- label: REASONING_EFFORT_LABELS[effort],
-}));
+const OPTIONS: ReadonlyArray> = REASONING_EFFORTS.map(
+ (effort) => ({
+ value: effort,
+ label: REASONING_EFFORT_LABELS[effort],
+ })
+);
export interface EffortSectionProps {
/** Where the effort is kept (`store/reasoningEffort.ts`): a chat's scope, or a draft's. */
scope: string;
/** The effort the store holds for `scope`. */
effort: ReasoningEffort;
- /** The slider's thumb, so the menu around it can move focus there (`moveFocusAcrossEffort`). */
- thumbRef?: React.Ref;
+ /** Lets the surrounding menu reach the selected segment with Up and Down. */
+ controlRef?: React.Ref;
}
/**
- * The model menu's Effort section (R-26, owner message 13): ONE slider with three stops,
- * Quick, Normal and Deep, where the menu used to list three radio rows.
- *
- * The head row says "Effort" with an InfoTip on the left and the current stop on the right; the
- * slider under it fills the menu's width. A change applies at once, through the same store and
- * the same scope the radio rows wrote (`setReasoningEffort(scope, …)`), and the menu stays open
- * so the person sees what they set.
- *
- * The InfoTip carries what the three levels mean, and the thumb points at the InfoTip's hidden
- * description, so a screen reader hears it on focus without hovering: the per-level descriptions
- * the radio rows had in tooltips are now one place.
- *
- * The section is a `group` named "Effort" (a `menu` may own groups; a bare slider among its rows
- * would not be one of its allowed children). How the keyboard reaches it inside the menu is
- * `moveFocusAcrossEffort`, below.
+ * A choice applies immediately and keeps the menu open. The surrounding menu's vertical keys
+ * visit this control as one stop; horizontal keys stay within its radio group.
*/
-export function EffortSection({ scope, effort, thumbRef }: EffortSectionProps) {
+export function EffortSection({ scope, effort, controlRef }: EffortSectionProps) {
const labelId = React.useId();
const helpId = useInfoTipId();
return (
-
{MODEL_COPY.effortGroup}
@@ -57,20 +58,17 @@ export function EffortSection({ scope, effort, thumbRef }: EffortSectionProps) {
help={REASONING_EFFORT_HELP}
data-testid="effort-info"
/>
- {/* The slider speaks its own value; this is the same word for the eye. */}
-
- {REASONING_EFFORT_LABELS[effort]}
-
);
@@ -80,53 +78,42 @@ const MENU_ROW_SELECTOR =
'[role="menuitem"]:not([data-disabled]), [role="menuitemradio"]:not([data-disabled]), [role="menuitemcheckbox"]:not([data-disabled])';
/**
- * Lets the menu's Up and Down keys reach the Effort slider, as `onKeyDownCapture` on the menu.
- *
- * ⚠ **A Radix menu's arrow keys visit menu ROWS only** (its roving focus group holds
- * `MenuItem`s), and its Tab leaves the menu (`useTabLeavesMenu`), so a slider among the rows is
- * reached by neither: the keyboard could not set the effort at all. So the slider is made one
- * more stop in the menu's vertical order: Up from the row below it lands on the thumb, Down
- * from the thumb goes to that row, and the same the other way round should rows ever sit above
- * it. On the thumb, Up and Down walk the MENU (the menu runs vertically, and a slider that
- * swallowed them would be a dead end the person can only leave with Escape); Left, Right, Home,
- * End, Page Up and Page Down move the effort, as the slider pattern has them. Anything else
- * (row to row, typeahead, Tab, Escape) is left to Radix.
- *
- * Runs in the capture phase, so it decides before the thumb's own handler (which would take Up
- * and Down) and before the row's roving handler.
+ * Radix menus only visit menu rows. Include the selected effort segment in that vertical order,
+ * before the radio group's own key handler can interpret Up or Down as a value change.
*/
export function moveFocusAcrossEffort(
event: React.KeyboardEvent,
- thumb: HTMLElement | null
+ control: HTMLElement | null
): void {
- if (!thumb || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
+ if (!control || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
+ const selected = control.querySelector('[role="radio"][aria-checked="true"]');
+ if (!selected) return;
const menu = event.currentTarget;
const target = event.target as HTMLElement;
const rows = Array.from(menu.querySelectorAll(MENU_ROW_SELECTOR));
- // Every stop in document order: the rows and the thumb.
- const stops = [...rows, thumb].sort((a, b) =>
+ const stops = [...rows, selected].sort((a, b) =>
a === b ? 0 : a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
);
const step = event.key === 'ArrowDown' ? 1 : -1;
let next: HTMLElement | undefined;
- if (target === thumb) {
- next = stops[stops.indexOf(thumb) + step];
- // No row that way: stay on the thumb, and do not let the slider read it as a value key.
+ if (control.contains(target)) {
+ next = stops[stops.indexOf(selected) + step];
+ // At the menu edge, keep the value and focus where they are.
if (!next) {
event.preventDefault();
event.stopPropagation();
return;
}
} else if (target === menu) {
- // Focus on the menu itself (the pointer left a row): with no row to go to, the thumb.
+ // Focus on the menu itself (the pointer left a row): with no row to go to, the selection.
if (rows.length > 0) return;
- next = thumb;
+ next = selected;
} else {
const at = stops.indexOf(target);
- if (at === -1 || stops[at + step] !== thumb) return;
- next = thumb;
+ if (at === -1 || stops[at + step] !== selected) return;
+ next = selected;
}
event.preventDefault();
event.stopPropagation();
diff --git a/ui/desktop/src/components/bottom_menu/ToolsChip.test.tsx b/ui/desktop/src/components/bottom_menu/ToolsChip.test.tsx
index 9d93c18a0..a2a95974c 100644
--- a/ui/desktop/src/components/bottom_menu/ToolsChip.test.tsx
+++ b/ui/desktop/src/components/bottom_menu/ToolsChip.test.tsx
@@ -7,7 +7,7 @@ import { TOOLS_COPY } from './copy';
import { TOOLTIP_DELAY_MS } from '../ui/Tooltip';
/**
- * Spec 3.7: ONE Tools chip ("Tools" and a sum) opening ONE popover, with a
+ * ONE Tools chip with its counts on hover opening ONE popover, with a
* segmented control over the three lists. The lists themselves are tested in
* their own files (`BottomMenu*Selection*.test.tsx`); here they are stand-ins
* that record what the chip asks of them.
@@ -105,20 +105,27 @@ beforeEach(() => {
});
const last = (values: boolean[]) => values[values.length - 1];
+const toolCounts = (chip: HTMLElement) => chip.getAttribute('aria-label')?.match(/\d+/g) ?? [];
describe('ToolsChip', () => {
- it('is one chip labelled Tools with the sum, and says the parts in its name', async () => {
+ it('shows only Tools, and gives the enabled total and category counts on hover', async () => {
const user = userEvent.setup();
render();
const chip = screen.getByTestId('composer-tools-chip');
- expect(chip).toHaveTextContent('Tools15');
+ expect(chip).toHaveTextContent(/^Tools$/);
+ expect(toolCounts(chip)).toEqual(['4', '9', '2']);
expect(chip).toHaveAccessibleName('Tools: 4 extensions, 9 skills, 2 knowledge bases');
expect(chip).toHaveClass('br-picker-chip');
expect(chip).not.toHaveAttribute('title');
+ expect(chip.querySelectorAll('svg')).toHaveLength(1);
await user.hover(chip);
- expect(await screen.findByRole('tooltip')).toHaveTextContent(TOOLS_COPY.chipTooltip(4, 9, 2));
+ const tooltip = await screen.findByRole('tooltip');
+ expect(tooltip).toHaveTextContent(/15 (?:enabled tools|tools enabled)/);
+ expect(tooltip).toHaveTextContent('4 extensions');
+ expect(tooltip).toHaveTextContent('9 skills');
+ expect(tooltip).toHaveTextContent('2 knowledge bases');
});
it('opens one popover with a segmented control over one list at a time', async () => {
@@ -266,28 +273,27 @@ describe('ToolsChip', () => {
expect(await screen.findByTestId('knowledge-panel')).toHaveAttribute('data-in-chat', 'false');
});
- describe('the number states this chat’s tools, or nothing (V2-R7-3)', () => {
+ describe('the counts state this chat’s tools, or remain unknown (V2-R7-3)', () => {
/**
- * Every number the chip has shown, in order, from the first commit on. The
+ * Every count tuple the chip has named, in order, from the first commit on. The
* observer reports in a microtask, so each step is flushed before the next.
*/
function recordNumbers(chip: HTMLElement): string[] {
const seen: string[] = [];
const note = () => {
- const shown = chip.querySelector('.br-picker-chip__count')?.textContent ?? '';
+ const shown = toolCounts(chip).join('|');
if (seen[seen.length - 1] !== shown) seen.push(shown);
};
note();
new MutationObserver(note).observe(chip, {
- childList: true,
- subtree: true,
- characterData: true,
+ attributes: true,
+ attributeFilter: ['aria-label'],
});
return seen;
}
const flush = () => act(async () => {});
- it('shows no number until all three lists have answered, then the total once', async () => {
+ it('keeps counts unknown until all three lists have answered, then names them once', async () => {
const user = userEvent.setup();
hooks.extensions = { count: 2, answered: false };
hooks.skills = { count: 0, answered: false };
@@ -296,6 +302,7 @@ describe('ToolsChip', () => {
const chip = screen.getByTestId('composer-tools-chip');
const numbers = recordNumbers(chip);
+ expect(toolCounts(chip)).toEqual([]);
expect(chip).toHaveTextContent(/^Tools$/);
expect(chip).toHaveAccessibleName(TOOLS_COPY.chip);
// Nothing to say on hover either: the parts are not known. (Held past the
@@ -313,21 +320,22 @@ describe('ToolsChip', () => {
hooks.skills = { count: 3, answered: true };
rerender();
await flush();
+ expect(toolCounts(chip)).toEqual([]);
expect(chip).toHaveTextContent(/^Tools$/);
hooks.extensions = { count: 1, answered: true };
rerender();
await flush();
- expect(chip).toHaveTextContent('Tools4');
+ expect(toolCounts(chip)).toEqual(['1', '3', '0']);
expect(chip).toHaveAccessibleName('Tools: 1 extension, 3 skills, 0 knowledge bases');
- // Never 2, 5 or 3 on the way: nothing, then the total.
- expect(numbers).toEqual(['', '4']);
+ // No partial figures on the way: unknown, then the settled tuple.
+ expect(numbers).toEqual(['', '1|3|0']);
});
- it('never shows the previous chat’s number under the next one', async () => {
+ it('never shows the previous chat’s counts under the next one', async () => {
const { rerender } = render();
const chip = screen.getByTestId('composer-tools-chip');
- expect(chip).toHaveTextContent('Tools15');
+ expect(toolCounts(chip)).toEqual(['4', '9', '2']);
const numbers = recordNumbers(chip);
// The tab keeps its mount and binds another chat: the lists still hold
@@ -336,17 +344,17 @@ describe('ToolsChip', () => {
hooks.skills = { count: 9, answered: false };
rerender();
await flush();
- expect(chip).toHaveTextContent(/^Tools$/);
+ expect(toolCounts(chip)).toEqual([]);
hooks.extensions = { count: 1, answered: true };
hooks.skills = { count: 2, answered: true };
rerender();
await flush();
- expect(chip).toHaveTextContent('Tools5');
- expect(numbers).toEqual(['15', '', '5']);
+ expect(toolCounts(chip)).toEqual(['1', '2', '2']);
+ expect(numbers).toEqual(['4|9|2', '', '1|2|2']);
});
- it('keeps the number while one list re-asks for the same chat, and moves it once', async () => {
+ it('keeps the counts while one list re-asks for the same chat, and moves them once', async () => {
const { rerender } = render();
const chip = screen.getByTestId('composer-tools-chip');
const numbers = recordNumbers(chip);
@@ -356,16 +364,16 @@ describe('ToolsChip', () => {
hooks.knowledge = { count: 5, answered: false };
rerender();
await flush();
- expect(chip).toHaveTextContent('Tools15');
+ expect(toolCounts(chip)).toEqual(['4', '9', '2']);
hooks.knowledge = { count: 3, answered: true };
rerender();
await flush();
- expect(chip).toHaveTextContent('Tools16');
- expect(numbers).toEqual(['15', '16']);
+ expect(toolCounts(chip)).toEqual(['4', '9', '3']);
+ expect(numbers).toEqual(['4|9|2', '4|9|3']);
});
- it('carries the count into a chat this composer has just started', async () => {
+ it('carries the counts into a chat this composer has just started', async () => {
// A new tab's composer: no chat yet, counting what the next chat gets.
const { rerender } = render();
const chip = screen.getByTestId('composer-tools-chip');
@@ -376,27 +384,27 @@ describe('ToolsChip', () => {
hooks.skills = { count: 9, answered: false };
rerender();
await flush();
- expect(chip).toHaveTextContent('Tools15');
+ expect(toolCounts(chip)).toEqual(['4', '9', '2']);
hooks.extensions = { count: 4, answered: true };
hooks.skills = { count: 9, answered: true };
rerender();
await flush();
- expect(numbers).toEqual(['15']);
+ expect(numbers).toEqual(['4|9|2']);
});
- it('names a carried number by its parts, as it names an answered one', async () => {
+ it('names carried counts as it names answered ones', async () => {
const { rerender } = render();
const chip = screen.getByTestId('composer-tools-chip');
hooks.knowledge = { count: 5, answered: false };
rerender();
await flush();
- // The number on screen is the settled one, and so is the name (V2-R8-3).
- expect(chip).toHaveTextContent('Tools15');
+ // The accessible counts remain settled while the list refreshes (V2-R8-3).
+ expect(toolCounts(chip)).toEqual(['4', '9', '2']);
expect(chip).toHaveAccessibleName('Tools: 4 extensions, 9 skills, 2 knowledge bases');
});
- it('hands its number to the chip that replaces it when the composer remounts (V2-R8-3)', async () => {
+ it('hands its counts to the chip that replaces it when the composer remounts (V2-R8-3)', async () => {
// A new chat's first send moves the composer from the centre of the empty
// chat to the bar under the transcript: a second mount, in one commit.
function Seat({ centre, sessionId }: { centre: boolean; sessionId: string | null }) {
@@ -410,7 +418,7 @@ describe('ToolsChip', () => {
}
const { rerender } = render();
const first = screen.getByTestId('composer-tools-chip');
- expect(first).toHaveTextContent('Tools15');
+ expect(toolCounts(first)).toEqual(['4', '9', '2']);
// The new mount's lists have not answered yet, for "no chat" or the chat.
hooks.extensions = { count: 4, answered: false };
@@ -427,12 +435,12 @@ describe('ToolsChip', () => {
hooks.skills = { count: 9, answered: true };
rerender();
await flush();
- expect(numbers).toEqual(['15']);
+ expect(numbers).toEqual(['4|9|2']);
// Not for another chat: a remount onto a different chat starts from nothing.
hooks.extensions = { count: 4, answered: false };
rerender();
- expect(screen.getByTestId('composer-tools-chip')).toHaveTextContent(/^Tools$/);
+ expect(toolCounts(screen.getByTestId('composer-tools-chip'))).toEqual([]);
});
});
});
diff --git a/ui/desktop/src/components/bottom_menu/ToolsChip.tsx b/ui/desktop/src/components/bottom_menu/ToolsChip.tsx
index 05319a71f..dff36127f 100644
--- a/ui/desktop/src/components/bottom_menu/ToolsChip.tsx
+++ b/ui/desktop/src/components/bottom_menu/ToolsChip.tsx
@@ -12,9 +12,8 @@ import { SkillsPanel, useSkillPicker } from './BottomMenuSkillSelection';
import { TOOLS_COPY } from './copy';
/**
- * The composer's Tools chip (spec 3.7): one control, labelled "Tools" and the
- * sum of the extensions, skills and knowledge bases this chat can reach. It
- * opens one popover over the chip: one list at a time (a search field, a scope
+ * The composer's Tools chip: one label, with enabled counts in its tooltip.
+ * It opens one popover over the chip: one list at a time (a search field, a scope
* line and switch rows) with the segmented control (Extensions, Skills,
* Knowledge) under it, directly above the chip.
*
@@ -111,9 +110,9 @@ export function ToolsChip({ sessionId, handOffKey }: ToolsChipProps) {
inputRef.current?.focus();
}, [segment]);
- // The three lists live for as long as the composer does, so the chip's number
- // is right before the popover is ever opened. Each re-reads its source when
- // its own list comes on screen.
+ // The three lists live for as long as the composer does, so its tooltip
+ // has the right counts before the popover is ever opened. Each list re-reads
+ // its source when it comes on screen.
const extensions = useExtensionPicker(sessionId, {
open: open && segment === 'extensions',
// "Show this chat's extensions" from elsewhere in the app.
@@ -127,11 +126,11 @@ export function ToolsChip({ sessionId, handOffKey }: ToolsChipProps) {
const counts = [extensions.activeCount, skills.activeCount, knowledge.visibleCount] as const;
- // ⚠ **The number is shown only once all three lists have answered for THIS
+ // ⚠ **Counts are exposed only once all three lists have answered for THIS
// chat** (V2-R7-3, principle 10). Summed as each arrived, a chat switch read
// "Tools 2", then 5, then 4: the configuration's fallback, then each list in
// turn. A number shown and then withdrawn is the class V2-R5-5 fixed for the
- // summary rail. So the chip reads "Tools" alone until it can state a fact.
+ // summary rail. Wait until all three figures stand.
//
// Two cases keep the last settled figures instead of hiding them:
// - the same chat, when one list goes back to asking (the knowledge selection
@@ -157,15 +156,12 @@ export function ToolsChip({ sessionId, handOffKey }: ToolsChipProps) {
setSettled({ chat, counts });
}
useComposerHandOff(handOffKey, TOOLS_HAND_OFF, settled);
- // The figures the chip states, or none. The number, the accessible name and
- // the tooltip all say the same figures: a carried number is named as one.
+ // The accessible name and tooltip share the same settled figures.
const shown: SettledTools['counts'] | null = ready
? counts
: settled !== null && (settled.chat === chat || (settled.chat === null && chat !== null))
? settled.counts
: null;
- const shownTotal = shown === null ? null : shown[0] + shown[1] + shown[2];
-
const onOpenChange = (next: boolean) => {
setOpen(next);
if (!next) {
@@ -185,9 +181,9 @@ export function ToolsChip({ sessionId, handOffKey }: ToolsChipProps) {
data-testid="composer-tools-chip"
className="br-picker-chip"
aria-label={shown !== null ? TOOLS_COPY.chipLabel(...shown) : TOOLS_COPY.chip}
+ aria-busy={shown === null}
>
{TOOLS_COPY.chip}
- {shownTotal !== null && {shownTotal}}
diff --git a/ui/desktop/src/components/bottom_menu/copy.ts b/ui/desktop/src/components/bottom_menu/copy.ts
index eab5a8cc4..d680cd3fa 100644
--- a/ui/desktop/src/components/bottom_menu/copy.ts
+++ b/ui/desktop/src/components/bottom_menu/copy.ts
@@ -18,11 +18,11 @@ function count(n: number, one: string, many: string): string {
export const TOOLS_COPY = {
chip: 'Tools',
- /** The chip's accessible name: the three numbers its one figure adds up. */
+ /** Counts stay available to assistive technology without opening the popover. */
chipLabel: (extensions: number, skills: number, knowledge: number) =>
`Tools: ${count(extensions, 'extension', 'extensions')}, ${count(skills, 'skill', 'skills')}, ${count(knowledge, 'knowledge base', 'knowledge bases')}`,
chipTooltip: (extensions: number, skills: number, knowledge: number) =>
- `${count(extensions, 'extension', 'extensions')} · ${count(skills, 'skill', 'skills')} · ${count(knowledge, 'knowledge base', 'knowledge bases')}`,
+ `${count(extensions + skills + knowledge, 'tool', 'tools')} enabled · ${count(extensions, 'extension', 'extensions')} · ${count(skills, 'skill', 'skills')} · ${count(knowledge, 'knowledge base', 'knowledge bases')}`,
segmentsLabel: 'Tool lists',
segments: {
extensions: 'Extensions',
diff --git a/ui/desktop/src/components/bottom_menu/effortSection.browser.test.tsx b/ui/desktop/src/components/bottom_menu/effortSection.browser.test.tsx
index 7afca9990..d1860adbc 100644
--- a/ui/desktop/src/components/bottom_menu/effortSection.browser.test.tsx
+++ b/ui/desktop/src/components/bottom_menu/effortSection.browser.test.tsx
@@ -1,15 +1,7 @@
// @vitest-environment node
/**
- * R-26 (owner message 13): the model menu's Effort section, measured in Chromium inside a real
- * Radix `DropdownMenu`, with the real `Slider`, `InfoTip` and effort store.
- *
- * jsdom can test the wiring (ModelsBottomBar.effort.test.tsx) but not three things that only a
- * layout engine and real input can: that the head row and the slider line up on the menu's 12px
- * text edge, that a real press and drag inside an open MENU (which steals focus on pointer moves
- * and closes on outside presses) sets the stop and leaves the menu open, and that the arrow keys
- * really carry focus between the menu's rows and the thumb. `pickers.css` and the
- * `@ws WS-PRIMITIVES` blocks of `main.css` supply the styles; Tailwind does not run, so the
- * menu's own width and padding are set inline at the recipe's values (w-72, p-1).
+ * The effort segments inside a real Radix menu: labels occupy their clickable segments,
+ * the shared thumb slides between selections, and menu navigation can enter and leave the group.
*/
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
@@ -95,7 +87,7 @@ function App() {
(listener) => subscribeToReasoningEffort(SCOPE, listener),
() => getReasoningEffort(SCOPE)
);
- const thumb = React.useRef(null);
+ const control = React.useRef(null);
(window as unknown as { __effort: () => string }).__effort = () => getReasoningEffort(SCOPE);
return (
@@ -105,9 +97,9 @@ function App() {
id="menu"
side="top"
align="start"
- onKeyDownCapture={(event) => moveFocusAcrossEffort(event, thumb.current)}
+ onKeyDownCapture={(event) => moveFocusAcrossEffort(event, control.current)}
>
-
+ Change model…Lead and worker…
@@ -137,7 +129,7 @@ const near = (actual: number, expected: number, what: string) =>
EPSILON
);
-describe('the model menu’s Effort slider in a real layout engine (R-26)', () => {
+describe('the model menu’s labelled effort segments in a real layout engine', () => {
let browser: Browser | null = null;
let script = '';
let css = '';
@@ -175,7 +167,7 @@ describe('the model menu’s Effort slider in a real layout engine (R-26)', () =
);
await tab.addScriptTag({ content: script });
await tab.click('#chip');
- await tab.waitForSelector('[data-testid="effort-slider"]');
+ await tab.waitForSelector('[data-testid="effort-control"]');
await tab.waitForTimeout(250);
return tab;
}
@@ -183,7 +175,7 @@ describe('the model menu’s Effort slider in a real layout engine (R-26)', () =
const effort = (tab: Page) =>
tab.evaluate(() => (window as unknown as { __effort: () => string }).__effort());
- it('lines the head row and the slider up on the menu’s 12px text edge', async (ctx) => {
+ it('puts all labels inside full-width segments on the menu’s text edge', async (ctx) => {
if (!browser) return ctx.skip();
const tab = await open();
try {
@@ -191,94 +183,118 @@ describe('the model menu’s Effort slider in a real layout engine (R-26)', () =
const box = (selector: string) => document.querySelector(selector)!.getBoundingClientRect();
const section = box('[data-testid="effort-section"]');
const label = document.querySelector('.br-effort__label')!;
- const value = document.querySelector('[data-testid="effort-value"]')!;
+ const control = box('[data-testid="effort-control"]');
+ const selected = box('[role="radio"][aria-checked="true"]');
+ const thumb = box('[data-slot="segmented-thumb"]');
return {
sectionLeft: section.left,
sectionRight: section.right,
labelLeft: label.getBoundingClientRect().left,
labelSize: getComputedStyle(label).fontSize,
labelWeight: getComputedStyle(label).fontWeight,
- valueRight: value.getBoundingClientRect().right,
- valueText: value.textContent,
- valueSize: getComputedStyle(value).fontSize,
- slider: box('[data-testid="effort-slider"]'),
- thumb: box('[role="slider"]'),
+ duplicateValue: document.querySelector('[data-testid="effort-value"]') !== null,
+ control,
head: box('.br-effort__head'),
+ selected,
+ thumb,
+ segments: Array.from(document.querySelectorAll('[role="radio"]')).map((item) => {
+ const segment = item.getBoundingClientRect();
+ const text = item.querySelector('.br-segmented-label')!.getBoundingClientRect();
+ return {
+ label: item.textContent,
+ width: segment.width,
+ height: segment.height,
+ inside:
+ text.left >= segment.left &&
+ text.right <= segment.right &&
+ text.top >= segment.top &&
+ text.bottom <= segment.bottom,
+ };
+ }),
};
});
- near(m.labelLeft - m.sectionLeft, 12, '"Effort" on the 12px text edge');
- near(m.sectionRight - m.valueRight, 12, 'current stop on the 12px trailing edge');
+ near(m.labelLeft - m.sectionLeft, 12, 'Effort label inset');
expect(m.labelSize).toBe('12px');
expect(m.labelWeight).toBe('500');
- expect(m.valueSize).toBe('12px');
- expect(m.valueText).toBe('Normal');
- // The slider fills the section inside its padding, 24px tall, under the head row.
- near(m.slider.left - m.sectionLeft, 12, 'slider left');
- near(m.sectionRight - m.slider.right, 12, 'slider right');
- near(m.slider.height, 24, 'slider height');
- expect(m.slider.top).toBeGreaterThanOrEqual(m.head.bottom);
- // Normal is the middle stop.
- near(
- m.thumb.left + m.thumb.width / 2,
- m.slider.left + m.slider.width / 2,
- 'Normal thumb centred'
- );
+ expect(m.duplicateValue).toBe(false);
+ near(m.control.left - m.sectionLeft, 12, 'control left inset');
+ near(m.sectionRight - m.control.right, 12, 'control right inset');
+ near(m.control.height, 28, 'shared segmented track height');
+ expect(m.control.top).toBeGreaterThanOrEqual(m.head.bottom);
+ expect(m.segments.map((segment) => segment.label)).toEqual(['Quick', 'Normal', 'Deep']);
+ for (const segment of m.segments) {
+ expect(segment.inside).toBe(true);
+ near(segment.height, 24, 'shared segment height');
+ near(segment.width, (m.control.width - 4) / 3, 'equal segment width');
+ }
+ near(m.thumb.left, m.selected.left, 'thumb starts at Normal');
+ near(m.thumb.width, m.selected.width, 'thumb covers the selected segment');
} finally {
await tab.close();
}
});
- it('sets the stop a press or a drag ends on, and leaves the menu open', async (ctx) => {
+ it('applies clicked labels immediately, keeps the menu open, and slides the shared thumb', async (ctx) => {
if (!browser) return ctx.skip();
const tab = await open();
try {
- const slider = (await tab.locator('[data-testid="effort-slider"]').boundingBox())!;
- const y = slider.y + slider.height / 2;
- // A press near the right end.
- await tab.mouse.click(slider.x + slider.width - 4, y);
+ await tab.emulateMedia({ reducedMotion: 'no-preference' });
+ const thumb = tab.locator('[data-slot="segmented-thumb"]');
+ const before = (await thumb.boundingBox())!;
+ const transition = await thumb.evaluate((node) => getComputedStyle(node).transitionProperty);
+ expect(transition).toContain('translate');
+ await tab.getByRole('radio', { name: 'Deep', exact: true }).click();
expect(await effort(tab)).toBe('deep');
- // A drag back to the left end, released there.
- await tab.mouse.move(slider.x + slider.width - 8, y);
- await tab.mouse.down();
- await tab.mouse.move(slider.x + slider.width / 2, y, { steps: 4 });
- await tab.mouse.move(slider.x + 2, y, { steps: 4 });
- await tab.mouse.up();
- expect(await effort(tab)).toBe('quick');
await tab.waitForTimeout(250);
- const m = await tab.evaluate(() => ({
- open: document.querySelector('[data-testid="effort-section"]') !== null,
- value: document.querySelector('[data-testid="effort-value"]')?.textContent,
- valuetext: document.querySelector('[role="slider"]')?.getAttribute('aria-valuetext'),
- }));
- expect(m).toEqual({ open: true, value: 'Quick', valuetext: 'Quick' });
+ const after = (await thumb.boundingBox())!;
+ expect(after.x).toBeGreaterThan(before.x);
+ const deep = (await tab.getByRole('radio', { name: 'Deep', exact: true }).boundingBox())!;
+ near(after.x, deep.x, 'thumb lands under Deep');
+ await tab.getByRole('radio', { name: 'Quick', exact: true }).click();
+ expect(await effort(tab)).toBe('quick');
+ expect(await tab.locator('[data-testid="effort-section"]').count()).toBe(1);
+ expect(
+ await tab.getByRole('radio', { name: 'Quick', exact: true }).getAttribute('aria-checked')
+ ).toBe('true');
} finally {
await tab.close();
}
});
- it('carries focus between the rows and the thumb with the arrow keys', async (ctx) => {
+ it('carries focus between menu rows and the selected segment without changing effort', async (ctx) => {
if (!browser) return ctx.skip();
const tab = await open();
try {
const focused = () =>
tab.evaluate(() => {
const el = document.activeElement;
- return el?.getAttribute('role') === 'slider' ? 'thumb' : (el?.id ?? null);
+ return el?.getAttribute('role') === 'radio' ? el.textContent : (el?.id ?? null);
});
await tab.focus('#change');
await tab.keyboard.press('ArrowUp');
- expect(await focused()).toBe('thumb');
+ expect(await focused()).toBe('Normal');
+ expect(await effort(tab)).toBe('normal');
+ await tab.keyboard.press('ArrowUp');
+ expect(await focused()).toBe('Normal');
+ expect(await effort(tab)).toBe('normal');
await tab.keyboard.press('ArrowRight');
+ await expect.poll(focused).toBe('Deep');
expect(await effort(tab)).toBe('deep');
- await tab.keyboard.press('ArrowLeft');
- await tab.keyboard.press('ArrowLeft');
+ await tab.keyboard.press('Home');
+ await expect.poll(focused).toBe('Quick');
expect(await effort(tab)).toBe('quick');
+ await tab.keyboard.press('End');
+ await expect.poll(focused).toBe('Deep');
+ expect(await effort(tab)).toBe('deep');
await tab.keyboard.press('ArrowDown');
expect(await focused()).toBe('change');
- // Row to row is Radix's own, which moves focus on a timer.
+ await tab.keyboard.press('ArrowUp');
+ expect(await focused()).toBe('Deep');
+ expect(await effort(tab)).toBe('deep');
+ await tab.keyboard.press('ArrowDown');
await tab.keyboard.press('ArrowDown');
await expect.poll(focused).toBe('lead');
- expect(await effort(tab)).toBe('quick');
+ expect(await effort(tab)).toBe('deep');
} finally {
await tab.close();
}
diff --git a/ui/desktop/src/components/bottom_menu/pickers.css b/ui/desktop/src/components/bottom_menu/pickers.css
index 85ea071fe..62345e410 100644
--- a/ui/desktop/src/components/bottom_menu/pickers.css
+++ b/ui/desktop/src/components/bottom_menu/pickers.css
@@ -227,12 +227,8 @@
}
/*
- * The model menu's Effort section (R-26): a head row ("Effort", its InfoTip,
- * and the current stop on the right) over one slider that fills the menu. The
- * label keeps the menu's group-label look (12px, 500, muted, 12px in from the
- * edge); the slider's own 8px rail inset then puts the thumb's outer edge on
- * that same 12px line at either end. The slider's geometry is the primitive's
- * (`.br-slider*`, main.css).
+ * The model menu's effort label and control share the menu's 12px text inset.
+ * Segments and the sliding selection use the shared primitive's geometry.
*/
.br-effort {
padding: 6px 12px 8px;
@@ -251,14 +247,7 @@
font-weight: 500;
}
-.br-effort__value {
- margin-inline-start: auto;
- padding-inline-start: 8px;
- color: var(--text-default);
- white-space: nowrap;
-}
-
-.br-effort__slider {
+.br-effort__control {
margin-top: 4px;
}
diff --git a/ui/desktop/src/components/bottom_menu/toolsPopover.browser.test.tsx b/ui/desktop/src/components/bottom_menu/toolsPopover.browser.test.tsx
index e67299eee..fa4002b4a 100644
--- a/ui/desktop/src/components/bottom_menu/toolsPopover.browser.test.tsx
+++ b/ui/desktop/src/components/bottom_menu/toolsPopover.browser.test.tsx
@@ -369,9 +369,11 @@ describe('the Tools popover keeps its segmented control above the chip (R-25)',
const tab = await open(height);
try {
const first = await measure(tab, 'extensions');
- // The stand-in chip is the app's own: the three lists have answered, so it
- // states their sum (1 + 1 + 1) and the popover is measured against that width.
- expect(await tab.textContent('[data-testid="composer-tools-chip"]')).toBe('Tools3');
+ const chip = tab.locator('[data-testid="composer-tools-chip"]');
+ expect(await chip.textContent()).toBe('Tools');
+ expect(await chip.getAttribute('aria-label')).toBe(
+ 'Tools: 1 extension, 1 skill, 1 knowledge base'
+ );
for (const segment of SEGMENTS) {
const { frames, under } =
segment === 'extensions'
diff --git a/ui/desktop/src/components/composer/composerCard.browser.test.tsx b/ui/desktop/src/components/composer/composerCard.browser.test.tsx
index 11f083d76..96cb874d6 100644
--- a/ui/desktop/src/components/composer/composerCard.browser.test.tsx
+++ b/ui/desktop/src/components/composer/composerCard.browser.test.tsx
@@ -22,15 +22,52 @@ import { mkdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { build } from 'esbuild';
+import { build, type Plugin } from 'esbuild';
import { chromium, type Browser, type Page } from 'playwright';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { CHAT_COLUMN_CHROME, CHAT_MIN_WIDTH } from '../Layout/yieldLadder';
const here = dirname(fileURLToPath(import.meta.url));
const STYLES = resolve(here, '../../styles');
const requireFromDesktop = createRequire(resolve(here, '../../../package.json'));
const SHOTS = process.env.COMPOSER_SHOTS_DIR;
+/** Keep the real Tools chip; only its daemon-backed lists are replaced. */
+const toolLists: Plugin = {
+ name: 'composer-tool-lists',
+ setup(builder) {
+ const lists: Record = {
+ './BottomMenuExtensionSelection': {
+ hook: 'useExtensionPicker',
+ panel: 'ExtensionsPanel',
+ count: 12,
+ },
+ './BottomMenuSkillSelection': { hook: 'useSkillPicker', panel: 'SkillsPanel', count: 123 },
+ './BottomMenuKnowledgeSelection': {
+ hook: 'useKnowledgePicker',
+ panel: 'KnowledgePanel',
+ count: 45,
+ },
+ };
+ builder.onResolve(
+ { filter: /^\.\/BottomMenu(Extension|Skill|Knowledge)Selection$/ },
+ (args) => ({ path: args.path, namespace: 'composer-tool-list' })
+ );
+ builder.onLoad({ filter: /.*/, namespace: 'composer-tool-list' }, ({ path }) => {
+ const { hook, panel, count } = lists[path];
+ return {
+ contents: `
+ export function ${hook}() {
+ return { activeCount: ${count}, visibleCount: ${count}, answered: true, setQuery() {} };
+ }
+ export function ${panel}() { return null; }
+ `,
+ loader: 'tsx',
+ };
+ });
+ },
+};
+
const ENTRY = `
import * as React from 'react';
import { createRoot } from 'react-dom/client';
@@ -38,12 +75,14 @@ import { composerCardClassName } from './composerCard';
import { ComposerChips } from './ComposerChips';
import { ComposerPlusMenu } from './ComposerPlusMenu';
import { MessageQueue } from '../MessageQueue';
+import { ToolsChip } from '../bottom_menu/ToolsChip';
import {
NO_MODEL_CHIP_LABEL,
NO_MODEL_COMPOSER_ACTION,
NO_MODEL_COMPOSER_HINT,
} from '../composerNoProvider';
import { Button } from '../ui/button';
+import { PrivacyBadge } from '../ui/PrivacyBadge';
import { ArrowUp, ChevronDown, Folder } from '../icons/app-icons';
const IMAGE =
@@ -52,12 +91,19 @@ const IMAGE =
''
);
-function Chip({ children }: { children: React.ReactNode }) {
+function ModelChip() {
return (
-
+