From a83d868abe4fdc7856e248a794b9d23b84321c0a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 16:05:41 +0200 Subject: [PATCH 1/3] fix(ui): point Button at the control-* tokens it was only aliasing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Button`'s `outline` and `ghost` variants hardcoded `border-border`, `bg-transparent`, and `text-ink-dim` — the values the `control-*` family happens to alias to in styles.css, rather than the `control-*` tokens themselves. That renders identically under every built-in theme, which is why it survived. It is wrong anyway: customAppearance.ts exposes all seven `control-*` tokens as independently user-editable and documents them as button chrome, and applyCustomAppearance writes them as inline custom properties on . A user who edited controlFg got every hand-rolled control button in the tree honouring it while every + {status.configured && status.source !== 'env' ? ( - + ) : null} diff --git a/src/renderer/src/features/voice-dictation/DictationGuideModal.tsx b/src/renderer/src/features/voice-dictation/DictationGuideModal.tsx index 28cf2ab4..e7c2cb70 100644 --- a/src/renderer/src/features/voice-dictation/DictationGuideModal.tsx +++ b/src/renderer/src/features/voice-dictation/DictationGuideModal.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' + +import { DialogActions } from '@renderer/components/ui/dialog-actions' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog' // Modal walking the user through Deepgram signup + key configuration. // @@ -10,6 +19,29 @@ import { useCallback, useEffect, useRef, useState } from 'react' // fully local, and reuses the same overlay stack every other Agent Code // command modal uses. // +// WHY this is a `DialogContent` and no longer a hand-rolled overlay: +// +// This file used to implement its own Escape handler, its own Tab focus +// trap, its own `fixed inset-0` backdrop, its own focus restoration, and it +// copied `data-agent-code-interaction-owner="app"` onto its root by hand. +// components/ui/README.md forbids every one of those by name — they are +// properties of DialogContent, not properties each feature reinterprets, and +// this was the last hand-rolled app modal in the tree. +// +// The hand-rolled trap was also subtly wrong in a way Radix's FocusScope is +// not: it queried focusable nodes once per keydown off a static selector, so +// the `` links inside the guide body participated but anything mounted +// into a nested portal would not have. Deleting it removes the divergence +// rather than fixing it twice. +// +// The one behaviour deliberately NOT carried over is the window-capture +// swallow of Cmd/Option chords. That existed because a hand-rolled overlay +// has no way to tell the workspace shortcut router "an app surface owns the +// turn". DialogContent mounts the ownership marker for exactly its own +// lifetime, and the router already checks that marker synchronously, so the +// chord suppression is now structural instead of a second listener racing +// the first. +// // WHY placeholder screenshots instead of committed assets: // // The user has final say on which screenshots ship — they will match a @@ -18,7 +50,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' // keep the layout obviously incomplete instead of "silently missing". export function DictationGuideModal() { const [open, setOpen] = useState(false) - const dialogRef = useRef(null) useEffect(() => { const onOpen = () => setOpen(true) @@ -26,101 +57,23 @@ export function DictationGuideModal() { return () => window.removeEventListener('agent-code:open-dictation-guide', onOpen) }, []) - const close = useCallback(() => setOpen(false), []) - useEffect(() => { - if (!open) return - const previouslyFocused = document.activeElement instanceof HTMLElement - ? document.activeElement - : null - const focusFrame = requestAnimationFrame(() => dialogRef.current?.focus()) - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - close() - return - } - if (event.key === 'Tab') { - const dialog = dialogRef.current - if (!dialog) return - const focusable = Array.from( - dialog.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', - ), - ) - if (focusable.length === 0) { - event.preventDefault() - dialog.focus() - return - } - const current = focusable.indexOf(document.activeElement as HTMLElement) - const next = event.shiftKey - ? current <= 0 ? focusable.length - 1 : current - 1 - : current === -1 || current === focusable.length - 1 ? 0 : current + 1 - event.preventDefault() - event.stopPropagation() - focusable[next]?.focus() - return - } - // The workspace shortcut router listens on document capture. Capturing - // app-owned Cmd/Option chords one level earlier (window) prevents a guide - // opened above the workspace from splitting/closing panes underneath it. - // Ordinary keys continue to the dialog; type/paste ingress is blocked by - // the explicit ownership marker on the dialog root below. - if (event.metaKey || event.altKey) { - event.preventDefault() - event.stopPropagation() - } - } - window.addEventListener('keydown', onKey, { capture: true }) - return () => { - cancelAnimationFrame(focusFrame) - window.removeEventListener('keydown', onKey, { capture: true }) - previouslyFocused?.focus() - } - }, [open, close]) - - if (!open) return null return ( -
{ - if (event.target === event.currentTarget) close() - }} - > -
-
-

- Configure Voice Dictation -

- -
- -
-

- Agent Code's inline dictation streams audio to{' '} - Deepgram for transcription. New - Deepgram accounts get $200 in free credits, which is generally enough - for very long-term personal use. Follow the three steps below to get - up and running. -

+ + {/* Wider than the 520px default and height-capped: this is three + illustrated steps rather than a confirm prompt, so the body scrolls + inside the dialog instead of letting the surface grow past the + viewport. */} + + + Configure Voice Dictation + + Agent Code's inline dictation streams audio to Deepgram for + transcription. New Deepgram accounts get $200 in free credits, which + is generally enough for very long-term personal use. + + +
-
- -
-
-
+ {/* Acknowledgement-only footer: there is nothing to cancel, so this is + the confirm-only shape DialogActions supports via omitting + onCancel. confirmOnEnter stays on — the body is prose and links, + nothing in it owns Enter. */} + setOpen(false)} /> + + ) } diff --git a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx index f9b8d2f5..ad8801f5 100644 --- a/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx +++ b/src/renderer/src/features/workspace/ui/NewAgentPlacementOverlay.tsx @@ -5,6 +5,7 @@ import { } from '@shared/types/providerKind' import type { AgentProviderKind } from '@shared/types/providerKind' import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' +import { Button } from '@renderer/components/ui/button' import { useEffect, useMemo, useRef, useState } from 'react' import { @@ -457,13 +458,17 @@ export function NewAgentPlacementOverlay({ itself. Placed here rather than in the kind picker because the placement step is exactly the state that had no way out. */} {selectedKind ? ( - + ) : null}
@@ -513,13 +518,9 @@ export function NewAgentPlacementOverlay({ mouse-first user actually looks for — and this is the only step Dispatch and linked-agent mode ever show. */}
- +
diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/ComposerActions.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/ComposerActions.tsx index a635da95..8db61e2e 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/ComposerActions.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/ComposerActions.tsx @@ -1,3 +1,4 @@ +import { Button } from '@renderer/components/ui/button' import type { DictationStatus } from '@shared/types/dictation' // ComposerActions — pointer-clickable Send and Stop, shown only in Mouse Mode. @@ -86,8 +87,9 @@ export function ComposerActions({ return (
{working ? ( - + ) : null}
- +
) @@ -169,24 +167,29 @@ export function DictationHistoryRow() {
{confirming === null ? (
- - +
) : (
- - +
)}
@@ -328,22 +334,19 @@ function HistoryRow({

{entry.text}

- - +
) : null} From a028dc906b15917b9d204119816fdb0dd6d5af9e Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 28 Jul 2026 16:58:50 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(ui):=20address=20review=20=E2=80=94=20t?= =?UTF-8?q?wo=20missed=20buttons,=20secondary's=20border,=20a=20danger=20v?= =?UTF-8?q?ariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the orchestrated contract review of this PR. Every item below was re-verified against the code before acting on it. 1. SettingsList's `action` arm was left hand-rolled on the grounds that its ternary needed a conditional class. Wrong: that ternary switches on TONE, not on a pressed/selected state, so both arms are ordinary variants. The carve-out only ever applied to toggles. 2. AgentCodeConventionsRow's On/Off toggle was missed entirely, and the reason is worth recording: the completeness check grepped `bg-control-bg`, and this site carries `border-control-border` + `text-control-fg` WITHOUT it. Any future sweep should grep `text-control-fg` too. 3. `secondary` now takes `control-border`. The original reasoning was half right — there genuinely is no `control-*` token for "raised resting" fill or for its higher-contrast text, so `bg-surface-hi`/`text-ink` stay. But `controlBorder` is documented as "Resting control border" and a secondary button is a control, so leaving it on the generic `border` token left the exact defect this PR exists to fix, half-fixed. SettingsPage renders an `outline` Close and a `secondary` Cancel in one view; they must not disagree. 4. New `destructive-outline` variant. Five call sites had independently written some spelling of "outline chrome, danger text", and each had to actively CANCEL `outline`'s `hover:text-ink` — that cancellation is the signal it was a variant rather than feature layout. Four move onto it. ComposerActions' Stop deliberately does NOT: `destructive-outline` colours text danger at rest, and Stop sits permanently beside Send while an agent runs, where a permanently red control reads as an error state rather than an available action. It keeps hover-only danger, with a comment saying why. 5. Corrected counts. The "17 files import Button" figure was measured on a stale branch; `main` has 20 (renderer) / 24 (all of src). That number was baked into button.tsx's comment, where a wrong figure is worse than in a doc because the comment is the durable artifact. Plan-doc audit numbers likewise corrected (223 raw buttons excluding tests, checkbox x7) and the counting method recorded so they are reproducible. The "provider renderers have zero raw buttons" line was ambiguous — the MODALS have zero; the surface as a whole has 13 in feed rows. Reworded. Separately verified while resolving, both by reading and empirically: - The dropped Cmd/Option swallow in DictationGuideModal is genuinely redundant. useKeybinds.ts:338-363 consults hasAppInteractionOwner() in CAPTURE phase ahead of every workspace shortcut and returns unconditionally, and shouldPreventOwnedApplicationShortcut covers the macOS-default preventDefault the old hand-rolled listener was doing. - `cn` is twMerge(clsx(...)), and every className override in this PR beats the variant class it conflicts with. Checked by running tailwind-merge over the actual class pairs, not by assuming argument order. Verified: `npm run typecheck` clean, `npm run test:renderer` 60 files / 262 tests passed. Co-Authored-By: Claude Opus 5 --- __dlg-main.js | 31 +++++++++++++++++++ __dlg.html | 30 ++++++++++++++++++ ...-07-28-ui-primitive-theme-fidelity-plan.md | 22 ++++++++----- src/renderer/src/components/ui/button.tsx | 31 +++++++++++++++---- .../settings/ui/AgentCodeConventionsRow.tsx | 11 ++++--- .../src/features/settings/ui/SettingsList.tsx | 16 +++++----- .../voice-dictation/DictationApiKeyRow.tsx | 7 +---- .../voice-dictation/DictationHistoryRow.tsx | 19 ++++++------ .../tile-tree/TileLeaf/ComposerActions.tsx | 8 +++-- 9 files changed, 131 insertions(+), 44 deletions(-) create mode 100644 __dlg-main.js create mode 100644 __dlg.html diff --git a/__dlg-main.js b/__dlg-main.js new file mode 100644 index 00000000..b1bada37 --- /dev/null +++ b/__dlg-main.js @@ -0,0 +1,31 @@ +const { app, BrowserWindow } = require('electron') +const fs = require('fs') +const path = require('path') +app.disableHardwareAcceleration() +const html = fs.readFileSync(path.join(__dirname, '__dlg.html'), 'utf8') +app.whenReady().then(async () => { + const results = [] + for (const [w, h] of [[1440, 900], [1280, 700], [900, 500], [800, 380]]) { + const win = new BrowserWindow({ width: w, height: h, show: false }) + await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html)) + const r = await win.webContents.executeJavaScript(`(() => { + const c = document.getElementById('content') + const b = document.getElementById('body') + const cr = c.getBoundingClientRect() + return { + viewport: innerHeight, + contentH: Math.round(cr.height), + contentTop: Math.round(cr.top), + contentBottom: Math.round(cr.bottom), + bodyClientH: b.clientHeight, + bodyScrollH: b.scrollHeight, + bodyScrolls: b.scrollHeight > b.clientHeight + 1, + overflowsViewport: cr.top < -0.5 || cr.bottom > innerHeight + 0.5, + } + })()`) + results.push({ win: w + 'x' + h, ...r }) + win.destroy() + } + console.log('RESULTS ' + JSON.stringify(results)) + app.quit() +}).catch(e => { console.log('ERR ' + e.message); app.quit() }) diff --git a/__dlg.html b/__dlg.html new file mode 100644 index 00000000..70a810f9 --- /dev/null +++ b/__dlg.html @@ -0,0 +1,30 @@ + +
+
Configure Voice Dictation
Agent Code's inline dictation streams audio to Deepgram for transcription. New Deepgram accounts get $200 in free credits, which is generally enough for very long-term personal use.
+
+

1 Create a Deepgram account

Open console.deepgram.com/signup and finish the signup — the $200 credit is applied automatically.

+

2 Create a project API key

From the console, open API Keys in the sidebar, click Create a New API Key, give it the scope Member or broader, copy the string that appears once.

+

3 Paste the key into Settings

In Agent Code, open Settings → Voice Dictation, paste the key into the Deepgram API Key row, and press Save. Your key is encrypted with your system keyring.

+

A note on the hotkey.

Dictation is triggered with Cmd+Shift+D by default: press once to record and again to finish, with no OS permission required. If you prefer holding Fn like macOS system dictation, switch the shortcut in Settings; macOS will then prompt for Accessibility permission the first time you enable dictation.

+
+
+
+ diff --git a/docs/plans_and_ideas/2026-07-28-ui-primitive-theme-fidelity-plan.md b/docs/plans_and_ideas/2026-07-28-ui-primitive-theme-fidelity-plan.md index 5e97a89c..7f7bcb42 100644 --- a/docs/plans_and_ideas/2026-07-28-ui-primitive-theme-fidelity-plan.md +++ b/docs/plans_and_ideas/2026-07-28-ui-primitive-theme-fidelity-plan.md @@ -89,12 +89,17 @@ Counted across the desktop renderer, `providers/*/renderer`, and | Measure | Count | | --- | --- | -| Raw ` +
diff --git a/src/renderer/src/features/settings/ui/SettingsList.tsx b/src/renderer/src/features/settings/ui/SettingsList.tsx index 8bd07627..30eaf2b9 100644 --- a/src/renderer/src/features/settings/ui/SettingsList.tsx +++ b/src/renderer/src/features/settings/ui/SettingsList.tsx @@ -185,18 +185,18 @@ function SettingRow({ /> ) : null} + {/* The variant ternary below switches on TONE, not on a + pressed/selected state, so both arms are ordinary variants — this + is not one of the toggles that has to keep a hand-rolled + conditional class. */} {control.type === 'action' ? ( - + ) : null} {/* CLI auto-updater — the row owns its own subscription diff --git a/src/renderer/src/features/voice-dictation/DictationApiKeyRow.tsx b/src/renderer/src/features/voice-dictation/DictationApiKeyRow.tsx index 2a18be5d..9291bffa 100644 --- a/src/renderer/src/features/voice-dictation/DictationApiKeyRow.tsx +++ b/src/renderer/src/features/voice-dictation/DictationApiKeyRow.tsx @@ -133,15 +133,10 @@ export function DictationApiKeyRow() { {status.configured && status.source !== 'env' ? ( - {/* Danger as an override on `outline`, not variant="destructive": - this only OPENS a confirmation, so a filled destructive button - would overstate what the click does. The filled treatment is - reserved for the Confirm below. */} + {/* destructive-outline, not destructive: this only OPENS the + confirmation. The filled treatment is reserved for Confirm. */} @@ -338,11 +338,10 @@ function HistoryRow({ {copyState === 'copied' ? 'Copied' : copyState === 'failed' ? 'Copy failed' : 'Copy'}