feat(ai-chat): align footer bar + DropdownV2 mode/autonomy selectors (#9308)

* feat(ai-chat): align footer bar, use DropdownV2 for mode/autonomy selectors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dropdown): add `selected` item prop rendering a trailing check

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): add small spacing between chat input and footer bar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): always offer the 3 autonomy options in the auto-accept picker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai-chat): default autonomy mode to auto-accept on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ai-chat): use Button component for footer dropdown triggers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): use a hand icon for the auto-accept-off autonomy state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): use subtle Button variant for mode and model selectors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): tighten spacing between input and footer bar

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): reword autonomy levels as ask/auto-accept/bypass permissions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(button): add 2xs unified size with tighter padding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai-chat): compact footer bar — 2xs buttons, AtSign context icon, short Yolo label, discreet model

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(ai-chat): widen the permission selector dropdown

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dropdown): group shortcut + selected check to avoid ml-auto collision

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ai-chat): cover getPersistedAutonomyMode default; clarify default comment

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-05-25 17:25:11 +02:00
committed by GitHub
parent 3f219aed98
commit 2f50e8bab0
10 changed files with 200 additions and 153 deletions
@@ -1,7 +1,7 @@
<script lang="ts">
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
import { Loader2 } from 'lucide-svelte'
import { Check, Loader2 } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
import type { Item } from '$lib/utils'
@@ -62,8 +62,17 @@
{item.displayName}
</p>
{@render item.extra?.()}
{#if item.shortcut}
<span class="ml-auto pl-4 text-2xs text-secondary shrink-0">{item.shortcut}</span>
{#if item.shortcut || item.selected}
<!-- Single trailing group so `shortcut` and `selected` can coexist:
two `ml-auto` siblings would collapse to one right-aligned item. -->
<div class="ml-auto flex shrink-0 items-center gap-2">
{#if item.shortcut}
<span class="pl-4 text-2xs text-secondary">{item.shortcut}</span>
{/if}
{#if item.selected}
<Check size={14} class="text-primary" />
{/if}
</div>
{/if}
{#if item.tooltip}
<Tooltip>
@@ -283,12 +283,14 @@
})
const overlayPaddingX: Record<InputSize, string> = {
'2xs': 'px-1',
xs: 'px-1',
sm: 'px-2',
md: 'px-2',
lg: 'px-2'
}
const overlayHeight: Record<InputSize, string> = {
'2xs': 'h-5',
xs: 'h-5',
sm: 'h-7',
md: 'h-8',
@@ -17,7 +17,7 @@ export namespace ButtonType {
* @deprecated Use `UnifiedSize` instead
*/
export type Size = 'xs3' | 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type UnifiedSize = 'xs' | 'sm' | 'md' | 'lg'
export type UnifiedSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg'
export type ExtendedSize = 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/**
* @deprecated Use `Variant` instead
@@ -221,6 +221,7 @@ export namespace ButtonType {
// New unified sizing system
export const UnifiedSizingClasses: Record<ButtonType.UnifiedSize, string> = {
'2xs': 'px-1', // Compact horizontal padding
xs: 'px-2',
sm: 'px-2', // Regular horizontal padding
md: 'px-4',
@@ -228,6 +229,7 @@ export namespace ButtonType {
}
export const UnifiedIconOnlySizingClasses: Record<ButtonType.UnifiedSize, string> = {
'2xs': 'px-1',
xs: 'px-1',
sm: 'px-2', // Square padding for icon-only (same as width padding)
md: 'px-2',
@@ -235,6 +237,7 @@ export namespace ButtonType {
}
export const UnifiedMinHeightClasses: Record<ButtonType.UnifiedSize, string> = {
'2xs': 'min-h-5',
xs: 'min-h-5',
sm: 'min-h-7',
md: 'min-h-8',
@@ -242,6 +245,7 @@ export namespace ButtonType {
}
export const UnifiedHeightClasses: Record<ButtonType.UnifiedSize, string> = {
'2xs': 'h-5',
xs: 'h-5',
sm: 'h-7',
md: 'h-8',
@@ -249,6 +253,7 @@ export namespace ButtonType {
}
export const UnifiedIconSizes: Record<ButtonType.UnifiedSize, number> = {
'2xs': 12,
xs: 12,
sm: 13,
md: 14,
@@ -256,6 +261,7 @@ export namespace ButtonType {
}
export const UnifiedFontSizes: Record<ButtonType.UnifiedSize, string> = {
'2xs': 'font-normal',
xs: 'font-normal',
sm: 'font-normal',
md: 'font-medium',
@@ -6,9 +6,11 @@
import {
AlertTriangle,
ArrowDown,
AtSign,
ChevronDown,
ChevronsRight,
CheckIcon,
Hand,
HistoryIcon,
Hourglass,
MousePointer2,
@@ -20,6 +22,7 @@
import Button from '$lib/components/common/button/Button.svelte'
import { fade } from 'svelte/transition'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ChatQuickActions from './ChatQuickActions.svelte'
@@ -38,16 +41,21 @@
const MAX_YOLO_TOOLTIP_TOOLS = 8
const aiChatManager = getAiChatManager()
type AutonomyModeOption = { label: string; mode: AIAutonomyMode }
// `label` is shown in the dropdown; `shortLabel` (when set) is shown in the
// compact trigger pill to save horizontal space.
type AutonomyModeOption = { label: string; shortLabel?: string; mode: AIAutonomyMode }
const autonomyModeOptions: AutonomyModeOption[] = [
{ label: 'auto accept off', mode: AIAutonomyMode.DEFAULT },
{ label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT },
{ label: 'yolo on', mode: AIAutonomyMode.YOLO }
{ label: 'Ask permission', mode: AIAutonomyMode.DEFAULT },
{ label: 'Auto-accept edits', mode: AIAutonomyMode.ACCEPT_EDIT },
{ label: 'Yolo (bypass permissions)', shortLabel: 'Yolo', mode: AIAutonomyMode.YOLO }
]
const autonomyModeLabel = (
mode: AIAutonomyMode,
options: AutonomyModeOption[] = autonomyModeOptions
) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label
const autonomyModeLabel = (mode: AIAutonomyMode) => {
const option = autonomyModeOptions.find((o) => o.mode === mode) ?? autonomyModeOptions[0]
return option.shortLabel ?? option.label
}
// "Auto-accept edits" only applies where script/flow edits can be accepted,
// "Bypass permissions" only where tool confirmations exist; filter the picker
// to the levels that actually do something in the current mode.
const isAutonomyModeAvailable = (
mode: AIAutonomyMode,
autoAcceptEditsAvailable: boolean,
@@ -63,6 +71,16 @@
}
return false
}
// Ask-permission holds (raised hand); auto-accept/bypass fast-forward. Color
// ramps from muted (ask) to accent (auto-accept) to red (bypass).
const autonomyModeIcon = (mode: AIAutonomyMode) =>
mode === AIAutonomyMode.DEFAULT ? Hand : ChevronsRight
const autonomyModeIconColor = (mode: AIAutonomyMode) =>
mode === AIAutonomyMode.YOLO
? 'text-red-500'
: mode === AIAutonomyMode.DEFAULT
? 'text-secondary'
: 'text-accent'
let {
messages,
@@ -218,6 +236,8 @@
)
)
)
// Fall back to ask-permission when the persisted mode isn't applicable in the
// current AI mode (e.g. auto-accept edits while in a mode without edits).
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
@@ -405,7 +425,7 @@
{#if showTypingIndicator}
<div
class={twMerge(
'sticky z-10 mt-2 ml-2 self-start pointer-events-none',
'sticky z-10 mt-0.5 ml-2 self-start pointer-events-none',
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
@@ -494,7 +514,7 @@
isFirstMessage={messages.length === 0}
/>
<div
class="flex flex-row items-center gap-x-1.5"
class="mt-1 flex flex-row flex-wrap items-center gap-x-1.5 gap-y-1"
class:justify-between={showFooterLeftControls}
class:justify-end={!showFooterLeftControls}
>
@@ -503,12 +523,14 @@
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="default"
title="Add context"
>
@
</div>
iconOnly
startIcon={{ icon: AtSign }}
/>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
@@ -538,53 +560,33 @@
</Popover>
{/if}
{#if showAutonomyModeSelector}
<div class="min-w-0">
<Popover class="max-w-full">
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
title={autonomyModeTooltip}
>
<ChevronsRight
size={13}
class={twMerge(
'shrink-0',
effectiveAutonomyMode === AIAutonomyMode.YOLO
? 'text-red-500'
: 'text-accent'
)}
/>
<span class="truncate"
>{autonomyModeLabel(
effectiveAutonomyMode,
availableAutonomyModeOptions
)}</span
>
<div class="shrink-0">
<ChevronDown size={16} />
</div>
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-32">
{#each availableAutonomyModeOptions as option (option.mode)}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
effectiveAutonomyMode === option.mode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.setAutonomyMode(option.mode)
close()
}}
>
{option.label}
</button>
{/each}
</div>
{/snippet}
</Popover>
</div>
<DropdownV2
items={() =>
availableAutonomyModeOptions.map((option) => ({
displayName: option.label,
selected: effectiveAutonomyMode === option.mode,
action: () => aiChatManager.setAutonomyMode(option.mode)
}))}
placement="bottom-start"
fixedHeight={false}
customWidth={240}
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="default"
title={autonomyModeTooltip}
startIcon={{
icon: autonomyModeIcon(effectiveAutonomyMode),
classes: autonomyModeIconColor(effectiveAutonomyMode)
}}
endIcon={{ icon: ChevronDown }}
>
{autonomyModeLabel(effectiveAutonomyMode)}
</Button>
{/snippet}
</DropdownV2>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
@@ -593,8 +595,8 @@
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
? 'Yolo auto-accepts edits and tool usage.'
: 'Yolo auto-accepts tool usage.'}
? 'Bypass permissions auto-accepts edits and tool usage.'
: 'Bypass permissions auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
@@ -126,15 +126,18 @@ function isWorkspacePath(path: string | undefined): path is string {
function getPersistedAutonomyMode(): AIAutonomyMode {
if (!BROWSER || typeof localStorage === 'undefined') {
return AIAutonomyMode.DEFAULT
return AIAutonomyMode.ACCEPT_EDIT
}
const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY)
if (isAIAutonomyMode(persistedMode)) {
return persistedMode
}
// No stored preference: default to auto-accepting edits (tool calls still
// require confirmation; only YOLO bypasses those). Note this means users who
// never opened the autonomy picker now start with edit auto-accept on.
return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true'
? AIAutonomyMode.YOLO
: AIAutonomyMode.DEFAULT
: AIAutonomyMode.ACCEPT_EDIT
}
function persistAutonomyMode(mode: AIAutonomyMode) {
@@ -46,6 +46,13 @@ vi.mock('./global/gate', () => ({
isGlobalAiEnabled: () => true
}))
// Force BROWSER=true so localStorage-backed autonomy persistence is exercised
// (the vitest "server" env reports BROWSER=false, which would short-circuit it).
vi.mock('esm-env', async (importOriginal) => ({
...(await importOriginal<typeof import('esm-env')>()),
BROWSER: true
}))
function createFlowHelpers({
hasPendingChanges,
acceptAllModuleActions
@@ -75,6 +82,9 @@ function createFlowHelpers({
describe('AIChatManager autonomy mode', () => {
beforeEach(() => {
localStorage.clear()
// These tests exercise the transition into auto-accept, so start from the
// ask-permission baseline rather than the new auto-accept-edits default.
localStorage.setItem('ai-chat-autonomy-mode', AIAutonomyMode.DEFAULT)
vi.clearAllMocks()
})
@@ -153,3 +163,28 @@ describe('AIChatManager autonomy mode', () => {
expect(applied).toBe(true)
})
})
describe('AIChatManager persisted autonomy default', () => {
// Mirrors the private storage keys in AIChatManager.svelte.ts.
const AUTONOMY_KEY = 'ai-chat-autonomy-mode'
const LEGACY_YOLO_KEY = 'ai-chat-yolo-mode'
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it('defaults to auto-accept edits when no preference is stored', () => {
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.ACCEPT_EDIT)
})
it('maps the legacy auto-accept-tool-confirmations flag to YOLO', () => {
localStorage.setItem(LEGACY_YOLO_KEY, 'true')
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.YOLO)
})
it('restores an explicitly persisted autonomy mode', () => {
localStorage.setItem(AUTONOMY_KEY, AIAutonomyMode.DEFAULT)
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.DEFAULT)
})
})
@@ -1,54 +1,37 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { twMerge } from 'tailwind-merge'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode'
let allowedModeList = $derived(
Object.values(AIMode).filter((mode) => aiChatManager.allowedModes[mode])
)
let hasMultiple = $derived(allowedModeList.length > 1)
</script>
<div class="min-w-0">
<Popover
disablePopup={Object.keys(aiChatManager.allowedModes).filter(
(k) => aiChatManager.allowedModes[k]
).length < 2}
class="max-w-full"
{#if hasMultiple}
<DropdownV2
items={() =>
allowedModeList.map((mode) => ({
displayName: modeLabel(mode),
selected: aiChatManager.mode === mode,
action: () => aiChatManager.changeMode(mode)
}))}
placement="bottom-start"
fixedHeight={false}
customWidth={170}
>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
>
<span class={`truncate`}>
{aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode
</span>
{#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1}
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each Object.values(AIMode) as possibleMode}
{#if aiChatManager.allowedModes[possibleMode]}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
aiChatManager.mode === possibleMode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.changeMode(possibleMode)
close()
}}
>
{possibleMode.charAt(0).toUpperCase() + possibleMode.slice(1)} mode
</button>
{/if}
{/each}
</div>
{/snippet}
</Popover>
</div>
{#snippet buttonReplacement()}
<Button nonCaptureEvent unifiedSize="2xs" variant="subtle" endIcon={{ icon: ChevronDown }}>
{modeLabel(aiChatManager.mode)}
</Button>
{/snippet}
</DropdownV2>
{:else}
<Button unifiedSize="2xs" variant="subtle">
{modeLabel(aiChatManager.mode)}
</Button>
{/if}
@@ -1,12 +1,12 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import {
COPILOT_SESSION_MODEL_SETTING_NAME,
COPILOT_SESSION_PROVIDER_SETTING_NAME
} from '$lib/stores'
import { storeLocalSetting } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
let providerModel = $derived(
@@ -21,37 +21,35 @@
let multipleModels = $derived($copilotInfo.aiModels.length > 1)
</script>
<div class="min-w-0">
<Popover disablePopup={!multipleModels} class="max-w-full">
{#snippet trigger()}
<div class="text-secondary text-2xs flex flex-row items-center font-normal gap-0.5">
<span class={`truncate ${multipleModels ? '' : 'pr-2'}`}>{providerModel.model}</span>
{#if multipleModels}
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
</div>
{#if multipleModels}
<DropdownV2
items={() =>
$copilotInfo.aiModels.map((m) => ({
displayName: m.model,
selected: m.model === providerModel.model,
action: () => {
$copilotSessionModel = m
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
}
}))}
placement="bottom-end"
fixedHeight={false}
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="subtle"
endIcon={{ icon: ChevronDown }}
btnClasses="max-w-[160px] text-secondary font-normal"
>
<span class="truncate">{providerModel.model}</span>
</Button>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-24">
{#each $copilotInfo.aiModels as providerModel}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
providerModel.model === $copilotSessionModel?.model && 'bg-surface-hover'
)}
onclick={() => {
$copilotSessionModel = providerModel
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, providerModel.provider)
close()
}}
>
{providerModel.model}
</button>
{/each}
</div>
{/snippet}
</Popover>
</div>
</DropdownV2>
{:else}
<Button unifiedSize="2xs" variant="subtle" btnClasses="max-w-[160px] text-secondary font-normal">
<span class="truncate">{providerModel.model}</span>
</Button>
{/if}
@@ -24,6 +24,11 @@
import { ButtonType } from '$lib/components/common/button/model'
export const inputSizeClasses = {
'2xs': twMerge(
ButtonType.UnifiedSizingClasses['2xs'],
ButtonType.UnifiedMinHeightClasses['2xs'],
'px-1 !py-0.5'
),
xs: twMerge(
ButtonType.UnifiedSizingClasses.xs,
ButtonType.UnifiedMinHeightClasses.xs,
@@ -47,6 +52,7 @@
// so the exact centered value there is (content-box height 2px). Scoped to
// the same 1760px breakpoint as the font-size bump; small mode is unchanged.
export const inputLeadingClasses: Record<ButtonType.UnifiedSize, string> = {
'2xs': 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 py-0.5 → 1rem
xs: 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 py-0.5 → 1rem
sm: 'leading-6 min-[1760px]:leading-[calc(1.5rem_-_2px)]', // h-7 py-0.5 → 1.5rem
md: 'leading-8 min-[1760px]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem
+3
View File
@@ -1536,6 +1536,9 @@ export type Item = {
separatorTop?: boolean
submenuItems?: Item[]
shortcut?: string
// Renders a trailing check on the right of the label to mark the
// currently-selected item (for dropdowns used as a single-choice picker).
selected?: boolean
}
export function isObjectTooBig(obj: any): boolean {