Keep desktop transcript and plugin controls readable after layout regressions

Two desktop surfaces regressed in ways that made the app harder to read and operate.
The plugin header compressed summary cards and action buttons too early for the available width,
and the rewind/copied affordances in chat were sharing the same horizontal lane as message bubbles,
which visually broke the expected "user on the right, assistant on the left" transcript structure.

This commit keeps both fixes narrow: the plugin header now delays its split layout and lets summary
cards and controls reflow responsively, while chat message actions live inside each message column so
interaction affordances no longer distort the bubble alignment.

Constraint: Desktop settings and transcript layouts must remain readable in narrower window widths
Rejected: Keep message actions inline with the bubble row | action buttons keep stretching the message lane and blur role alignment
Rejected: Hide rewind and copy entirely until a larger redesign | removes the control instead of fixing the regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep transcript actions inside the message column unless a future redesign also redefines bubble alignment rules
Tested: bun run test src/__tests__/pluginsSettings.test.tsx --run; bun run test src/components/chat/MessageList.test.tsx --run; bun run lint; bun run build; agent-browser verification against local mock desktop session
Not-tested: Real Tauri runtime screenshot on a live backend session after these layout changes
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 14:55:40 +08:00
parent 0f533efb8d
commit 70fb6c429e
5 changed files with 96 additions and 31 deletions

View File

@ -9,8 +9,11 @@ type Props = {
export function AssistantMessage({ content, isStreaming }: Props) {
return (
<div className="group mb-5 ml-10 flex items-end gap-1.5">
<div className="min-w-0">
<div className="group mb-5 flex justify-start">
<div
data-message-shell="assistant"
className="flex min-w-0 w-full max-w-[84%] flex-col items-start gap-2 sm:max-w-[80%] lg:max-w-[74%]"
>
<div className="rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm">
<MarkdownRenderer content={content} />
{!isStreaming && <InlineImageGallery text={content} />}
@ -18,12 +21,13 @@ export function AssistantMessage({ content, isStreaming }: Props) {
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
)}
</div>
</div>
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
/>
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
align="start"
/>
</div>
</div>
)
}

View File

@ -5,6 +5,7 @@ type Props = {
copyLabel: string
onRewind?: () => void
rewindLabel?: string
align?: 'start' | 'end'
}
export function MessageActionBar({
@ -12,6 +13,7 @@ export function MessageActionBar({
copyLabel,
onRewind,
rewindLabel = 'Rewind to here',
align = 'start',
}: Props) {
const hasCopy = Boolean(copyText?.trim())
const hasRewind = Boolean(onRewind)
@ -19,7 +21,13 @@ export function MessageActionBar({
if (!hasCopy && !hasRewind) return null
return (
<div className="shrink-0 pb-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div
data-message-actions
data-align={align}
className={`flex w-full opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100 ${
align === 'end' ? 'justify-end' : 'justify-start'
}`}
>
<div className="flex items-center gap-1.5">
{hasRewind && (
<button
@ -27,7 +35,7 @@ export function MessageActionBar({
onClick={onRewind}
aria-label={rewindLabel}
title={rewindLabel}
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="inline-flex min-h-7 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[14px]">undo</span>
<span className="hidden min-[920px]:inline">Rewind</span>
@ -39,7 +47,7 @@ export function MessageActionBar({
label={copyLabel}
displayLabel="Copy"
displayCopiedLabel="Copied"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="inline-flex min-h-7 items-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-2.5 text-[11px] font-medium text-[var(--color-text-tertiary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
/>
)}
</div>

View File

@ -338,6 +338,43 @@ describe('MessageList nested tool calls', () => {
)
})
it('keeps user actions anchored to the right bubble and assistant actions to the left bubble', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'user-1',
type: 'user_text',
content: '请把这条 prompt 放在右侧',
timestamp: 1,
},
{
id: 'assistant-1',
type: 'assistant_text',
content: '这条回复应该停在左侧。',
timestamp: 2,
},
],
}),
},
})
render(<MessageList />)
const userShell = screen.getByText('请把这条 prompt 放在右侧').closest('[data-message-shell="user"]')
const assistantShell = screen.getByText('这条回复应该停在左侧。').closest('[data-message-shell="assistant"]')
const userActions = screen.getByRole('button', { name: 'Copy prompt' }).closest('[data-message-actions]')
const assistantActions = screen.getByRole('button', { name: 'Copy reply' }).closest('[data-message-actions]')
expect(userShell).toBeTruthy()
expect(userShell?.className).toContain('items-end')
expect(assistantShell).toBeTruthy()
expect(assistantShell?.className).toContain('items-start')
expect(userActions?.getAttribute('data-align')).toBe('end')
expect(assistantActions?.getAttribute('data-align')).toBe('start')
})
it('opens a rewind preview modal for user messages', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {

View File

@ -13,8 +13,11 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro
const hasText = content.trim().length > 0
return (
<div className="group mb-5 flex items-end justify-end gap-1.5">
<div className="min-w-0 max-w-[82%] space-y-2">
<div className="group mb-5 flex justify-end">
<div
data-message-shell="user"
className="flex min-w-0 w-full max-w-[82%] flex-col items-end gap-2 sm:max-w-[78%] lg:max-w-[72%]"
>
{attachments && attachments.length > 0 && (
<AttachmentGallery attachments={attachments} variant="message" />
)}
@ -27,16 +30,17 @@ export function UserMessage({ content, attachments, onRewind, rewindLabel }: Pro
{content}
</div>
)}
</div>
{hasText && (
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
onRewind={onRewind}
rewindLabel={rewindLabel}
/>
)}
{hasText && (
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
onRewind={onRewind}
rewindLabel={rewindLabel}
align="end"
/>
)}
</div>
</div>
)
}

View File

@ -102,7 +102,7 @@ export function PluginList() {
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.5fr)_minmax(340px,1fr)] xl:items-end">
<div className="grid gap-4 px-5 py-5 min-w-0 2xl:grid-cols-[minmax(0,1.45fr)_minmax(420px,0.95fr)] 2xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.browserEyebrow')}
@ -129,8 +129,8 @@ export function PluginList() {
)}
</div>
<div className="flex flex-col gap-3 min-w-0">
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-4">
<div className="flex flex-col gap-3 min-w-0 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="grid min-w-0 grid-cols-[repeat(auto-fit,minmax(120px,1fr))] gap-3">
<SummaryCard
label={t('settings.plugins.summary.total')}
value={String(summary?.total ?? plugins.length)}
@ -152,12 +152,22 @@ export function PluginList() {
icon="storefront"
/>
</div>
<div className="flex flex-wrap gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => void fetchPlugins(currentWorkDir)}>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button
variant="secondary"
size="sm"
className="min-h-9 flex-1 sm:flex-none"
onClick={() => void fetchPlugins(currentWorkDir)}
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{t('settings.plugins.refresh')}
</Button>
<Button size="sm" onClick={handleReload} loading={isApplying}>
<Button
size="sm"
className="min-h-9 flex-1 sm:flex-none"
onClick={handleReload}
loading={isApplying}
>
<span className="material-symbols-outlined text-[16px]">sync</span>
{t('settings.plugins.apply')}
</Button>
@ -316,12 +326,14 @@ function SummaryCard({
icon: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 min-w-0">
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<div className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3">
<div className="flex min-w-0 items-start gap-1.5 text-[11px] uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<span className="truncate">{label}</span>
<span className="min-w-0 break-words text-[10px] leading-4 whitespace-normal">
{label}
</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
<div className="mt-2 truncate text-xl font-semibold text-[var(--color-text-primary)]">
{value}
</div>
</div>