Keep all assistant-side transcript content on one shared content rail

The desktop transcript had drifted into multiple competing left edges.
Assistant bubbles, thinking rows, tool cards, permissions, and standalone
results were using different offsets, which made the timeline feel visually
broken even when the underlying data was correct.

This change removes the ad hoc assistant-side indentation and makes the
assistant output lane follow the same content rail as the composer. The
assistant message component still distinguishes short bubble replies from
markdown-heavy document replies, but both now sit on the same shared left
alignment. Supporting chat blocks were updated to use that same rail so the
whole transcript reads as one coherent column.

Constraint: Assistant transcript content must align with the composer rail, not with local per-block offsets
Rejected: Keep tool/thinking blocks on a separate inset lane | creates multiple left edges and keeps the transcript visually inconsistent
Rejected: Fix only final assistant replies | leaves the rest of the assistant-side timeline misaligned
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Treat the composer rail as the canonical left edge for all assistant-side transcript blocks unless the entire transcript layout is redesigned together
Tested: bun run test src/components/chat/MessageList.test.tsx --run; bun run lint; bun run build
Not-tested: Real Tauri runtime screenshot against a live session after this unified alignment change
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 15:30:05 +08:00
parent 70fb6c429e
commit bef9596ed5
9 changed files with 71 additions and 11 deletions

View File

@ -135,7 +135,7 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
if (!activeQuestion) return null
return (
<div className={`mb-4 ml-10 rounded-[var(--radius-lg)] border overflow-hidden ${
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'

View File

@ -8,14 +8,23 @@ type Props = {
}
export function AssistantMessage({ content, isStreaming }: Props) {
const documentLayout = shouldUseDocumentLayout(content)
return (
<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%]"
data-layout={documentLayout ? 'document' : 'bubble'}
className={`flex min-w-0 flex-col items-start gap-2 ${
documentLayout
? 'w-full max-w-full'
: 'w-full max-w-[88%] sm:max-w-[80%] lg:max-w-[72%]'
}`}
>
<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} />
<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 ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<MarkdownRenderer content={content} variant={documentLayout ? 'document' : 'default'} />
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
@ -31,3 +40,18 @@ export function AssistantMessage({ content, isStreaming }: Props) {
</div>
)
}
function shouldUseDocumentLayout(content: string) {
const normalized = content.trim()
if (!normalized) return false
if (/```/.test(normalized)) return true
if (/^\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s|\|.+\|)/m.test(normalized)) return true
const paragraphs = normalized
.split(/\n\s*\n/)
.map((chunk) => chunk.trim())
.filter(Boolean)
return paragraphs.length >= 2 || normalized.split('\n').filter((line) => line.trim()).length >= 8
}

View File

@ -371,10 +371,46 @@ describe('MessageList nested tool calls', () => {
expect(userShell?.className).toContain('items-end')
expect(assistantShell).toBeTruthy()
expect(assistantShell?.className).toContain('items-start')
expect(assistantShell?.className).not.toContain('ml-10')
expect(userActions?.getAttribute('data-align')).toBe('end')
expect(assistantActions?.getAttribute('data-align')).toBe('start')
})
it('uses the document column for markdown-heavy assistant replies', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'assistant-doc',
type: 'assistant_text',
content: [
'## 交付结果',
'',
'已完成以下内容:',
'',
'- 添加任务',
'- 删除任务',
'',
'```bash',
'npm run build',
'```',
].join('\n'),
timestamp: 1,
},
],
}),
},
})
render(<MessageList />)
const assistantShell = screen.getByText('交付结果').closest('[data-message-shell="assistant"]')
expect(assistantShell?.getAttribute('data-layout')).toBe('document')
expect(assistantShell?.className).toContain('w-full')
expect(assistantShell?.className).not.toContain('ml-10')
})
it('opens a rewind preview modal for user messages', async () => {
vi.spyOn(sessionsApi, 'rewind').mockResolvedValue({
target: {

View File

@ -127,7 +127,7 @@ export function PermissionDialog({ requestId, toolName, input, description }: Pr
const allowRawToggle = !preview
return (
<div className={`mb-4 ml-10 overflow-hidden rounded-[var(--radius-lg)] border ${
<div className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'

View File

@ -23,7 +23,7 @@ export function StreamingIndicator() {
}
return (
<div className="mb-2 ml-10 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<div className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<span className="text-[var(--color-brand)] animate-shimmer text-xs"></span>
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
{elapsedSeconds > 0 && (

View File

@ -18,7 +18,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
const preview = firstLine.length > 80 ? firstLine.slice(0, 80) + '...' : firstLine
return (
<div className="mb-1 ml-10">
<div className="mb-1">
<style>{thinkingStyles}</style>
<button
onClick={() => setExpanded((v) => !v)}

View File

@ -51,7 +51,7 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2 ml-10'
compact ? 'mb-0' : 'mb-2'
}`}>
<button
type="button"

View File

@ -128,7 +128,7 @@ function AgentToolGroup({
}, [isStreaming])
return (
<div className="mb-2 ml-10">
<div className="mb-2">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
@ -201,7 +201,7 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt
}, [hasNestedToolCalls, isStreaming])
return (
<div className="mb-2 ml-10">
<div className="mb-2">
<button
type="button"
onClick={() => setExpanded((v) => !v)}

View File

@ -27,7 +27,7 @@ export function ToolResultBlock({ content, isError, toolName, standalone = true
const hasMore = text.length > 200
return (
<div className={`mb-2 ml-10 overflow-hidden rounded-xl border ${
<div className={`mb-2 overflow-hidden rounded-xl border ${
isError
? 'border-[var(--color-error)]/20'
: 'border-[var(--color-outline-variant)]/20'