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 ( -
+
{ + if ( + ['ArrowLeft', 'ArrowRight', 'Home', 'End', ' '].includes(event.key) && + (event.target as HTMLElement).closest('[role="radiogroup"]') + ) + event.stopPropagation(); + }} + >
{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. */} -
- setReasoningEffort(scope, next)} aria-labelledby={labelId} aria-describedby={helpId} - className="br-effort__slider" - data-testid="effort-slider" + className="br-effort__control" + data-testid="effort-control" />
); @@ -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}}