From 369e9993b5ae155be52aa8dd8d389110c973af71 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 18:59:03 +0200 Subject: [PATCH] feat(ui): show the local agent as a robot with a status lamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header read "Agent active · Qwen2.5 Coder 0.5B" next to the chat toggle. It is now a robot icon drawn on the same grid as the chat toggle, with the status lamp at its corner; a click still opens the agent configuration. The state and the model name move into the tooltip and the accessible name, and the model is chosen in the configuration. The header's agent and chat labels now come from the workspace strings, which removes the seven hardcoded chrome strings the style gate found in AtlasChrome.tsx. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/app/AtlasChrome.test.tsx | 26 +++++++++++++--------- graph-ui/src/app/AtlasChrome.tsx | 12 +++++----- graph-ui/src/app/agent-status.css | 32 ++++++++++++++++++--------- graph-ui/src/app/workspace-strings.ts | 7 ++++++ 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/graph-ui/src/app/AtlasChrome.test.tsx b/graph-ui/src/app/AtlasChrome.test.tsx index 4bbbd853b..de834f9f1 100644 --- a/graph-ui/src/app/AtlasChrome.test.tsx +++ b/graph-ui/src/app/AtlasChrome.test.tsx @@ -89,28 +89,34 @@ describe('AtlasChrome', () => { expect(onOpenConfig).not.toHaveBeenCalled(); }); - it('shows the selected model subtly in the agent status button and opens its configuration directly', async () => { + it('shows a robot with a status lamp and keeps the model name in the tooltip and the label', async () => { const settings = vi.fn(), toggle = vi.fn(); await render(props({ agentState: 'active', agentModelName: 'Qwen 3.5 0.8B', onOpenBrowserAi: settings, onToggleBrowserAi: toggle })); - const button = container.querySelector('[aria-label="Local agent settings"]')!; - expect(button.textContent).toContain('Agent active'); - expect(button.querySelector('small')?.textContent).toBe('Qwen 3.5 0.8B'); + const button = container.querySelector('.atlas-browser-ai-action')!; + expect(button.textContent).toBe(''); + expect(button.querySelector('svg[aria-hidden="true"]')).not.toBeNull(); + expect(button.querySelector(':scope > span[aria-hidden="true"]')).not.toBeNull(); + expect(button.dataset.state).toBe('active'); + expect(button.getAttribute('aria-label')).toBe('Local agent settings: Agent active, Qwen 3.5 0.8B'); + expect(button.title).toBe('Agent active · Qwen 3.5 0.8B. Configure the browser-local agent.'); await act(async () => button.click()); expect(settings).toHaveBeenCalledOnce(); expect(toggle).not.toHaveBeenCalled(); expect(container.querySelector('[role="menu"]')).toBeNull(); }); - it('omits the model label when no model is selected', async () => { + it('omits the model from the label when no model is selected', async () => { await render(props({ agentState: 'off', onOpenBrowserAi: vi.fn() })); - expect(container.querySelector('.atlas-agent-model-name')).toBeNull(); + expect(container.querySelector('.atlas-browser-ai-action')?.getAttribute('aria-label')).toBe('Local agent settings: Agent off'); }); - it.each([['off', 'Enable agent'], ['active', 'Agent active'], ['busy', 'Agent working'], ['loading', 'Agent loading'], ['error', 'Agent error']] as const)('shows truthful local agent state %s and opens settings beside the project', async (agentState, label) => { + it.each([['off', 'Agent off'], ['active', 'Agent active'], ['busy', 'Agent working'], ['loading', 'Agent loading'], ['error', 'Agent error']] as const)('shows truthful local agent state %s and opens settings beside the project', async (agentState, label) => { const settings = vi.fn(); await render(props({ agentState, onOpenBrowserAi: settings, projectSwitcher: })); - const button = container.querySelector('[aria-label="Local agent settings"]')!; - expect(button.textContent).toBe(label); + const button = container.querySelector('.atlas-browser-ai-action')!; + expect(button.dataset.state).toBe(agentState); + expect(button.getAttribute('aria-label')).toBe(`Local agent settings: ${label}`); + expect(button.title).toContain(label); expect(button.parentElement?.previousElementSibling?.textContent).toBe('Project'); await act(async () => button.click()); expect(settings).toHaveBeenCalledOnce(); @@ -124,7 +130,7 @@ describe('AtlasChrome', () => { expect(group?.querySelectorAll('button')).toHaveLength(2); const draft = container.querySelector('[aria-label="Chat draft"]')!; draft.value = 'retained draft'; - const settingsButton = group?.querySelector('[aria-label="Local agent settings"]')!; + const settingsButton = group?.querySelector('.atlas-browser-ai-action')!; const openButton = group?.querySelector('[aria-label="Open chat"]')!; expect(openButton.getAttribute('aria-expanded')).toBe('false'); await act(async () => settingsButton.click()); diff --git a/graph-ui/src/app/AtlasChrome.tsx b/graph-ui/src/app/AtlasChrome.tsx index bc8cfc157..becdfc58e 100644 --- a/graph-ui/src/app/AtlasChrome.tsx +++ b/graph-ui/src/app/AtlasChrome.tsx @@ -677,13 +677,15 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element { ))} {props.projectSwitcher} - {(props.onOpenBrowserAi !== undefined || props.onToggleBrowserAi !== undefined) &&
- {props.onOpenBrowserAi !== undefined && } - {props.onToggleBrowserAi !== undefined && }
} diff --git a/graph-ui/src/app/agent-status.css b/graph-ui/src/app/agent-status.css index 278217f1e..4f1a442a2 100644 --- a/graph-ui/src/app/agent-status.css +++ b/graph-ui/src/app/agent-status.css @@ -7,10 +7,32 @@ } .atlas-agent-controls .atlas-browser-ai-action { + position: relative; + justify-content: center; + min-width: 36px; + padding: 6px 8px; border: 0; border-radius: 5px 0 0 5px; } +.atlas-agent-controls .atlas-browser-ai-action:hover { + background: var(--atlas-select); + color: var(--atlas-fg); +} + +.atlas-agent-controls .atlas-browser-ai-action > span { + position: absolute; + top: 4px; + right: 5px; + width: 7px; + height: 7px; + box-shadow: 0 0 0 2px var(--atlas-bg); +} + +.atlas-agent-controls .atlas-browser-ai-action[data-state=active] > span { + box-shadow: 0 0 0 2px var(--atlas-bg), 0 0 8px var(--atlas-glow); +} + .atlas-agent-chat-toggle { display: grid; place-items: center; @@ -32,13 +54,3 @@ .atlas-agent-chat-toggle-pane { fill: transparent; } .atlas-agent-chat-toggle[aria-expanded="true"] .atlas-agent-chat-toggle-pane { fill: currentColor; opacity: .3; } .atlas-agent-controls button:focus-visible { outline: 2px solid var(--atlas-phosphor); outline-offset: 2px; } - -.atlas-agent-model-name { - max-width: 22ch; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--atlas-dim); - font-size: 10px; - font-weight: 400; -} diff --git a/graph-ui/src/app/workspace-strings.ts b/graph-ui/src/app/workspace-strings.ts index 4b3fe5c59..f4b888f38 100644 --- a/graph-ui/src/app/workspace-strings.ts +++ b/graph-ui/src/app/workspace-strings.ts @@ -48,6 +48,13 @@ export const workspaceStrings = { changeLater: 'Switch workspaces at any time.', localAi: 'Set up browser AI', browserAi: 'Chat', + agentControls: 'Local agent', + agentStatus: { off: 'Agent off', loading: 'Agent loading', active: 'Agent active', busy: 'Agent working', error: 'Agent error' }, + /** The header shows a robot and a lamp; state and model are read out and shown on hover. */ + agentSettings: (status: string, model?: string) => `Local agent settings: ${status}${model ? `, ${model}` : ''}`, + agentSettingsTitle: (status: string, model?: string) => `${status}${model ? ` · ${model}` : ''}. Configure the browser-local agent.`, + openChat: 'Open chat', + hideChat: 'Hide chat', daemon: 'Daemon', daemonNavigation: (state: string) => `Open System: daemon ${state}`, chatWidth: 'Width of local chat',