refactor(ui): unify chat composer controls

- Add a canonical 24, 28, and 32 pixel control scale

- Rebuild the AI prompt as a multiline compound input

- Keep property-panel icon actions at the explicit compact size
This commit is contained in:
Danila Poyarkov 2026-08-14 13:21:29 +03:00
parent 62f3594819
commit 2f88ef4827
14 changed files with 236 additions and 147 deletions

View file

@ -5,9 +5,8 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import ChatProfileSelect from '@/components/chat/ChatProfileSelect.vue'
import ProviderModelSelect from '@/components/chat/ProviderModelSelect.vue'
import AppInput from '@/components/ui/AppInput.vue'
import Tip from '@/components/ui/Tip.vue'
import { useButtonUI } from '@/components/ui/button'
import IconButton from '@/components/ui/IconButton.vue'
import InputGroup from '@/components/ui/InputGroup.vue'
import { useAIChat } from '@/app/ai/chat/use'
import { designModelProfile, designModelProfiles } from '@/app/ai/models'
import { validateReferenceImageFile } from '@/app/ai/reference-image/prepare'
@ -62,18 +61,6 @@ const acpAgentName = computed(() => {
const isCustomProvider = computed(
() => providerID.value === 'openai-compatible' || providerID.value === 'anthropic-compatible'
)
const stopButton = useButtonUI({
tone: 'ghost',
shape: 'rounded',
size: 'sm',
ui: { base: 'shrink-0 border border-border px-2 py-1.5' }
})
const sendButton = useButtonUI({
tone: 'accent',
shape: 'rounded',
size: 'sm',
ui: { base: 'shrink-0 px-2.5 py-1.5 font-medium' }
})
const customModelName = computed(() => customModelID.value.trim())
const usesCustomModel = computed(
() => !!providerDef.value.supportsCustomModel && !!customModelName.value
@ -113,6 +100,13 @@ function handlePaste(event: ClipboardEvent) {
onBeforeUnmount(clearReference)
function handleInputKeydown(event: KeyboardEvent) {
if (event.code !== 'Enter' || event.shiftKey || event.isComposing) return
event.preventDefault()
const target = event.currentTarget
if (target instanceof HTMLElement) target.closest('form')?.requestSubmit()
}
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
@ -127,113 +121,114 @@ function handleSubmit(e: Event) {
<template>
<TooltipProvider>
<div class="shrink-0 border-t border-border px-3 py-2">
<!-- Model selector & settings -->
<div class="mb-1.5 flex items-center gap-1">
<template v-if="isACPProvider">
<div class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-muted">
<icon-lucide-bot class="size-3" />
{{ acpAgentName }}
</div>
</template>
<ChatProfileSelect v-else-if="canSwitchProfile && (isCustomProvider || usesCustomModel)">
<template #value>
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
<div class="shrink-0 border-t border-border p-2.5">
<form @submit="handleSubmit" @paste.stop="handlePaste">
<InputGroup :disabled="isStreaming">
<template v-if="reference" #attachment>
<div class="flex items-center gap-2 rounded-lg bg-canvas p-1.5">
<img
:src="reference.previewURL"
alt="Reference image"
width="40"
height="40"
class="size-10 rounded object-cover"
/>
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ reference.file.name }}
</span>
<IconButton label="Remove reference image" size="xs" @click="clearReference">
<icon-lucide-x class="size-3" />
</IconButton>
</div>
</template>
</ChatProfileSelect>
<template v-else-if="isCustomProvider || usesCustomModel">
<div
class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-muted"
data-test-id="chat-custom-model-label"
>
<icon-lucide-bot class="size-3" />
{{ selectedModelName }}
</div>
</template>
<ProviderModelSelect v-else>
<template #value>{{ selectedModelName }}</template>
</ProviderModelSelect>
<div class="ml-auto">
<Tip :label="dialogs.providerSettings">
<button
type="button"
<textarea
v-model="input"
data-test-id="chat-input"
:placeholder="dialogs.describeChange"
:disabled="isStreaming"
rows="2"
aria-label="Describe a change"
class="block min-h-12 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60"
@keydown="handleInputKeydown"
@copy.stop
@cut.stop
/>
<template #leading>
<IconButton
label="Attach reference image"
size="sm"
:disabled="isStreaming"
@click="openReferenceDialog()"
>
<icon-lucide-image-plus class="size-4" />
</IconButton>
</template>
<template #model>
<div class="flex min-w-0 items-center">
<template v-if="isACPProvider">
<div class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted">
<icon-lucide-bot class="size-3 shrink-0" />
<span class="truncate">{{ acpAgentName }}</span>
</div>
</template>
<ChatProfileSelect
v-else-if="canSwitchProfile && (isCustomProvider || usesCustomModel)"
>
<template #value>
<span class="min-w-0 truncate">{{ selectedProfileName }}</span>
</template>
</ChatProfileSelect>
<div
v-else-if="isCustomProvider || usesCustomModel"
class="flex min-w-0 items-center gap-1 px-1.5 text-[10px] text-muted"
data-test-id="chat-custom-model-label"
>
<icon-lucide-bot class="size-3 shrink-0" />
<span class="truncate">{{ selectedModelName }}</span>
</div>
<ProviderModelSelect v-else>
<template #value>
<span class="min-w-0 truncate">{{ selectedModelName }}</span>
</template>
</ProviderModelSelect>
</div>
</template>
<template #actions>
<IconButton
:label="dialogs.providerSettings"
size="sm"
data-test-id="provider-settings-trigger"
:aria-label="dialogs.providerSettings"
class="rounded p-0.5 text-muted hover:bg-hover hover:text-surface"
@click="openSettingsDialog('ai')"
>
<icon-lucide-settings class="size-3" />
</button>
</Tip>
</div>
</div>
<!-- Input form -->
<div
v-if="reference"
class="mb-2 flex items-center gap-2 rounded-lg border border-border bg-canvas p-1.5"
>
<img
:src="reference.previewURL"
alt="Reference image"
class="size-10 rounded object-cover"
/>
<span class="min-w-0 flex-1 truncate text-[10px] text-surface">
{{ reference.file.name }}
</span>
<Tip label="Remove reference image">
<button
type="button"
aria-label="Remove reference image"
class="rounded p-1 text-muted hover:bg-hover hover:text-surface"
@click="clearReference"
>
<icon-lucide-x class="size-3" />
</button>
</Tip>
</div>
<form class="flex gap-1.5" @submit="handleSubmit" @paste.stop="handlePaste">
<Tip label="Attach reference image">
<button
type="button"
aria-label="Attach reference image"
class="shrink-0 rounded p-1.5 text-muted hover:bg-hover hover:text-surface"
:disabled="isStreaming"
@click="openReferenceDialog()"
>
<icon-lucide-image-plus class="size-4" />
</button>
</Tip>
<AppInput
v-model="input"
data-test-id="chat-input"
:placeholder="dialogs.describeChange"
class="min-w-0 flex-1 placeholder:text-muted"
:disabled="isStreaming"
@copy.stop
@cut.stop
/>
<Tip v-if="isStreaming" :label="dialogs.stopGenerating">
<button
type="button"
data-test-id="chat-stop-button"
:class="stopButton.base"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
</button>
</Tip>
<Tip v-else :label="dialogs.sendMessage">
<button
type="submit"
data-test-id="chat-send-button"
:class="sendButton.base"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3" />
</button>
</Tip>
<icon-lucide-settings class="size-3.5" />
</IconButton>
<IconButton
v-if="isStreaming"
:label="dialogs.stopGenerating"
size="sm"
data-test-id="chat-stop-button"
class="border border-border"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
</IconButton>
<IconButton
v-else
:label="dialogs.sendMessage"
size="sm"
type="submit"
data-test-id="chat-send-button"
class="bg-accent text-white hover:bg-accent/90 hover:text-white"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3.5" />
</IconButton>
</template>
</InputGroup>
</form>
</div>
</TooltipProvider>

View file

@ -135,7 +135,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
<div class="flex h-6 items-center justify-end">
<IconButton
:label="panels.independentCornerRadii"
size="md"
size="xs"
:active="independentCorners === true"
@click="actions.toggleIndependentCorners"
>
@ -189,7 +189,7 @@ function blendModeOptions(value: BlendMode | typeof MIXED) {
<template #actions>
<IconButton
:label="panels.independentCornerRadii"
size="md"
size="xs"
active
@click="actions.toggleIndependentCorners"
>

View file

@ -48,7 +48,7 @@ function setLayoutMode(mode: string) {
<IconButton
v-if="ctx.isFlex"
:label="panels.layoutWrap"
size="md"
size="xs"
:active="ctx.node.layoutWrap === 'WRAP'"
@click="ctx.updateProp('layoutWrap', ctx.node.layoutWrap === 'WRAP' ? 'NO_WRAP' : 'WRAP')"
>

View file

@ -23,7 +23,7 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<template v-if="CONTAINER_TYPES.includes(ctx.node.type)" #actions>
<IconButton
:label="ctx.node.layoutMode === 'NONE' ? panels.addAutoLayout : panels.removeAutoLayout"
size="md"
size="xs"
:active="ctx.node.layoutMode !== 'NONE'"
class="data-[state=on]:bg-accent/15"
@click="

View file

@ -34,21 +34,21 @@ function handleAlign(
<div class="flex gap-0.5">
<IconButton
:label="panels.alignLeft"
size="md"
size="xs"
@click="handleAlign(actions.align, 'horizontal', 'min')"
>
<icon-lucide-align-start-vertical class="size-3.5" />
</IconButton>
<IconButton
:label="panels.alignCenterHorizontally"
size="md"
size="xs"
@click="handleAlign(actions.align, 'horizontal', 'center')"
>
<icon-lucide-align-center-vertical class="size-3.5" />
</IconButton>
<IconButton
:label="panels.alignRight"
size="md"
size="xs"
@click="handleAlign(actions.align, 'horizontal', 'max')"
>
<icon-lucide-align-end-vertical class="size-3.5" />
@ -57,21 +57,21 @@ function handleAlign(
<div class="flex gap-0.5">
<IconButton
:label="panels.alignTop"
size="md"
size="xs"
@click="handleAlign(actions.align, 'vertical', 'min')"
>
<icon-lucide-align-start-horizontal class="size-3.5" />
</IconButton>
<IconButton
:label="panels.alignCenterVertically"
size="md"
size="xs"
@click="handleAlign(actions.align, 'vertical', 'center')"
>
<icon-lucide-align-center-horizontal class="size-3.5" />
</IconButton>
<IconButton
:label="panels.alignBottom"
size="md"
size="xs"
@click="handleAlign(actions.align, 'vertical', 'max')"
>
<icon-lucide-align-end-horizontal class="size-3.5" />
@ -145,13 +145,13 @@ function handleAlign(
</NumberField>
</Tip>
<div class="flex h-6 items-center justify-end gap-0.5">
<IconButton :label="panels.flipHorizontal" size="md" @click="actions.flip('horizontal')">
<IconButton :label="panels.flipHorizontal" size="xs" @click="actions.flip('horizontal')">
<icon-lucide-flip-horizontal-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.flipVertical" size="md" @click="actions.flip('vertical')">
<IconButton :label="panels.flipVertical" size="xs" @click="actions.flip('vertical')">
<icon-lucide-flip-vertical-2 class="size-3.5" />
</IconButton>
<IconButton :label="panels.rotate90" size="md" @click="actions.rotate(90)">
<IconButton :label="panels.rotate90" size="xs" @click="actions.rotate(90)">
<icon-lucide-rotate-cw-square class="size-3.5" />
</IconButton>
</div>

View file

@ -222,7 +222,7 @@ function onToggleSides(activeNode: SceneNode | null) {
</Tip>
<IconButton
:label="panels.strokeSides"
size="md"
size="xs"
class="size-[26px] shrink-0"
:active="expandedSides"
data-property="stroke-sides"
@ -235,7 +235,7 @@ function onToggleSides(activeNode: SceneNode | null) {
<div v-if="!isMixed && items.length > 0" class="mt-1.5 flex items-center gap-1.5">
<IconButton
:label="panels.strokeDash"
size="md"
size="xs"
class="shrink-0"
:active="strokeCtx.dashState(items[0]).on"
data-property="stroke-dash"

View file

@ -208,7 +208,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
>
<IconButton
:label="`${menu.bold} (${appMenuShortcutLabel('text.bold')})`"
size="md"
size="xs"
:active="ctx.activeFormatting.value.includes('bold')"
@click="ctx.actions.toggleBold"
>
@ -216,7 +216,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</IconButton>
<IconButton
:label="`${menu.italic} (${appMenuShortcutLabel('text.italic')})`"
size="md"
size="xs"
:active="ctx.activeFormatting.value.includes('italic')"
@click="ctx.actions.toggleItalic"
>
@ -224,7 +224,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</IconButton>
<IconButton
:label="`${menu.underline} (${appMenuShortcutLabel('text.underline')})`"
size="md"
size="xs"
:active="ctx.activeFormatting.value.includes('underline')"
@click="ctx.actions.toggleDecoration('UNDERLINE')"
>
@ -232,7 +232,7 @@ function featureEnabled(features: Array<{ tag: string; enabled: boolean }>, tag:
</IconButton>
<IconButton
:label="menu.strikethrough"
size="md"
size="xs"
:active="ctx.activeFormatting.value.includes('strikethrough')"
@click="ctx.actions.toggleDecoration('STRIKETHROUGH')"
>

View file

@ -2,6 +2,7 @@
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import type { ControlSize } from '@/theme/control'
import theme from '@/theme/input'
interface AppInputProps {
@ -16,7 +17,7 @@ interface AppInputProps {
max?: number
step?: number
tone?: 'default' | 'panel'
size?: 'sm' | 'md'
size?: ControlSize
state?: 'idle' | 'mixed' | 'bound' | 'invalid'
}

View file

@ -3,6 +3,7 @@ import { computed, normalizeClass, useAttrs } from 'vue'
import { tv } from 'tailwind-variants'
import theme from '@/theme/icon-button'
import type { ControlSize } from '@/theme/control'
import Tip from '@/components/ui/Tip.vue'
const {
@ -10,14 +11,14 @@ const {
disabled = false,
label,
side = 'top',
size = 'sm',
size = 'xs',
type = 'button'
} = defineProps<{
active?: boolean
disabled?: boolean
label?: string
side?: 'top' | 'bottom' | 'left' | 'right'
size?: 'sm' | 'md'
size?: ControlSize
type?: 'button' | 'submit' | 'reset'
}>()

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv, type VariantProps } from 'tailwind-variants'
import theme from '@/theme/input-group'
import type { ComponentUI } from '@/components/ui/types'
const inputGroup = tv(theme)
type InputGroupVariants = VariantProps<typeof inputGroup>
type InputGroupUI = ComponentUI<typeof theme>
const {
size = 'sm',
disabled = false,
ui
} = defineProps<{
size?: NonNullable<InputGroupVariants['size']>
disabled?: boolean
ui?: InputGroupUI
}>()
const cls = computed(() => inputGroup({ size, disabled }))
</script>
<template>
<div
data-slot="input-group"
:data-size="size"
:data-disabled="disabled || undefined"
:class="cls.root({ class: ui?.root })"
>
<div v-if="$slots.attachment" data-slot="input-group-attachment" :class="ui?.attachment">
<slot name="attachment" />
</div>
<div data-slot="input-group-control" :class="ui?.control">
<slot />
</div>
<div data-slot="input-group-toolbar" :class="cls.toolbar({ class: ui?.toolbar })">
<slot name="leading" />
<div data-slot="input-group-model" :class="cls.model({ class: ui?.model })">
<slot name="model" />
</div>
<div data-slot="input-group-actions" :class="cls.actions({ class: ui?.actions })">
<slot name="actions" />
</div>
</div>
</div>
</template>

13
src/theme/control.ts Normal file
View file

@ -0,0 +1,13 @@
export type ControlSize = 'xs' | 'sm' | 'md'
export const controlHeight = {
xs: 'h-6',
sm: 'h-7',
md: 'h-8'
} satisfies Record<ControlSize, string>
export const squareControlSize = {
xs: 'size-6',
sm: 'size-7',
md: 'size-8'
} satisfies Record<ControlSize, string>

View file

@ -1,11 +1,10 @@
import { panelIconButtonBase } from './panel/field'
export default {
base: 'flex cursor-pointer items-center justify-center bg-transparent text-muted outline-none hover:bg-hover hover:text-surface focus-visible:border-panel-focus',
variants: {
size: {
sm: 'size-5 rounded border-none text-sm leading-none',
md: panelIconButtonBase
xs: 'size-6 rounded border-none text-sm leading-none',
sm: 'size-7 rounded-md border-none text-sm leading-none',
md: 'size-8 rounded-md border-none text-base leading-none'
},
active: {
true: 'border-accent text-accent'

27
src/theme/input-group.ts Normal file
View file

@ -0,0 +1,27 @@
import { controlHeight } from '@/theme/control'
export default {
slots: {
root: 'min-w-0 rounded-xl border border-border bg-input transition-colors hover:border-muted/60 focus-within:border-panel-focus focus-within:ring-1 focus-within:ring-accent/30',
attachment: 'px-2 pt-2',
control:
'block min-h-12 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-xs leading-relaxed text-surface outline-none placeholder:text-muted disabled:cursor-not-allowed disabled:opacity-60',
toolbar: 'flex min-w-0 items-center gap-1 px-1.5 pb-1.5',
model: 'min-w-0 flex-1',
actions: 'ml-auto flex shrink-0 items-center gap-1'
},
variants: {
size: {
xs: { toolbar: controlHeight.xs },
sm: { toolbar: controlHeight.sm },
md: { toolbar: controlHeight.md }
},
disabled: {
true: { root: 'opacity-60' }
}
},
defaultVariants: {
size: 'sm' as const,
disabled: false
}
}

View file

@ -1,21 +1,25 @@
import { panelFieldBase, panelFieldState } from './panel/field'
const defaultInputBase =
'min-w-0 rounded-md border border-border bg-input text-surface outline-none hover:border-muted/60 focus:border-panel-focus focus:ring-1 focus:ring-accent/25 disabled:cursor-not-allowed disabled:opacity-60'
export default {
base: 'w-full tabular-nums',
variants: {
tone: {
default: panelFieldBase,
default: defaultInputBase,
panel: panelFieldBase
},
size: {
sm: 'px-2 text-[11px]',
md: 'px-2 text-[11px]'
xs: 'h-6 px-2 text-[11px]',
sm: 'h-7 px-2.5 text-xs',
md: 'h-8 px-3 text-xs'
},
state: panelFieldState
},
defaultVariants: {
tone: 'default' as const,
size: 'md' as const,
size: 'xs' as const,
state: 'idle' as const
}
}