mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
0f533efb8d
commit
70fb6c429e
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user