diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md
index 6ec48e166..cc49d2de5 100644
--- a/docs/release-control/v6/internal/subsystems/ai-runtime.md
+++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md
@@ -52,7 +52,6 @@ leave the transcript without exposing hidden provider/tool metadata.
4. `internal/api/ai_handler.go` shared with `api-contracts`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
5. `internal/api/ai_handlers.go` shared with `api-contracts`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
6. `internal/api/ai_intelligence_handlers.go` shared with `api-contracts`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
-
## Extension Points
Guest-family AI runtime code paths (VMs and LXC system containers) share
@@ -103,6 +102,13 @@ activity has arrived, and keeping successful raw output behind the details
disclosure by default once the turn completes. Pending tools, skipped/canceled
tools, failed tools, and completed-turn tool details must remain
visible/inspectable; failed output may render inline because it is actionable.
+AI Chat icon-only actions own Assistant/session semantics, labels, and
+handlers, but their visible button chrome must compose the shared
+`ActionIconButton` primitive from `frontend-modern/src/components/shared/Button.tsx`.
+Header utilities, session row controls, transcript fallback close/download
+controls, activity-dock queued follow-up actions, composer send, footer
+help/route actions, and compact dismiss controls must not recreate local
+h-6/h-7/h-8/h-9 icon-button class shells.
Assistant live workflow status follows OpenCode's current-state timeline
model. CANONICAL (supersedes the per-row transcript-status rules elsewhere in
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 753d9d1cb..8cd2c4c97 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -557,6 +557,12 @@ not a replacement status card, CTA band, or page-local nested card.
disabled treatment, title fallback, and accessible name wiring must come
from that shared primitive rather than page-local `` plus inline SVG
shells.
+ AI Chat follows that same boundary for drawer header controls, session row
+ actions, transcript fallback close/download actions, activity-dock queued
+ follow-up controls, composer send, footer help/route actions, and compact
+ dismiss controls: the Assistant owns chat/session behavior and copy, while
+ `ActionIconButton` owns h-6/h-7/h-8/h-9 sizing, outline, primary, accent,
+ warning, info, danger, disabled, title, and focus chrome.
Global app-shell prompts are part of the same action boundary.
`frontend-modern/src/components/GitHubStarBanner.tsx` may own its display
timing, product copy, and GitHub destination, but its primary, defer, and
diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json
index 7a47f399a..9e831b4b3 100644
--- a/frontend-modern/scripts/shared-template-registry.json
+++ b/frontend-modern/scripts/shared-template-registry.json
@@ -1257,6 +1257,7 @@
{ "path": "src/components/Alerts/AlertResourceTableMobile.tsx" },
{ "path": "src/components/Alerts/AlertResourceTableRow.tsx" },
{ "path": "src/components/Alerts/ResourceTable.tsx" },
+ { "path": "src/components/AI/Chat/index.tsx" },
{ "path": "src/components/Settings/RolesPanel.tsx" },
{ "path": "src/components/Settings/SSOProvidersPanel.tsx" }
],
@@ -1290,6 +1291,21 @@
"text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5"
]
},
+ {
+ "path": "src/components/AI/Chat/index.tsx",
+ "patterns": [
+ "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted",
+ "flex-shrink-0 p-2 hover:text-base-content rounded-md hover:bg-surface-hover transition-colors",
+ "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 disabled:cursor-wait disabled:opacity-70 dark:text-blue-200 dark:hover:bg-blue-900/60",
+ "rounded p-1 text-muted opacity-0 transition-opacity hover:bg-blue-100 hover:text-blue-600 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-blue-900 dark:hover:text-blue-300",
+ "order-2 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md hover:text-base-content hover:bg-surface-hover transition-colors sm:order-none",
+ "flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-200 dark:hover:bg-amber-900",
+ "flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900",
+ "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60",
+ "flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-45",
+ "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content focus:outline-none focus:ring-2 focus:ring-blue-500/30"
+ ]
+ },
{
"path": "src/components/Settings/SSOProvidersPanel.tsx",
"patterns": [
@@ -3170,6 +3186,136 @@
"scripts/shared-template-audit.mjs"
]
},
+ {
+ "id": "ai-chat-action-icon-header-local-shell",
+ "category": "action-button",
+ "summary": "AI Chat header icon actions must not recreate local h-8 bordered icon-button shells; compose ActionIconButton.",
+ "canonical": {
+ "path": "src/components/shared/Button.tsx",
+ "export": "ActionIconButton"
+ },
+ "scopes": ["src/components/AI/Chat"],
+ "extensions": [".tsx"],
+ "allPatterns": [
+ "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content"
+ ],
+ "legacyReason": "Retired migration debt. AI Chat header icon actions belong to ActionIconButton.",
+ "allowedPaths": [],
+ "ignoredPaths": [
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "src/components/shared/Button.test.tsx"
+ ],
+ "proof": [
+ "src/components/shared/SharedPrimitives.guardrails.test.ts",
+ "src/components/shared/Button.test.tsx",
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "scripts/shared-template-audit.mjs"
+ ]
+ },
+ {
+ "id": "ai-chat-action-icon-accent-local-shell",
+ "category": "action-button",
+ "summary": "AI Chat queued follow-up and session accent icon actions must not recreate local blue h-6/h-7 icon-button shells; compose ActionIconButton.",
+ "canonical": {
+ "path": "src/components/shared/Button.tsx",
+ "export": "ActionIconButton"
+ },
+ "scopes": ["src/components/AI/Chat"],
+ "extensions": [".tsx"],
+ "allPatterns": [
+ "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950"
+ ],
+ "legacyReason": "Retired migration debt. AI Chat accent icon actions belong to ActionIconButton.",
+ "allowedPaths": [],
+ "ignoredPaths": [
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "src/components/shared/Button.test.tsx"
+ ],
+ "proof": [
+ "src/components/shared/SharedPrimitives.guardrails.test.ts",
+ "src/components/shared/Button.test.tsx",
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "scripts/shared-template-audit.mjs"
+ ]
+ },
+ {
+ "id": "ai-chat-action-icon-primary-send-local-shell",
+ "category": "action-button",
+ "summary": "AI Chat composer send action must not recreate the local primary icon-button shell; compose ActionIconButton.",
+ "canonical": {
+ "path": "src/components/shared/Button.tsx",
+ "export": "ActionIconButton"
+ },
+ "scopes": ["src/components/AI/Chat"],
+ "extensions": [".tsx"],
+ "allPatterns": [
+ "flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700"
+ ],
+ "legacyReason": "Retired migration debt. AI Chat primary icon actions belong to ActionIconButton.",
+ "allowedPaths": [],
+ "ignoredPaths": [
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "src/components/shared/Button.test.tsx"
+ ],
+ "proof": [
+ "src/components/shared/SharedPrimitives.guardrails.test.ts",
+ "src/components/shared/Button.test.tsx",
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "scripts/shared-template-audit.mjs"
+ ]
+ },
+ {
+ "id": "ai-chat-action-icon-warning-local-shell",
+ "category": "action-button",
+ "summary": "AI Chat transcript fallback warning icon actions must not recreate local amber icon-button shells; compose ActionIconButton.",
+ "canonical": {
+ "path": "src/components/shared/Button.tsx",
+ "export": "ActionIconButton"
+ },
+ "scopes": ["src/components/AI/Chat"],
+ "extensions": [".tsx"],
+ "allPatterns": [
+ "flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100"
+ ],
+ "legacyReason": "Retired migration debt. AI Chat warning icon actions belong to ActionIconButton.",
+ "allowedPaths": [],
+ "ignoredPaths": [
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "src/components/shared/Button.test.tsx"
+ ],
+ "proof": [
+ "src/components/shared/SharedPrimitives.guardrails.test.ts",
+ "src/components/shared/Button.test.tsx",
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "scripts/shared-template-audit.mjs"
+ ]
+ },
+ {
+ "id": "ai-chat-action-icon-footer-local-shell",
+ "category": "action-button",
+ "summary": "AI Chat composer footer icon actions must not recreate local bordered h-7 icon-button shells; compose ActionIconButton.",
+ "canonical": {
+ "path": "src/components/shared/Button.tsx",
+ "export": "ActionIconButton"
+ },
+ "scopes": ["src/components/AI/Chat"],
+ "extensions": [".tsx"],
+ "allPatterns": [
+ "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content"
+ ],
+ "legacyReason": "Retired migration debt. AI Chat footer icon actions belong to ActionIconButton.",
+ "allowedPaths": [],
+ "ignoredPaths": [
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "src/components/shared/Button.test.tsx"
+ ],
+ "proof": [
+ "src/components/shared/SharedPrimitives.guardrails.test.ts",
+ "src/components/shared/Button.test.tsx",
+ "src/components/AI/Chat/__tests__/AIChat.test.tsx",
+ "scripts/shared-template-audit.mjs"
+ ]
+ },
{
"id": "settings-connection-editor-local-warning-callout-shell",
"category": "callout",
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx
index cbf99259e..f497d729c 100644
--- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx
+++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx
@@ -4,6 +4,7 @@ import { Show, createSignal } from 'solid-js';
import type { ChatMessage, ModelInfo, ModelRouteRecoveryOption } from '../types';
import type { QueuedFollowUp } from '../hooks/useChat';
import { WORKFLOW_STATUS_PACE_MS } from '../workflowStatusDisplay';
+import aiChatSource from '../index.tsx?raw';
import { logger } from '@/utils/logger';
// ── Hoisted mocks (vi.mock factories reference these) ──────────────────────
@@ -548,6 +549,28 @@ describe('AIChat', () => {
// ── Rendering ──────────────────────────────────────────────────────────
describe('rendering', () => {
+ it('keeps icon-only action chrome on the shared ActionIconButton primitive', () => {
+ expect(aiChatSource).toContain(
+ "import { ActionIconButton } from '@/components/shared/Button';",
+ );
+ expect(aiChatSource).toContain(' {
renderChat();
expect(screen.getByText('Pulse Assistant')).toBeInTheDocument();
@@ -2102,17 +2125,15 @@ describe('AIChat', () => {
});
it('searches Assistant sessions from /sessions arguments without sending a provider prompt', async () => {
- mockAIChatAPI.listSessions
- .mockResolvedValueOnce([])
- .mockResolvedValueOnce([
- {
- id: 's-backup',
- title: 'Backup Patrol Follow-up',
- created_at: '',
- updated_at: '',
- message_count: 3,
- },
- ]);
+ mockAIChatAPI.listSessions.mockResolvedValueOnce([]).mockResolvedValueOnce([
+ {
+ id: 's-backup',
+ title: 'Backup Patrol Follow-up',
+ created_at: '',
+ updated_at: '',
+ message_count: 3,
+ },
+ ]);
renderChat();
await waitFor(() => {
@@ -2846,10 +2867,7 @@ describe('AIChat', () => {
});
await waitFor(() => expect(document.activeElement).toBe(queuedRow));
- expect(queuedRow).toHaveAttribute(
- 'data-assistant-queue-command-target',
- 'true',
- );
+ expect(queuedRow).toHaveAttribute('data-assistant-queue-command-target', 'true');
expect(textarea.value).toBe('');
});
@@ -5689,7 +5707,9 @@ describe('AIChat', () => {
'Selected route connection failed before any output; retrying. · attempt 2/3 · retrying in 1.9s',
);
expect(
- screen.getByTestId('assistant-activity-dock').querySelector('[data-status-kind="retrying"]'),
+ screen
+ .getByTestId('assistant-activity-dock')
+ .querySelector('[data-status-kind="retrying"]'),
).not.toBeNull();
});
diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx
index 0ae25ce41..b04606c66 100644
--- a/frontend-modern/src/components/AI/Chat/index.tsx
+++ b/frontend-modern/src/components/AI/Chat/index.tsx
@@ -41,6 +41,7 @@ import {
type ChatSession,
type ChatSessionHandoffSummary,
} from '@/api/aiChat';
+import { ActionIconButton } from '@/components/shared/Button';
import { SearchField } from '@/components/shared/SearchField';
import { notificationStore } from '@/stores/notifications';
import { aiChatStore, type AIChatContext } from '@/stores/aiChat';
@@ -153,9 +154,7 @@ import { AssistantCommandHelpDialog } from './AssistantCommandHelpDialog';
import { ModelSelector } from './ModelSelector';
import { MentionAutocomplete, type MentionResource } from './MentionAutocomplete';
import { SlashCommandAutocomplete } from './SlashCommandAutocomplete';
-import {
- getAssistantActiveTurnStatus,
-} from './activeTurnStatus';
+import { getAssistantActiveTurnStatus } from './activeTurnStatus';
import {
createPacedWorkflowStatus,
replaceLatestWorkflowStatusEventForDisplay,
@@ -1588,8 +1587,7 @@ export const AIChat: Component = (props) => {
(firstQueued ? queuedFollowUpRowRefs.get(firstQueued.id) : undefined) ||
document.querySelector('[data-testid="assistant-queued-follow-up-row"]');
const queuedRow = findQueuedRow();
- const targetQueuedId =
- firstQueued?.id ?? queuedRow?.dataset.assistantQueuedFollowUpId ?? null;
+ const targetQueuedId = firstQueued?.id ?? queuedRow?.dataset.assistantQueuedFollowUpId ?? null;
if (!firstQueued && !queuedRow) {
markQueuedFollowUpCommandTarget(null);
@@ -2269,10 +2267,7 @@ export const AIChat: Component = (props) => {
return true;
}
- const modelRoute = normalizeAssistantModelRouteArgument(
- target,
- knownAssistantModelProviders(),
- );
+ const modelRoute = normalizeAssistantModelRouteArgument(target, knownAssistantModelProviders());
if (!modelRoute) {
openModelSelector(target);
focusComposer();
@@ -2370,7 +2365,10 @@ export const AIChat: Component = (props) => {
logger.error('[AIChat] Failed to load AI settings:', settingsResult.reason);
}
if (modelsResult.status === 'rejected') {
- logger.debug('[AIChat] Model catalog unavailable during initialization:', modelsResult.reason);
+ logger.debug(
+ '[AIChat] Model catalog unavailable during initialization:',
+ modelsResult.reason,
+ );
}
} catch (error) {
logger.error('[AIChat] Failed to initialize:', error);
@@ -2472,17 +2470,14 @@ export const AIChat: Component = (props) => {
const activeWorkflowStatusHistory = createMemo(() => {
const message = activeAssistantMessage();
if (!message || message.isStreaming === false) return [];
- return message.workflowStatusHistory || (message.workflowStatus ? [message.workflowStatus] : []);
+ return (
+ message.workflowStatusHistory || (message.workflowStatus ? [message.workflowStatus] : [])
+ );
});
const activeWorkflowStatusPaceSequenceKey = createMemo(() => {
const message = activeAssistantMessage();
const first = activeWorkflowStatusHistory()[0];
- return [
- message?.id,
- first?.phase,
- first?.message,
- first?.startedAt,
- ]
+ return [message?.id, first?.phase, first?.message, first?.startedAt]
.map((value) => String(value ?? ''))
.join(':');
});
@@ -3961,22 +3956,18 @@ export const AIChat: Component = (props) => {
- {
void handleToggleAttentionNotifications();
}}
- class={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content ${
- assistantNotificationsEnabled()
- ? 'bg-surface-alt text-base-content'
- : 'bg-surface text-muted'
- }`}
+ tone={assistantNotificationsEnabled() ? 'outlineSelected' : 'outline'}
+ size="md"
+ label="Toggle Assistant attention notifications"
title={
assistantNotificationsEnabled()
? 'Notifications on: you are alerted when the Assistant finishes or needs you while this tab is in the background'
: 'Notify me when the Assistant finishes or needs me while this tab is in the background'
}
- aria-label="Toggle Assistant attention notifications"
aria-pressed={assistantNotificationsEnabled()}
>
= (props) => {
>
-
+
- {
void handleForkSession();
}}
disabled={!canForkCurrentSession()}
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted"
+ tone="outline"
+ size="md"
title={AI_CHAT_FORK_SESSION_LABEL}
- aria-label={AI_CHAT_FORK_SESSION_LABEL}
+ label={AI_CHAT_FORK_SESSION_LABEL}
aria-busy={forkingSession()}
>
= (props) => {
>
-
+
- {
void handleCompactSession();
}}
disabled={!canCompactCurrentSession()}
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted"
+ tone="outline"
+ size="md"
title={AI_CHAT_COMPACT_SESSION_LABEL}
- aria-label={AI_CHAT_COMPACT_SESSION_LABEL}
+ label={AI_CHAT_COMPACT_SESSION_LABEL}
aria-busy={compactingSession()}
>
= (props) => {
>
-
+
- {
void handleUndoLastTurn();
}}
disabled={!canUndoLastTurn()}
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted"
+ tone="outline"
+ size="md"
title={AI_CHAT_UNDO_LAST_TURN_LABEL}
- aria-label={AI_CHAT_UNDO_LAST_TURN_LABEL}
+ label={AI_CHAT_UNDO_LAST_TURN_LABEL}
aria-busy={undoingLastTurn()}
>
= (props) => {
>
-
+
- {
void handleRedoLastTurn();
}}
disabled={!canRedoLastTurn()}
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted"
+ tone="outline"
+ size="md"
title={AI_CHAT_REDO_LAST_TURN_LABEL}
- aria-label={AI_CHAT_REDO_LAST_TURN_LABEL}
+ label={AI_CHAT_REDO_LAST_TURN_LABEL}
aria-busy={redoingLastTurn()}
>
= (props) => {
>
-
+
- {
void copyLastAssistantAnswer();
}}
disabled={!hasLastAssistantAnswer()}
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted"
+ tone="outline"
+ size="md"
title={AI_CHAT_COPY_LAST_ANSWER_LABEL}
- aria-label={AI_CHAT_COPY_LAST_ANSWER_LABEL}
+ label={AI_CHAT_COPY_LAST_ANSWER_LABEL}
>
-
+
- {
void copyAssistantTranscript();
}}
disabled={!hasCurrentTranscript()}
- class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted"
+ tone="outline"
+ size="md"
title={AI_CHAT_COPY_TRANSCRIPT_LABEL}
- aria-label={AI_CHAT_COPY_TRANSCRIPT_LABEL}
+ label={AI_CHAT_COPY_TRANSCRIPT_LABEL}
>
-
+
-
-
+
{/* Session picker */}
-
{
void handleToggleSessions();
}}
- class="flex-shrink-0 p-2 hover:text-base-content rounded-md hover:bg-surface-hover transition-colors"
+ tone="muted"
+ size="md"
title={AI_CHAT_SESSION_MENU_TITLE}
- aria-label={AI_CHAT_SESSION_MENU_TITLE}
+ label={AI_CHAT_SESSION_MENU_TITLE}
aria-haspopup="dialog"
aria-expanded={showSessions()}
>
-
+
= (props) => {
aria-label={`New title for ${session.title || 'Untitled'}`}
class="min-w-0 flex-1 rounded border border-blue-300 bg-surface px-2 py-1 text-sm font-medium text-base-content outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:opacity-70 dark:border-blue-800"
/>
-
= (props) => {
aria-hidden="true"
/>
-
-
+
-
+
-
startRenamingSession(session, event)}
- aria-label={getSessionRenameLabel(session)}
+ label={getSessionRenameLabel(session)}
title={getSessionRenameLabel(session)}
>
-
-
+
togglePinnedSession(session.id, event)
}
aria-pressed={isSessionPinned(session.id)}
- aria-label={getSessionPinLabel(session)}
+ label={getSessionPinLabel(session)}
title={getSessionPinLabel(session)}
>
-
-
+
handleDeleteSession(session.id, event)
}
- aria-label={getSessionDeleteLabel(session)}
+ label={getSessionDeleteLabel(session)}
title={getSessionDeleteLabel(session)}
>
-
+
@@ -4374,19 +4371,20 @@ export const AIChat: Component = (props) => {
{/* Close button (Always visible as fallback) */}
- {
e.stopPropagation();
props.onClose();
}}
- class="order-2 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md hover:text-base-content hover:bg-surface-hover transition-colors sm:order-none"
+ tone="neutral"
+ size="lg"
+ class="order-2 sm:order-none"
title={AI_CHAT_CLOSE_LABEL}
- aria-label={AI_CHAT_CLOSE_LABEL}
+ label={AI_CHAT_CLOSE_LABEL}
data-testid="assistant-close-button"
>
-
+
@@ -4398,24 +4396,24 @@ export const AIChat: Component = (props) => {
{AI_CHAT_TRANSCRIPT_FALLBACK_TITLE}
-
-
-
+ setTranscriptCopyFallback(null)}
- class="flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900"
+ tone="warningGhost"
+ size="sm"
title={AI_CHAT_TRANSCRIPT_FALLBACK_CLOSE_LABEL}
- aria-label={AI_CHAT_TRANSCRIPT_FALLBACK_CLOSE_LABEL}
+ label={AI_CHAT_TRANSCRIPT_FALLBACK_CLOSE_LABEL}
>
-
+
@@ -4696,19 +4687,20 @@ export const AIChat: Component = (props) => {
)}
-
-
+
@@ -4735,15 +4727,15 @@ export const AIChat: Component = (props) => {
>
Switch to Approval
- setAutonomousBannerDismissed(true)}
- class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-red-500 transition-colors hover:bg-red-50 hover:text-red-700 dark:text-red-200 dark:hover:bg-red-950/40"
+ tone="danger"
+ size="xs"
title={AI_CHAT_AUTONOMOUS_WARNING_DISMISS_LABEL}
- aria-label={AI_CHAT_AUTONOMOUS_WARNING_DISMISS_LABEL}
+ label={AI_CHAT_AUTONOMOUS_WARNING_DISMISS_LABEL}
>
-
+
0}>
@@ -4766,18 +4758,18 @@ export const AIChat: Component = (props) => {
)}{' '}
{chat.queuedFollowUpsPaused() ? 'paused' : 'queued'}
- {
chat.clearQueuedFollowUps();
focusComposer();
}}
- class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-900 dark:text-blue-200 dark:hover:bg-blue-900/50"
+ tone="accentGhost"
+ size="xs"
title="Clear queued follow-ups"
- aria-label="Clear queued follow-up messages"
+ label="Clear queued follow-up messages"
>
-
+
@@ -4824,48 +4816,48 @@ export const AIChat: Component = (props) => {
1 && index() > 0}>
- sendQueuedFollowUpNext(queued.id)}
- class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
+ tone="accentGhost"
+ size="xs"
title="Send queued follow-up next"
- aria-label={`Send queued follow-up next: ${preview()}`}
+ label={`Send queued follow-up next: ${preview()}`}
>
-
+
- sendQueuedFollowUpNext(queued.id)}
- class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
+ tone="accentGhost"
+ size="xs"
title="Resume queued follow-up"
- aria-label={`Resume queued follow-up: ${preview()}`}
+ label={`Resume queued follow-up: ${preview()}`}
>
-
+
- editQueuedFollowUp(queued.id)}
- class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
+ tone="accentGhost"
+ size="xs"
title="Edit queued follow-up"
- aria-label={`Edit queued follow-up: ${preview()}`}
+ label={`Edit queued follow-up: ${preview()}`}
>
-
-
+ {
chat.cancelQueuedFollowUp(queued.id);
focusComposer();
}}
- class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60"
+ tone="accentGhost"
+ size="xs"
title="Remove queued follow-up"
- aria-label={`Remove queued follow-up: ${preview()}`}
+ label={`Remove queued follow-up: ${preview()}`}
>
-
+
);
}}
@@ -4918,15 +4910,16 @@ export const AIChat: Component = (props) => {
visible={slashCommandActive()}
/>
-
-
+
@@ -4972,34 +4965,34 @@ export const AIChat: Component = (props) => {
)}
-
-
-
+ cycleRecentModelRoute()}
disabled={!nextRecentModelRoute()}
- class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:text-base-content disabled:cursor-not-allowed disabled:opacity-45"
+ tone="outline"
+ size="sm"
title={
nextRecentModelRouteLabel()
? `${AI_CHAT_CYCLE_RECENT_MODEL_LABEL}: ${nextRecentModelRouteLabel()}`
: AI_CHAT_CYCLE_RECENT_MODEL_LABEL
}
- aria-label={
+ label={
nextRecentModelRouteLabel()
? `${AI_CHAT_CYCLE_RECENT_MODEL_LABEL}: ${nextRecentModelRouteLabel()}`
: AI_CHAT_CYCLE_RECENT_MODEL_LABEL
}
>
-
+
{
const onClick = vi.fn();
render(() => (
-
- E
-
+ <>
+
+ E
+
+
+ ?
+
+
+ N
+
+
+ S
+
+
+ P
+
+
+ W
+
+
+ D
+
+
+ I
+
+ >
));
const button = screen.getByRole('button', { name: 'Edit thresholds' });
@@ -243,6 +266,21 @@ describe('Button', () => {
expect(button).toHaveClass('w-6');
expect(button).toHaveClass('bg-blue-50');
+ expect(screen.getByRole('button', { name: 'Open help' })).toHaveClass('border-border');
+ expect(screen.getByRole('button', { name: 'Notifications enabled' })).toHaveClass(
+ 'bg-surface-alt',
+ );
+ expect(screen.getByRole('button', { name: 'Send message' })).toHaveClass('h-9');
+ expect(screen.getByRole('button', { name: 'Send message' })).toHaveClass('bg-blue-600');
+ expect(screen.getByRole('button', { name: 'Promote queued follow-up' })).toHaveClass(
+ 'text-blue-700',
+ );
+ expect(screen.getByRole('button', { name: 'Dismiss warning' })).toHaveClass('text-amber-700');
+ expect(screen.getByRole('button', { name: 'Download fallback' })).toHaveClass(
+ 'border-amber-200',
+ );
+ expect(screen.getByRole('button', { name: 'Dismiss info' })).toHaveClass('text-cyan-500');
+
button.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index da65c2556..a8e3e15a1 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -2982,6 +2982,21 @@ describe('shared primitive guardrails', () => {
const alertResourceActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'alert-resource-action-local-svg-button-shell',
);
+ const aiChatActionIconHeaderGuard = registry.patternGuards?.find(
+ (guard) => guard.id === 'ai-chat-action-icon-header-local-shell',
+ );
+ const aiChatActionIconAccentGuard = registry.patternGuards?.find(
+ (guard) => guard.id === 'ai-chat-action-icon-accent-local-shell',
+ );
+ const aiChatActionIconPrimarySendGuard = registry.patternGuards?.find(
+ (guard) => guard.id === 'ai-chat-action-icon-primary-send-local-shell',
+ );
+ const aiChatActionIconWarningGuard = registry.patternGuards?.find(
+ (guard) => guard.id === 'ai-chat-action-icon-warning-local-shell',
+ );
+ const aiChatActionIconFooterGuard = registry.patternGuards?.find(
+ (guard) => guard.id === 'ai-chat-action-icon-footer-local-shell',
+ );
const commandCopyGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-command-copy-local-shell',
);
@@ -3817,6 +3832,7 @@ describe('shared primitive guardrails', () => {
'src/components/Alerts/AlertResourceTableMobile.tsx',
'src/components/Alerts/AlertResourceTableRow.tsx',
'src/components/Alerts/ResourceTable.tsx',
+ 'src/components/AI/Chat/index.tsx',
'src/components/Settings/RolesPanel.tsx',
'src/components/Settings/SSOProvidersPanel.tsx',
]);
@@ -3855,6 +3871,21 @@ describe('shared primitive guardrails', () => {
'text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5',
]),
}),
+ expect.objectContaining({
+ path: 'src/components/AI/Chat/index.tsx',
+ patterns: expect.arrayContaining([
+ 'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted',
+ 'flex-shrink-0 p-2 hover:text-base-content rounded-md hover:bg-surface-hover transition-colors',
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 disabled:cursor-wait disabled:opacity-70 dark:text-blue-200 dark:hover:bg-blue-900/60',
+ 'rounded p-1 text-muted opacity-0 transition-opacity hover:bg-blue-100 hover:text-blue-600 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-blue-900 dark:hover:text-blue-300',
+ 'order-2 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md hover:text-base-content hover:bg-surface-hover transition-colors sm:order-none',
+ 'flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-200 dark:hover:bg-amber-900',
+ 'flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900',
+ 'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60',
+ 'flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-45',
+ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content focus:outline-none focus:ring-2 focus:ring-blue-500/30',
+ ]),
+ }),
expect.objectContaining({
path: 'src/components/Settings/SSOProvidersPanel.tsx',
patterns: expect.arrayContaining([
@@ -3882,6 +3913,37 @@ describe('shared primitive guardrails', () => {
'src/components/shared/Button.test.tsx',
'src/components/shared/SharedPrimitives.guardrails.test.ts',
]);
+ for (const guard of [
+ aiChatActionIconHeaderGuard,
+ aiChatActionIconAccentGuard,
+ aiChatActionIconPrimarySendGuard,
+ aiChatActionIconWarningGuard,
+ aiChatActionIconFooterGuard,
+ ]) {
+ expect(guard?.canonical?.path).toBe('src/components/shared/Button.tsx');
+ expect(guard?.canonical?.export).toBe('ActionIconButton');
+ expect(guard?.scopes).toEqual(['src/components/AI/Chat']);
+ expect(guard?.allowedPaths ?? []).toHaveLength(0);
+ expect(guard?.ignoredPaths).toEqual([
+ 'src/components/AI/Chat/__tests__/AIChat.test.tsx',
+ 'src/components/shared/Button.test.tsx',
+ ]);
+ }
+ expect(aiChatActionIconHeaderGuard?.allPatterns).toEqual([
+ 'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content',
+ ]);
+ expect(aiChatActionIconAccentGuard?.allPatterns).toEqual([
+ 'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950',
+ ]);
+ expect(aiChatActionIconPrimarySendGuard?.allPatterns).toEqual([
+ 'flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700',
+ ]);
+ expect(aiChatActionIconWarningGuard?.allPatterns).toEqual([
+ 'flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100',
+ ]);
+ expect(aiChatActionIconFooterGuard?.allPatterns).toEqual([
+ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content',
+ ]);
expect(copyValueRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
expect(copyValueRule?.canonical?.export).toBe('CopyValueButton');
expect(copyValueRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
@@ -3962,6 +4024,14 @@ describe('shared primitive guardrails', () => {
expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_TONE_CLASSES');
expect(buttonModelSource).toContain('ACTION_ICON_BUTTON_SIZE_CLASSES');
+ expect(buttonModelSource).toContain('outline:');
+ expect(buttonModelSource).toContain('outlineSelected:');
+ expect(buttonModelSource).toContain('primary:');
+ expect(buttonModelSource).toContain('accentGhost:');
+ expect(buttonModelSource).toContain('warningGhost:');
+ expect(buttonModelSource).toContain('warningOutline:');
+ expect(buttonModelSource).toContain('infoGhost:');
+ expect(buttonModelSource).toContain(['lg', ": 'h-9 w-9'"].join(''));
expect(buttonModelSource).toContain('dangerOutline:');
expect(buttonModelSource).toContain('settingsAction:');
expect(buttonModelSource).toContain('getCopyValueButtonClass');
@@ -3993,6 +4063,22 @@ describe('shared primitive guardrails', () => {
expect(chatMessagesSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content',
);
+ expect(aiChatSource).toContain('@/components/shared/Button');
+ expect(aiChatSource).toContain('ActionIconButton');
+ for (const retiredActionIconShell of [
+ 'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted',
+ 'flex-shrink-0 p-2 hover:text-base-content rounded-md hover:bg-surface-hover transition-colors',
+ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 disabled:cursor-wait disabled:opacity-70 dark:text-blue-200 dark:hover:bg-blue-900/60',
+ 'rounded p-1 text-muted opacity-0 transition-opacity hover:bg-blue-100 hover:text-blue-600 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-blue-900 dark:hover:text-blue-300',
+ 'order-2 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md hover:text-base-content hover:bg-surface-hover transition-colors sm:order-none',
+ 'flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-200 dark:hover:bg-amber-900',
+ 'flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900',
+ 'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60',
+ 'flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-sm transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-45',
+ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content focus:outline-none focus:ring-2 focus:ring-blue-500/30',
+ ]) {
+ expect(aiChatSource).not.toContain(retiredActionIconShell);
+ }
expect(resourceDetailDrawerDebugTabSource).toContain('@/components/shared/Button');
expect(resourceDetailDrawerDebugTabSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content',
diff --git a/frontend-modern/src/components/shared/buttonModel.ts b/frontend-modern/src/components/shared/buttonModel.ts
index cd438ea30..0d750ccba 100644
--- a/frontend-modern/src/components/shared/buttonModel.ts
+++ b/frontend-modern/src/components/shared/buttonModel.ts
@@ -80,8 +80,20 @@ export const getButtonClass = (options: ButtonClassOptions = {}): string =>
export type CopyValueButtonVariant = 'neutral' | 'ghost' | 'accent' | 'chip';
export type CopyValueButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'chip';
-export type ActionIconButtonTone = 'neutral' | 'muted' | 'accent' | 'success' | 'danger';
-export type ActionIconButtonSize = 'xs' | 'sm' | 'md';
+export type ActionIconButtonTone =
+ | 'neutral'
+ | 'muted'
+ | 'outline'
+ | 'outlineSelected'
+ | 'primary'
+ | 'accent'
+ | 'accentGhost'
+ | 'success'
+ | 'warningGhost'
+ | 'warningOutline'
+ | 'infoGhost'
+ | 'danger';
+export type ActionIconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
export const COPY_VALUE_BUTTON_BASE_CLASS =
'inline-flex shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
@@ -124,10 +136,22 @@ export const ACTION_ICON_BUTTON_BASE_CLASS =
export const ACTION_ICON_BUTTON_TONE_CLASSES: Record = {
neutral: 'text-base-content hover:bg-surface-hover hover:text-base-content',
muted: 'text-muted hover:bg-surface-hover hover:text-base-content',
+ outline:
+ 'border border-border bg-surface text-muted shadow-sm hover:border-border hover:bg-surface-hover hover:text-base-content',
+ outlineSelected:
+ 'border border-border bg-surface-alt text-base-content shadow-sm hover:border-border hover:bg-surface-hover hover:text-base-content',
+ primary: 'border border-transparent bg-blue-600 text-white shadow-sm hover:bg-blue-700',
accent:
'bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 dark:bg-blue-900 dark:text-blue-400 dark:hover:bg-blue-950 dark:hover:text-blue-300',
+ accentGhost:
+ 'text-blue-700 hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60',
success:
'bg-green-50 text-green-600 hover:bg-green-100 hover:text-green-700 dark:bg-green-900 dark:text-green-400 dark:hover:bg-green-950 dark:hover:text-green-300',
+ warningGhost:
+ 'text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900',
+ warningOutline:
+ 'border border-amber-200 bg-surface text-amber-700 hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-200 dark:hover:bg-amber-900',
+ infoGhost: 'text-cyan-500 hover:bg-cyan-100 dark:text-cyan-400 dark:hover:bg-cyan-800',
danger:
'text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300',
};
@@ -136,6 +160,7 @@ export const ACTION_ICON_BUTTON_SIZE_CLASSES: Record