fix(desktop): drop native title= from all 24 button sites the fixed guard flags
Convert every `<button>`/`<Button>` that carried a native `title=` under apps/desktop/src, per the DESIGN.md rule (tips only where hover teaches something new; otherwise `aria-label` for a11y): - `aria-label` swap where the visible affordance already teaches the action (zoom/lightbox triggers, delete icon, install "+", reference thumbnail). - `title=` deleted where an equal `aria-label` or visible text already exists (remove/disconnect trash icons, send arrow, stop glyph, Download button, MCP tool chips with `aria-pressed`, Lock face + its caption, pet toggles, spectator bubble with `aria-expanded`, Create Group whose dialog copy already says "Pick 2–N bots" — a tip on a `disabled` Button never opens anyway). - `<Tip>` where hover reveals something not on screen: full path on changed files rows (`key` on the Tip inside the map), "Restore checkpoint — rerun from this prompt", "Disconnect (runs the removal command in the terminal)", group-chat attach hint and the speaker-handle toggle. Guard hardening on top of the salvaged scanner: skip `//` and `/* */` comments inside the opening tag (an apostrophe in `// Don't steal focus…` between attributes otherwise opens a phantom string and hides `title=` — two shipped sites were invisible that way), skip `*.test.tsx` fixtures, and match `title=` as its own attribute (not `data-title=`). One fixture test pins those cases. Co-authored-by: wnuuee1 <poli.koltsova@gmail.com>
This commit is contained in:
@@ -192,7 +192,6 @@ export function PetInlineToggle() {
|
||||
onClick={toggle}
|
||||
// Don't steal focus from the search input on click.
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
title={enabled ? copy.turnOff : copy.turnOn}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
|
||||
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
|
||||
<button aria-label={t.desktop.openImage} className="shrink-0" onClick={() => setViewing(true)} type="button">
|
||||
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -469,7 +469,6 @@ export function PetOverlayApp() {
|
||||
top: 0,
|
||||
width: 24
|
||||
}}
|
||||
title="Open in Hermes"
|
||||
type="button"
|
||||
>
|
||||
<Mail style={{ height: 13, width: 13 }} />
|
||||
|
||||
@@ -589,7 +589,6 @@ export function AppearanceSettings() {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
|
||||
@@ -122,7 +122,6 @@ export function KeyField({
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title={t.settings.credentials.remove}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -276,11 +276,11 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
</Button>
|
||||
{endpoint.source !== 'direct-config' && (
|
||||
<Button
|
||||
aria-label={t.settings.customEndpoints.deleteEndpoint}
|
||||
className="hover:text-destructive"
|
||||
disabled={deleting === endpoint.id}
|
||||
onClick={() => void handleDelete(endpoint)}
|
||||
size="icon-sm"
|
||||
title={t.settings.customEndpoints.deleteEndpoint}
|
||||
variant="ghost"
|
||||
>
|
||||
{deleting === endpoint.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
@@ -282,7 +283,6 @@ function ConnectedProviderRow({
|
||||
disabled={disconnecting}
|
||||
onClick={() => onDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={`${t.common.remove} ${title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -290,16 +290,17 @@ function ConnectedProviderRow({
|
||||
</Button>
|
||||
)}
|
||||
{terminalDisconnect && (
|
||||
<Button
|
||||
aria-label={`${copy.disconnect} ${title}`}
|
||||
onClick={() => onTerminalDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={copy.disconnectInTerminal}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
<Tip label={copy.disconnectInTerminal}>
|
||||
<Button
|
||||
aria-label={`${copy.disconnect} ${title}`}
|
||||
onClick={() => onTerminalDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1330,7 +1330,6 @@ function ServerConfig({
|
||||
disabled={!saved}
|
||||
key={tool.name}
|
||||
onClick={() => onToggleTool(tool.name)}
|
||||
title={on ? m.disableTool(tool.name) : m.enableTool(tool.name)}
|
||||
type="button"
|
||||
>
|
||||
{tool.name}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import { FadeScroll } from '@/components/ui/fade-scroll'
|
||||
import { FileTypeIcon } from '@/components/ui/file-type-icon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { displayPath } from '@/lib/display-path'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -57,17 +58,17 @@ export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) =
|
||||
</div>
|
||||
<FadeScroll className="-mx-1.5 mt-1.5 flex flex-col overscroll-y-auto px-1.5" maxHeight={MAX_ROWS_HEIGHT}>
|
||||
{files.map(file => (
|
||||
<button
|
||||
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
|
||||
key={file.path}
|
||||
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
|
||||
title={displayPath(file.path)}
|
||||
type="button"
|
||||
>
|
||||
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
|
||||
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
|
||||
<DiffCount added={file.added} removed={file.removed} />
|
||||
</button>
|
||||
<Tip key={file.path} label={displayPath(file.path)}>
|
||||
<button
|
||||
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
|
||||
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
|
||||
type="button"
|
||||
>
|
||||
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
|
||||
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
|
||||
<DiffCount added={file.added} removed={file.removed} />
|
||||
</button>
|
||||
</Tip>
|
||||
))}
|
||||
</FadeScroll>
|
||||
</div>
|
||||
|
||||
@@ -921,7 +921,6 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
||||
// the edit silently never sends. The restore button guards the
|
||||
// same way.
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
title={copy.sendEdited}
|
||||
type="button"
|
||||
>
|
||||
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/type
|
||||
import { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions'
|
||||
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -463,7 +464,6 @@ export const UserMessage: FC<{
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
@@ -511,33 +511,33 @@ export const UserMessage: FC<{
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
<Tip label={copy.restoreFromHere}>
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -134,9 +134,9 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
|
||||
)}
|
||||
{src && (
|
||||
<button
|
||||
aria-label={copy.openImage}
|
||||
className="absolute inset-0 block size-full cursor-zoom-in"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
title={copy.openImage}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -138,7 +138,6 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
|
||||
className="flex shrink-0 items-center gap-1 rounded-md border border-(--ui-stroke-tertiary) bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
|
||||
disabled={downloading}
|
||||
onClick={() => void downloadFile()}
|
||||
title={t.fileMenu.download}
|
||||
type="button"
|
||||
>
|
||||
<Download className="size-3" />
|
||||
|
||||
@@ -32,10 +32,10 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
|
||||
data-slot={slot ?? 'aui_zoomable-image'}
|
||||
>
|
||||
<button
|
||||
aria-label={canOpen ? copy.openImage : undefined}
|
||||
className="contents"
|
||||
disabled={!canOpen}
|
||||
onClick={() => canOpen && setLightboxOpen(true)}
|
||||
title={canOpen ? copy.openImage : undefined}
|
||||
type="button"
|
||||
>
|
||||
<img alt={alt ?? ''} className={className} src={src} {...props} />
|
||||
@@ -125,7 +125,6 @@ export function ImageActionButton({
|
||||
event.stopPropagation()
|
||||
void onClick()
|
||||
}}
|
||||
title={saving ? copy.savingImage : copy.downloadImage}
|
||||
type="button"
|
||||
>
|
||||
<Download className={cn('size-4', saving && 'animate-pulse')} />
|
||||
|
||||
@@ -7,12 +7,12 @@ import { describe, expect, it } from 'vitest'
|
||||
// may use the native HTML `title=` attribute. Native tooltips are unstyled,
|
||||
// delayed (~500ms OS default), and visually inconsistent with the themed `Tip`.
|
||||
// When a tip is warranted (see DESIGN.md — not every icon, never menu triggers),
|
||||
// use `<Tip label={...}>` instead of `title=`.
|
||||
// use `<Tip label={...}>` instead of `title=`; otherwise keep an `aria-label`.
|
||||
//
|
||||
// This is a source-text scan, not a behavior test — it's the same category as
|
||||
// an ESLint rule, expressed as a vitest so it runs with the rest of the suite.
|
||||
|
||||
// Recursively walk a directory and collect all .tsx file paths.
|
||||
// Recursively walk a directory and collect all shipped .tsx file paths.
|
||||
function collectTsxFiles(dir: string): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
@@ -27,7 +27,9 @@ function collectTsxFiles(dir: string): string[] {
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...collectTsxFiles(fullPath))
|
||||
} else if (entry.endsWith('.tsx')) {
|
||||
} else if (entry.endsWith('.tsx') && !entry.endsWith('.test.tsx')) {
|
||||
// Test fixtures (`<button title={title}>` stand-ins for SDK components)
|
||||
// are not shipped UI.
|
||||
results.push(fullPath)
|
||||
}
|
||||
}
|
||||
@@ -35,67 +37,75 @@ function collectTsxFiles(dir: string): string[] {
|
||||
return results
|
||||
}
|
||||
|
||||
describe('no native title= on button elements', () => {
|
||||
// Scan every .tsx file under src/ for <button or <Button opening tags that
|
||||
// also carry a title= attribute (anywhere in the opening tag, which may span
|
||||
// multiple lines).
|
||||
it('uses <Tip> instead of native title= on all button elements', () => {
|
||||
const violations: string[] = []
|
||||
const srcDir = resolve(__dirname, '../../..')
|
||||
|
||||
for (const filePath of collectTsxFiles(srcDir)) {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
const relativePath = filePath.replace(srcDir + '/', '')
|
||||
|
||||
// Match <Button ...> or <button ...> opening tags (may span multiple lines).
|
||||
// Scan to the brace-depth-0 `>` so a `>` inside a JSX expression (e.g.
|
||||
// `onClick={() => ...}`) doesn't truncate the tag and hide a later
|
||||
// title= (#113688). String literals are skipped the same way.
|
||||
for (const { tagName, attrs, index } of eachButtonOpenTag(content)) {
|
||||
if (/\btitle=/.test(attrs)) {
|
||||
const lineNum = content.slice(0, index).split('\n').length
|
||||
violations.push(`${relativePath}:${lineNum} <${tagName}> has title= — use <Tip>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations, violations.join('\n')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function eachButtonOpenTag(content: string): Array<{ tagName: string; attrs: string; index: number }> {
|
||||
const tags: Array<{ tagName: string; attrs: string; index: number }> = []
|
||||
const openPattern = /<(Button|button)\b/gsu
|
||||
/** Every `<button …>` / `<Button …>` opening tag in `content`, with the
|
||||
* attribute text up to the tag's real closing `>`. */
|
||||
function eachButtonOpenTag(content: string): Array<{ attrs: string; index: number; tagName: string }> {
|
||||
const tags: Array<{ attrs: string; index: number; tagName: string }> = []
|
||||
const openPattern = /<(Button|button)\b/gu
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = openPattern.exec(content)) !== null) {
|
||||
const end = findBraceDepthZeroClose(content, match.index + match[0].length)
|
||||
const attrsStart = match.index + match[0].length
|
||||
const end = findTagClose(content, attrsStart)
|
||||
|
||||
if (end < 0) {
|
||||
continue
|
||||
}
|
||||
tags.push({ tagName: match[1], attrs: content.slice(match.index, end), index: match.index })
|
||||
|
||||
tags.push({ attrs: content.slice(attrsStart, end), index: match.index, tagName: match[1] })
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
function findBraceDepthZeroClose(content: string, start: number): number {
|
||||
// The opening tag ends at the first `>` outside every `{…}` expression, string
|
||||
// literal and comment. A plain `[^>]*?>` stops inside `onClick={() =>` and
|
||||
// hides any `title=` written after the handler (#113688). Comments matter too:
|
||||
// a `// …doesn't…` note between attributes would otherwise open a quote.
|
||||
function findTagClose(content: string, start: number): number {
|
||||
let depth = 0
|
||||
let quote: string | null = null
|
||||
let quote: null | string = null
|
||||
|
||||
for (let i = start; i < content.length; i++) {
|
||||
const char = content[i]
|
||||
|
||||
if (quote) {
|
||||
if (char === quote && content[i - 1] !== '\\') {
|
||||
if (char === '\\') {
|
||||
i++
|
||||
} else if (char === quote) {
|
||||
quote = null
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && content[i + 1] === '/') {
|
||||
const eol = content.indexOf('\n', i)
|
||||
|
||||
if (eol < 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
i = eol
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/' && content[i + 1] === '*') {
|
||||
const close = content.indexOf('*/', i + 2)
|
||||
|
||||
if (close < 0) {
|
||||
return -1
|
||||
}
|
||||
|
||||
i = close + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' || char === "'" || char === '`') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (char === '{') {
|
||||
} else if (char === '{') {
|
||||
depth++
|
||||
} else if (char === '}') {
|
||||
depth--
|
||||
@@ -106,3 +116,62 @@ function findBraceDepthZeroClose(content: string, start: number): number {
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
// `title=` as an attribute of its own — not `data-title=` / `subtitle=`.
|
||||
const TITLE_ATTR = /(?<![\w.-])title=/u
|
||||
|
||||
function titleViolations(content: string, relativePath: string): string[] {
|
||||
const violations: string[] = []
|
||||
|
||||
for (const { attrs, index, tagName } of eachButtonOpenTag(content)) {
|
||||
if (TITLE_ATTR.test(attrs)) {
|
||||
const lineNum = content.slice(0, index).split('\n').length
|
||||
|
||||
violations.push(`${relativePath}:${lineNum} <${tagName}> has title= — use <Tip> or aria-label`)
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
describe('no native title= on button elements', () => {
|
||||
it('sees title= after inline handlers and inside comment-bearing tags, and nowhere else', () => {
|
||||
const flagged = [
|
||||
'<button onClick={() => {}} title="probe">hi</button>',
|
||||
'<Button\n onClick={event => {\n event.preventDefault()\n }}\n // macOS doesn\'t focus a button on mousedown\n title={copy.send}\n type="button"\n>',
|
||||
"<button className={cn(saved ? 'a' : 'b')} title={on ? m.off(t) : m.on(t)}>"
|
||||
]
|
||||
|
||||
const clean = [
|
||||
'<button aria-label="a > b" onClick={() => {}}>hi</button>',
|
||||
'<button data-title="x" onClick={() => {}}>hi</button>',
|
||||
'<button onClick={() => {}}>{`title=${x}`}</button>',
|
||||
'<span title="fine"><button type="button">hi</button></span>'
|
||||
]
|
||||
|
||||
for (const source of flagged) {
|
||||
expect(titleViolations(source, 'probe.tsx'), source).toHaveLength(1)
|
||||
}
|
||||
|
||||
for (const source of clean) {
|
||||
expect(titleViolations(source, 'probe.tsx'), source).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// Scan every shipped .tsx file under src/ for <button or <Button opening tags
|
||||
// that also carry a title= attribute (anywhere in the opening tag, which may
|
||||
// span multiple lines).
|
||||
it('uses <Tip> or aria-label instead of native title= on all button elements', () => {
|
||||
const violations: string[] = []
|
||||
const srcDir = resolve(__dirname, '../../..')
|
||||
|
||||
for (const filePath of collectTsxFiles(srcDir)) {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
const relativePath = filePath.replace(srcDir + '/', '')
|
||||
|
||||
violations.push(...titleViolations(content, relativePath))
|
||||
}
|
||||
|
||||
expect(violations, violations.join('\n')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('Zoomable', () => {
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('overlay')).toBeNull()
|
||||
fireEvent.click(screen.getByTitle('Open diagram'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open diagram' }))
|
||||
expect(screen.getByTestId('overlay')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('Zoomable', () => {
|
||||
</Zoomable>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Open diagram'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open diagram' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const body = dialog.firstElementChild
|
||||
|
||||
@@ -35,9 +35,9 @@ export function Zoomable({ children, overlay, onCopy, label = 'Open full view',
|
||||
<div className={cn('group/zoomable relative', className)}>
|
||||
{/* The whole content is the trigger — click anywhere to open, like an image. */}
|
||||
<button
|
||||
aria-label={label}
|
||||
className="block w-full cursor-zoom-in text-left"
|
||||
onClick={() => setOpen(true)}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -192,7 +192,6 @@ export function AvatarPicker({ shape, color, image, onShape, onColor, onImage, g
|
||||
<Button
|
||||
onClick={() => onShape(blobShapeString(locked ? '' : pickerName, kind))}
|
||||
size="sm"
|
||||
title={locked ? b.avatar.unlockFollowsName : 'Keep this exact face even if the name changes'}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -1347,11 +1347,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
|
||||
<Button onClick={onClose} variant="secondary">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreate}
|
||||
onClick={create}
|
||||
title={selected.length < 2 ? 'Pick at least 2 bots' : undefined}
|
||||
>{`Create Group${selected.length ? ` (${selected.length})` : ''}`}</Button>
|
||||
<Button disabled={!canCreate} onClick={create}>{`Create Group${selected.length ? ` (${selected.length})` : ''}`}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -962,16 +962,18 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
|
||||
}
|
||||
|
||||
const attachButton = (thread: null | string) => (
|
||||
<Button
|
||||
className="shrink-0 text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => void pickGroupAttachments().then(picked => addImages(thread, picked))}
|
||||
size="sm"
|
||||
title={b.group.attachHint}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="attach" />
|
||||
</Button>
|
||||
<Tip label={b.group.attachHint}>
|
||||
<Button
|
||||
aria-label={b.group.attachHint}
|
||||
className="shrink-0 text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => void pickGroupAttachments().then(picked => addImages(thread, picked))}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="attach" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
|
||||
// #91359: recognized @mentions render as inline references; recomputed
|
||||
@@ -1076,15 +1078,16 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
|
||||
{isUser ? (
|
||||
<span className="text-[0.7rem] font-semibold text-foreground">{label}</span>
|
||||
) : (
|
||||
<Button
|
||||
className="text-left text-[0.7rem] font-semibold text-(--ui-accent)"
|
||||
onClick={() => setRevealedSpeaker(revealed ? null : entryKey)}
|
||||
size="inline"
|
||||
title={revealed ? 'Hide full handle' : 'Show full handle'}
|
||||
variant="text"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Tip label={revealed ? 'Hide full handle' : 'Show full handle'}>
|
||||
<Button
|
||||
className="text-left text-[0.7rem] font-semibold text-(--ui-accent)"
|
||||
onClick={() => setRevealedSpeaker(revealed ? null : entryKey)}
|
||||
size="inline"
|
||||
variant="text"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{relativeTime(entry.at)}</span>
|
||||
{entry.text.trim() || !isUser ? (
|
||||
|
||||
@@ -252,11 +252,11 @@ export function HubSkillsSection({ forProfile, onInstalled }: HubSkillsSectionPr
|
||||
<span className="shrink-0 text-[0.65rem] text-(--ui-text-tertiary)">✓ added</span>
|
||||
) : (
|
||||
<Button
|
||||
aria-label={`Install "${r.name}" and add it to the list above`}
|
||||
className="shrink-0 px-2 font-semibold"
|
||||
disabled={installing !== null}
|
||||
onClick={() => void install(r.name)}
|
||||
size="sm"
|
||||
title={`Install "${r.name}" and add it to the list above`}
|
||||
variant="ghost"
|
||||
>
|
||||
{installing === r.name ? '…' : '+'}
|
||||
|
||||
Reference in New Issue
Block a user