From 8004496f1068ef7efa3067bcc0fad8b1ab245286 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:42:11 -0400 Subject: [PATCH 1/7] feat(frontend): restyle the session transcript as a plain log with collapsible tool details --- frontend/eslint.config.js | 2 +- frontend/package.json | 1 + .../src/components/message/CodePreview.tsx | 3 +- .../message/ComposerActivityBar.test.tsx | 8 +- .../message/ComposerActivityBar.tsx | 14 +- frontend/src/components/message/DiffLines.tsx | 82 +++++ .../components/message/ExecuteToolPart.tsx | 167 ++++----- .../src/components/message/FileToolRender.tsx | 164 ++++++--- .../src/components/message/MessageError.tsx | 2 +- .../components/message/MessagePart.test.tsx | 133 +++++++- .../src/components/message/MessagePart.tsx | 8 +- .../components/message/MessageSkeleton.tsx | 149 ++++---- .../components/message/MessageThread.test.tsx | 60 +++- .../src/components/message/MessageThread.tsx | 320 ++++++++++-------- .../message/PromptInput.composer.test.tsx | 19 +- .../src/components/message/PromptInput.tsx | 4 +- frontend/src/components/message/RetryPart.tsx | 41 +-- .../components/message/StepFileChanges.tsx | 21 +- .../components/message/ToolCallPart.test.tsx | 73 ++++ .../src/components/message/ToolCallPart.tsx | 288 ++++++++-------- .../components/message/ToolDetails.test.tsx | 119 +++++++ .../src/components/message/ToolDetails.tsx | 86 +++++ .../components/message/transcriptStyles.ts | 5 + .../components/schedules/schedule-utils.ts | 10 +- frontend/src/lib/clampDisplayText.test.ts | 21 +- frontend/src/lib/clampDisplayText.ts | 12 + frontend/src/lib/diffLines.test.ts | 48 +++ frontend/src/lib/diffLines.ts | 58 ++++ frontend/src/lib/utils.ts | 12 + pnpm-lock.yaml | 3 + 30 files changed, 1328 insertions(+), 605 deletions(-) create mode 100644 frontend/src/components/message/DiffLines.tsx create mode 100644 frontend/src/components/message/ToolDetails.test.tsx create mode 100644 frontend/src/components/message/ToolDetails.tsx create mode 100644 frontend/src/components/message/transcriptStyles.ts create mode 100644 frontend/src/lib/diffLines.test.ts create mode 100644 frontend/src/lib/diffLines.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 926b5140a..26bf08104 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -30,7 +30,7 @@ export default defineConfig([ }, }, { - files: ['src/components/ui/**/*.{ts,tsx}', 'src/components/message/FileToolRender.tsx'], + files: ['src/components/ui/**/*.{ts,tsx}', 'src/components/message/FileToolRender.tsx', 'src/components/message/CodePreview.tsx', 'src/components/message/ToolDetails.tsx'], rules: { 'react-refresh/only-export-components': 'off', }, diff --git a/frontend/package.json b/frontend/package.json index e4a6eddb8..b7b4dfd48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "date-fns": "^4.1.0", "diff": "^8.0.2", "jsqr": "^1.4.0", + "lowlight": "^3.3.0", "lucide-react": "^0.546.0", "mermaid": "^11.12.2", "react": "^19.1.1", diff --git a/frontend/src/components/message/CodePreview.tsx b/frontend/src/components/message/CodePreview.tsx index 8ee22e484..945a5c248 100644 --- a/frontend/src/components/message/CodePreview.tsx +++ b/frontend/src/components/message/CodePreview.tsx @@ -15,7 +15,8 @@ function extractFileFromPath(path: string): string { return parts[parts.length - 1] || path } -function detectLanguage(fileName: string): string { +/** Maps a file name to the lowlight language name used for previews. */ +export function detectLanguage(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase() const langMap: Record = { diff --git a/frontend/src/components/message/ComposerActivityBar.test.tsx b/frontend/src/components/message/ComposerActivityBar.test.tsx index d14687420..3af4647b7 100644 --- a/frontend/src/components/message/ComposerActivityBar.test.tsx +++ b/frontend/src/components/message/ComposerActivityBar.test.tsx @@ -41,15 +41,15 @@ describe('ComposerActivityBar', () => { expect(container).toBeEmptyDOMElement() }) - it('sits inside the docked composer on mobile and below the composer on desktop', () => { + it('fills the gap above the controls row without taking layout height', () => { useSessionStatus.getState().setStatus('session-a', { type: 'busy' }) render() const bar = screen.getByRole('progressbar', { name: 'Session working' }) - expect(bar).toHaveClass('pointer-events-none', 'flex') - expect(bar).toHaveClass('absolute', 'inset-x-3', 'bottom-4', 'md:-bottom-[11px]') + expect(bar).toHaveClass('pointer-events-none', 'flex', 'absolute', 'inset-x-0', 'bottom-full') expect(bar).not.toHaveClass('fixed') + expect(bar).not.toHaveClass('pb-1.5') }) it('renders a masked segment track with only the sweeping window', () => { @@ -58,7 +58,7 @@ describe('ComposerActivityBar', () => { render() const track = trackOf(screen.getByRole('progressbar', { name: 'Session working' })) - expect(track).toHaveClass('scanner-track', 'relative', 'h-2', 'w-full') + expect(track).toHaveClass('scanner-track', 'relative', 'h-1.5', 'md:h-2', 'w-full') const children = Array.from(track.children) as HTMLElement[] expect(children).toHaveLength(1) diff --git a/frontend/src/components/message/ComposerActivityBar.tsx b/frontend/src/components/message/ComposerActivityBar.tsx index 05e9bbe13..57518bdff 100644 --- a/frontend/src/components/message/ComposerActivityBar.tsx +++ b/frontend/src/components/message/ComposerActivityBar.tsx @@ -25,11 +25,11 @@ const ACTIVITY_PRESENTATION = { } as const satisfies Record /** - * Session activity scanner shown by the composer. On mobile it sits inside the bottom edge of the - * docked composer; from `md` it is anchored in the gap below the composer frame. A fading lit window - * of 4x8px segments sweeps across the strip. Renders nothing while the session is idle and has no - * background activity; otherwise announces the current activity through an indeterminate - * progressbar. + * Session activity scanner shown by the composer. It overlays the gap above the bottom controls row + * without taking layout height, so the composer never resizes or jumps when activity starts or stops. + * A fading lit window of 4x8px segments sweeps across the strip. Renders nothing while the session is + * idle and has no background activity; otherwise announces the current activity through an + * indeterminate progressbar. */ export function ComposerActivityBar({ sessionID }: ComposerActivityBarProps) { const status = useSessionStatusForSession(sessionID) @@ -52,9 +52,9 @@ export function ComposerActivityBar({ sessionID }: ComposerActivityBarProps) { aria-label={label} aria-valuemin={0} aria-valuemax={100} - className="pointer-events-none absolute inset-x-3 bottom-4 flex md:-bottom-[11px]" + className="pointer-events-none absolute inset-x-0 bottom-full flex" > - + ) } diff --git a/frontend/src/components/message/DiffLines.tsx b/frontend/src/components/message/DiffLines.tsx new file mode 100644 index 000000000..a6e9db46a --- /dev/null +++ b/frontend/src/components/message/DiffLines.tsx @@ -0,0 +1,82 @@ +import { useMemo, type ReactNode } from 'react' +import { common, createLowlight } from 'lowlight' +import type { DiffRow } from '@/lib/diffLines' +import { cn } from '@/lib/utils' + +const lowlight = createLowlight(common) + +type HighlightedNode = ReturnType['children'][number] + +const ROW_CLASS: Record = { + add: 'bg-diff-add/15', + remove: 'bg-diff-delete/15', + context: '', + gap: '', +} + +const MARKER_CLASS: Record = { + add: 'text-diff-add', + remove: 'text-diff-delete', + context: 'text-muted-foreground', + gap: 'text-muted-foreground', +} + +const MARKER_TEXT: Record = { + add: '+', + remove: '-', + context: ' ', + gap: '⋯', +} + +function renderHighlightedNode(node: HighlightedNode, key: number): ReactNode { + if (node.type === 'text') return node.value + if (node.type !== 'element') return null + + const rawClassName = node.properties?.className + const className = Array.isArray(rawClassName) + ? rawClassName.join(' ') + : typeof rawClassName === 'string' + ? rawClassName + : undefined + + return ( + + {node.children.map((child, index) => renderHighlightedNode(child, index))} + + ) +} + +function highlightLine(text: string, language: string): ReactNode { + if (!text || !lowlight.registered(language)) return text + return lowlight + .highlight(language, text) + .children.map((node, index) => renderHighlightedNode(node, index)) +} + +/** Renders unified diff rows as a themed, line-numbered, syntax-highlighted block. */ +export function DiffLines({ rows, language }: { rows: DiffRow[]; language: string }) { + const highlighted = useMemo( + () => rows.map((row) => (row.kind === 'gap' ? row.text : highlightLine(row.text, language))), + [rows, language], + ) + const maxLineNumber = rows.reduce((max, row) => Math.max(max, row.newLine ?? row.oldLine ?? 0), 0) + const gridTemplateColumns = `${Math.max(String(maxLineNumber).length, 1)}ch 1ch minmax(0, 1fr)` + + return ( + + {rows.map((row, index) => ( + + + {row.kind === 'gap' ? '' : row.newLine ?? row.oldLine} + + {MARKER_TEXT[row.kind]} + {highlighted[index]} + + ))} + + ) +} diff --git a/frontend/src/components/message/ExecuteToolPart.tsx b/frontend/src/components/message/ExecuteToolPart.tsx index 3a7e226a4..1d3b92bc1 100644 --- a/frontend/src/components/message/ExecuteToolPart.tsx +++ b/frontend/src/components/message/ExecuteToolPart.tsx @@ -3,12 +3,19 @@ import ReactMarkdown from 'react-markdown' import { Loader2 } from 'lucide-react' import { toolContentText, type SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { useSettings } from '@/hooks/useSettings' +import { useMobile } from '@/hooks/useMobile' import { toolMetadata } from '@/lib/backgroundWork' import { clampDisplayText } from '@/lib/clampDisplayText' +import { cn } from '@/lib/utils' import { markdownRehypePlugins } from '@/lib/markdownRehypePlugins' import { CopyButton } from '@/components/ui/copy-button' import { FileReferenceText } from './FileReferenceText' import { ToolContentFiles } from './ToolContentFiles' +import { ToolDetails, useToolDetailsState } from './ToolDetails' +import { TRANSCRIPT_DETAIL_CLASS, TRANSCRIPT_ROW_CLASS } from './transcriptStyles' + +const PREVIEW_CALL_LIMIT = 5 +const MOBILE_PREVIEW_CALL_LIMIT = 3 type ExecuteCallStatus = 'running' | 'completed' | 'error' @@ -152,7 +159,7 @@ function ExecuteCallRow({ call, onFileClick }: { call: ExecuteCall; onFileClick? ) return ( -
+
{expandable ? ( -
- - {calls.length > 0 && ( -
- {calls.map((call, index) => ( +
+ + + {!detailsOpen && calls.length > 0 && ( +
+ {calls.slice(0, previewCallLimit).map((call, index) => ( ))} + {calls.length > previewCallLimit && ( +
({calls.length - previewCallLimit} more calls)
+ )}
)} - + - {!expanded && errorPreview.length > 0 && ( -
+ {!detailsOpen && errorPreview.length > 0 && ( +
{errorPreview.map((line, index) => (
{index === 0 ? `↳ ${line}` : ` ${line}`} @@ -280,57 +279,63 @@ export function ExecuteToolPart({ part, isWaitingPermission, onFileClick }: Exec
)} - {expanded && ( -
-
-
Code
- {code ? ( - - ) : ( -
Waiting for code…
- )} + + {calls.length > 0 && ( +
+ {calls.map((call, index) => ( + + ))}
+ )} + +
+
Code
+ {code ? ( + + ) : ( +
Waiting for code…
+ )} +
-
-
Output
- {parsedOutput ? ( - <> - - {parsedOutput.rest && ( -
-                    {parsedOutput.rest}
-                  
- )} - - ) : displayOutput ? ( -
-                {displayOutput}
-              
- ) : ( -
- {status === 'completed' ? 'No output' : 'Waiting for output…'} -
- )} -
+
+
Output
+ {parsedOutput ? ( + <> + + {parsedOutput.rest && ( +
+                  {parsedOutput.rest}
+                
+ )} + + ) : displayOutput ? ( +
+              {displayOutput}
+            
+ ) : ( +
+ {status === 'completed' ? 'No output' : 'Waiting for output…'} +
+ )} +
-
- {code && ( -
- - Copy code -
- )} - {output && ( -
- - Copy output -
- )} -
+
+ {code && ( +
+ + Copy code +
+ )} + {output && ( +
+ + Copy output +
+ )}
- )} +
) } diff --git a/frontend/src/components/message/FileToolRender.tsx b/frontend/src/components/message/FileToolRender.tsx index 3b67c708e..d2d1af66b 100644 --- a/frontend/src/components/message/FileToolRender.tsx +++ b/frontend/src/components/message/FileToolRender.tsx @@ -1,10 +1,16 @@ -import React, { useState } from 'react' +import React, { useMemo } from 'react' import type { FileDiffInfo, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { useSettings } from '@/hooks/useSettings' +import { useMobile } from '@/hooks/useMobile' import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' import { DiffStats } from './DiffStats' -import { CodePreview } from './CodePreview' +import { CodePreview, detectLanguage } from './CodePreview' +import { DiffLines } from './DiffLines' import { ToolContentFiles } from './ToolContentFiles' +import { ToolDetails, useToolDetailsState } from './ToolDetails' +import { TRANSCRIPT_DETAIL_CLASS, TRANSCRIPT_ROW_CLASS } from './transcriptStyles' +import { parseUnifiedDiff } from '@/lib/diffLines' +import { cn } from '@/lib/utils' import { ChevronDown, ChevronUp } from 'lucide-react' function isFileDiff(data: unknown): data is FileDiffInfo { @@ -42,26 +48,8 @@ export function getRelativePath(filePath: string): string { return filePath } -function PatchViewer({ patch }: { patch: string }) { - const lines = patch.split('\n') - - return ( -
-      {lines.map((line, index) => {
-        const tone = line.startsWith('@@')
-          ? 'text-info'
-          : line.startsWith('+++') || line.startsWith('---')
-            ? 'text-muted-foreground'
-            : line.startsWith('+')
-              ? 'text-diff-add'
-              : line.startsWith('-')
-                ? 'text-diff-delete'
-                : 'text-muted-foreground'
-        return 
{line || ' '}
- })} -
- ) -} +const PATCH_PREVIEW_LINE_COUNT = 8 +const MOBILE_PATCH_PREVIEW_LINE_COUNT = 4 interface FileToolRenderProps { part: SessionMessageAssistantTool @@ -74,18 +62,24 @@ interface FileToolRenderProps { function FileToolRender({ part, filediff, filePath, content, toolName, onFileClick }: FileToolRenderProps) { const { preferences } = useSettings() + const isMobile = useMobile() const isRemoteServer = !isLocalOpenCodeServer(useOpenCodeServerId()) const isReadTool = toolName === 'Read' - const isEditTool = toolName === 'Edit' - const isWriteTool = toolName === 'Write' + const hasDiff = Boolean(filediff) const hasExpandableContent = !isReadTool && (filediff || content) const isFileLink = Boolean(onFileClick) && !isRemoteServer - - const isFileMutatingTool = isEditTool || isWriteTool - const defaultExpanded = isFileMutatingTool + + const defaultExpanded = hasDiff ? (preferences?.expandDiffs ?? true) : (preferences?.expandToolCalls ?? false) - const [expanded, setExpanded] = useState(defaultExpanded) + const [detailsOpen, setDetailsOpen] = useToolDetailsState(defaultExpanded) + const relativePath = filePath ? getRelativePath(filePath) : undefined + const detailsTitle = relativePath ? `${toolName} ${relativePath}` : toolName + const diffRows = useMemo(() => (filediff ? parseUnifiedDiff(filediff.patch) : []), [filediff]) + const language = useMemo(() => detectLanguage(filePath ?? ''), [filePath]) + const previewLineCount = isMobile ? MOBILE_PATCH_PREVIEW_LINE_COUNT : PATCH_PREVIEW_LINE_COUNT + const previewRows = diffRows.slice(0, previewLineCount) + const hiddenLineCount = diffRows.length - previewRows.length const getDuration = () => { if (part.time.ran === undefined || part.time.completed === undefined) return '' @@ -99,44 +93,102 @@ function FileToolRender({ part, filediff, filePath, content, toolName, onFileCli } } - return ( -
- + + {!detailsOpen && previewRows.length > 0 && ( +
+ + {hiddenLineCount > 0 && ( + + )} +
+ )} + + {detailsOpen && !isMobile && ( +
+ +
+ )} + + {isMobile && ( + + + )}
-
- {filediff && } + + + + ) + } + + return ( +
+
+ - + - {expanded && hasExpandableContent && ( -
- {filediff && } - {content && !filediff && } -
+ {hasExpandableContent && ( + + {content && } + )}
) diff --git a/frontend/src/components/message/MessageError.tsx b/frontend/src/components/message/MessageError.tsx index 68e45e0c0..9cbe8e156 100644 --- a/frontend/src/components/message/MessageError.tsx +++ b/frontend/src/components/message/MessageError.tsx @@ -36,7 +36,7 @@ export const MessageError = memo(function MessageError({ error }: MessageErrorPr const isAborted = error.type === 'aborted' const tone = isAborted ? 'text-muted-foreground' - : 'rounded-md border border-destructive/30 bg-destructive/10 px-2 py-1 text-destructive' + : 'border-l-2 border-destructive pl-2 text-destructive' return (
diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index a6c7a2b32..69a502542 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -15,12 +15,18 @@ import type { const mocks = vi.hoisted(() => ({ useSettings: vi.fn(), + useMobile: vi.fn(), })) vi.mock('@/hooks/useSettings', () => ({ useSettings: mocks.useSettings, })) +vi.mock('@/hooks/useMobile', async (importOriginal) => ({ + ...(await importOriginal()), + useMobile: mocks.useMobile, +})) + interface MockSettingsReturn { preferences: { simpleChatMode: boolean @@ -79,6 +85,7 @@ const completedShell = ( describe('MessagePart', () => { beforeEach(() => { vi.clearAllMocks() + mocks.useMobile.mockReturnValue(false) setupSettings({ simpleChatMode: false, showReasoning: false, @@ -240,12 +247,13 @@ describe('MessagePart', () => { it('renders a completed shell tool with its output', () => { renderWithProviders() - expect(screen.getByText('shell')).toBeInTheDocument() + const preview = screen.getByRole('button', { name: 'Show shell details' }) expect(screen.getByText('git status')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(preview) - expect(screen.getByText('clean tree')).toBeInTheDocument() + expect(screen.getByText('Output:')).toBeInTheDocument() + expect(document.querySelector('pre')?.textContent).toBe('clean tree') }) it('renders a running shell tool with its command', () => { @@ -261,7 +269,9 @@ describe('MessagePart', () => { it('renders a streaming tool with a preparing state', () => { renderWithProviders() - expect(screen.getByText('shell')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Show shell details' })) + + expect(screen.getByText('Preparing tool call...')).toBeInTheDocument() }) it('renders an error tool with the error message', () => { @@ -291,12 +301,12 @@ describe('MessagePart', () => { renderWithProviders() - expect(screen.getByText('shell')).toBeInTheDocument() + const preview = screen.getByRole('button', { name: 'Show shell details' }) expect(screen.getByText('bun test')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(preview) - expect(screen.getByText('12 tests passed')).toBeInTheDocument() + expect(document.querySelector('pre')?.textContent).toBe('12 tests passed') }) it('renders null for a non-subagent tool in simpleChatMode', () => { @@ -562,9 +572,65 @@ describe('MessagePart', () => { />, ) + const header = screen.getByRole('button', { name: /Edit \/test\/file\.txt/ }) + expect(within(header).getByText('←')).toBeInTheDocument() expect(screen.getByText('/test/file.txt')).toBeInTheDocument() expect(screen.getByText('+1')).toBeInTheDocument() expect(screen.getByText('-1')).toBeInTheDocument() + expect(screen.getByText('old')).toBeInTheDocument() + expect(screen.getByText('new')).toBeInTheDocument() + }) + + it('previews the first changed lines of a long patch until the diff is opened', () => { + setupSettings({ + simpleChatMode: false, + showReasoning: false, + expandToolCalls: false, + expandDiffs: false, + }) + + const changedLines = Array.from({ length: 20 }, (_, index) => `+line ${index + 1}`).join('\n') + + renderWithProviders( + , + ) + + const header = screen.getByRole('button', { name: /Edit \/test\/file\.txt/ }) + expect(within(header).getByText('←')).toBeInTheDocument() + + const preview = screen.getByRole('button', { name: 'Show diff for /test/file.txt' }) + expect(within(preview).getByText('line 1')).toBeInTheDocument() + expect(within(preview).getByText('line 8')).toBeInTheDocument() + expect(within(preview).queryByText('line 9')).toBeNull() + expect(within(preview).getByText('1')).toBeInTheDocument() + expect(within(preview).getByText('8')).toBeInTheDocument() + expect(within(preview).queryByText('9')).toBeNull() + expect(screen.getByText('(12 more lines)')).toBeInTheDocument() + expect(screen.queryByText('line 20')).toBeNull() + + fireEvent.click(preview) + + expect(screen.queryByRole('button', { name: 'Show diff for /test/file.txt' })).toBeNull() + expect(screen.getByText('line 9')).toBeInTheDocument() + expect(screen.getByText('line 20')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() }) it('renders a write tool with its file path', () => { @@ -631,6 +697,13 @@ describe('MessagePart', () => { }) it('renders every file of a multi-file patch with per-file links', () => { + setupSettings({ + simpleChatMode: false, + showReasoning: false, + expandToolCalls: false, + expandDiffs: true, + }) + const onFileClick = vi.fn() renderWithProviders( { />, ) - expect(screen.getByText('src/a.ts')).toBeInTheDocument() - expect(screen.getByText('src/b.ts')).toBeInTheDocument() - - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(2) - fireEvent.click(buttons[0]) - fireEvent.click(buttons[1]) - - expect(screen.getByText('+new-a')).toBeInTheDocument() - expect(screen.getByText('+new-b')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Patch src\/a\.ts/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Patch src\/b\.ts/ })).toBeInTheDocument() + expect(screen.getByText('old-a')).toBeInTheDocument() + expect(screen.getByText('old-b')).toBeInTheDocument() fireEvent.click(screen.getByText('src/a.ts')) expect(onFileClick).toHaveBeenCalledWith('src/a.ts') fireEvent.click(screen.getByText('src/b.ts')) expect(onFileClick).toHaveBeenCalledWith('src/b.ts') + + fireEvent.click(screen.getByRole('button', { name: /Patch src\/a\.ts/ })) + + expect(screen.getByRole('button', { name: 'Show diff for src/a.ts' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Show diff for src/b.ts' })).toBeNull() + }) + + it('syntax highlights added rows with the themed diff tints', () => { + const { container } = renderWithProviders( + , + ) + + const addedRow = container.querySelector('[class*="bg-diff-add/15"]') + expect(addedRow).not.toBeNull() + expect(addedRow?.querySelector('.hljs-keyword')?.textContent).toBe('const') }) it('falls back to the generic tool output when a patch has no diff metadata', () => { diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 5a0d60dbb..944950756 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -4,6 +4,8 @@ import { TextPart } from './TextPart' import { ToolCallPart } from './ToolCallPart' import { readShowArtifactCall } from '@/lib/artifactTool' import { useSettings } from '@/hooks/useSettings' +import { cn } from '@/lib/utils' +import { TRANSCRIPT_DETAIL_CLASS } from './transcriptStyles' import type { ShellNoticeOutcome } from '@/lib/backgroundWork' import type { WalkthroughSource } from '@opencode-manager/shared/schemas' @@ -33,11 +35,11 @@ export const MessagePart = memo(function MessagePart({ part, messageID, director if (simpleChatMode || !showReasoning) return null if (!part.text.trim()) return null return ( -
- +
+ Reasoning -
+
{part.text}
diff --git a/frontend/src/components/message/MessageSkeleton.tsx b/frontend/src/components/message/MessageSkeleton.tsx index 79b54fd04..d17f52c75 100644 --- a/frontend/src/components/message/MessageSkeleton.tsx +++ b/frontend/src/components/message/MessageSkeleton.tsx @@ -1,103 +1,76 @@ +import { cn } from '@/lib/utils' +function Bar({ className }: { className: string }) { + return
+} -export function MessageSkeleton() { +function UserTurnSkeleton({ widths }: { widths: string[] }) { return ( -
- {/* User message skeleton */} -
-
-
-
-
-
-
-
-
-
+
+
+
+ {widths.map((width, index) => ( + + ))}
+
+
+ ) +} - {/* Assistant message skeleton */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+function TurnFooterSkeleton() { + return ( +
+ + + +
+ ) +} - {/* Assistant message skeleton */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+function AssistantTurnSkeleton() { + return ( +
+
+ + +
+ +
-
- - {/* Second user message skeleton */} -
-
-
-
-
-
-
-
-
+
+ + +
+
+ +
+ ) +} - {/* Loading assistant message */} -
-
-
-
-
-
- ● -
-
-
-
- ▋ -
-
+/** Placeholder transcript shown while a session loads, mirroring the CLI-style turn layout. */ +export function MessageSkeleton() { + return ( + - ); + ) } - diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 4347cf24e..47e95d030 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -24,12 +24,18 @@ const mocks = vi.hoisted(() => ({ useSettings: vi.fn(), useRefreshMessage: vi.fn(), useSessionAgent: vi.fn(), + useMobile: vi.fn(), })) vi.mock('@/hooks/useSettings', () => ({ useSettings: mocks.useSettings, })) +vi.mock('@/hooks/useMobile', async (importOriginal) => ({ + ...(await importOriginal()), + useMobile: mocks.useMobile, +})) + vi.mock('@/hooks/useRemoveMessage', () => ({ useRefreshMessage: () => mocks.useRefreshMessage(), })) @@ -147,6 +153,7 @@ describe('MessageThread', () => { mutate: vi.fn(), }) mocks.useSessionAgent.mockReturnValue({ agent: 'test-agent' }) + mocks.useMobile.mockReturnValue(false) useUIState.getState().setIsEditingMessage(false) }) @@ -474,7 +481,28 @@ describe('MessageThread', () => { ) expect(screen.getByText('Hello')).toBeInTheDocument() - expect(screen.getByText('You')).toBeInTheDocument() + expect(screen.queryByText('You')).not.toBeInTheDocument() + }) + + it('renders the assistant footer with agent, model, and duration', () => { + setupSettings({ simpleChatMode: false, showReasoning: false }) + + render( + , + ) + + expect(screen.getByText('Build')).toBeInTheDocument() + expect(screen.getByText('test-model')).toBeInTheDocument() + expect(screen.getByText('1m 33s')).toBeInTheDocument() }) it('exposes the message id on a rendered user message', () => { @@ -530,6 +558,36 @@ describe('MessageThread', () => { expect(screen.getByText('12 tests passed')).toBeInTheDocument() }) + it('collapses a long shell output behind the preview and reveals it on click', () => { + setupSettings({ simpleChatMode: false, showReasoning: false }) + + const lines = Array.from({ length: 25 }, (_, index) => `line-${String(index + 1).padStart(2, '0')}`) + const output = lines.join('\n') + const messages: SessionMessageInfo[] = [ + { + id: 'shell-long', + type: 'shell', + shellID: 'sh_long', + command: 'seq 25', + status: 'exited', + exit: 0, + output: { output, cursor: output.length, size: output.length, truncated: false }, + time: { created: Date.now() }, + }, + ] + + render() + + expect(screen.getByText('(15 earlier lines)')).toBeInTheDocument() + expect(screen.getByText(/line-25/)).toBeInTheDocument() + expect(screen.queryByText(/line-01/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Show shell details' })) + + expect(screen.getByText(/line-01/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy output' })).toBeInTheDocument() + }) + it('renders a projected compaction banner from the fixtures', () => { setupSettings({ simpleChatMode: false, showReasoning: false }) diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index 021ff1e54..f25c66402 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -28,6 +28,9 @@ import { readShowArtifactCall } from '@/lib/artifactTool' import { groupExplorationParts, type AssistantContentItem } from '@/lib/explorationGroups' import type { WalkthroughSource } from '@opencode-manager/shared/schemas' import { ExplorationGroup } from './ExplorationGroup' +import { ShellPreview, ToolDetails, useToolDetailsState } from './ToolDetails' +import { cn, formatDuration } from '@/lib/utils' +import { getAgentStyleVars } from '@/lib/agent-colors' function getMessageText(message: SessionMessageInfo): string { switch (message.type) { @@ -169,7 +172,7 @@ function UserAttachments({ files: attachedFiles, agents: attachedAgents, skills: function MessageDivider({ label }: { label: string }) { return ( -
+
{label} @@ -224,7 +227,7 @@ function SessionNotice({ const { text, completion } = sessionNoticeContent(message) if (!completion) { - return
◈ {text}
+ return
◈ {text}
} const tone = completion.state === 'error' @@ -246,17 +249,18 @@ function SessionNotice({ type="button" onClick={() => onChildSessionClick(childID)} aria-live="polite" - className="my-1 block w-full px-3 py-1 text-left text-xs truncate hover:underline" + className="my-2 block w-full px-3 py-1 text-left text-xs truncate hover:underline" > {content} ) } - return
{content}
+ return
{content}
} function ShellMessage({ message }: { message: SessionMessageShell }) { + const [detailsOpen, setDetailsOpen] = useToolDetailsState(false) const output = message.output?.output ?? '' const statusLabel = message.status === 'running' ? 'running' @@ -265,26 +269,33 @@ function ShellMessage({ message }: { message: SessionMessageShell }) { : `${message.status} (exit ${message.exit})` return ( -
-
- $ - {message.command} - {statusLabel} -
+ <> + setDetailsOpen(!detailsOpen)} + status={{statusLabel}} + /> {output && ( -
-
{output}
- -
+ +
+
{output}
+ +
+
)} -
+ ) } function CompactionBanner({ message }: { message: SessionMessageCompaction }) { if (message.status === 'running') { return ( -
+
Compacting session...
@@ -293,38 +304,41 @@ function CompactionBanner({ message }: { message: SessionMessageCompaction }) { if (message.status === 'failed') { return ( -
- Compaction failed: {message.error.message} +
+ ✗ Compaction failed: {message.error.message}
) } return ( -
- Session compacted - {message.summary &&

{message.summary}

} -
+ <> + + {message.summary && ( +

+ {message.summary} +

+ )} + ) } function QueuedPromptRow({ item }: { item: SessionInboxUser }) { return ( -
-
- You - - {new Date(item.time.created).toLocaleTimeString()} - - - {item.delivery === 'queue' ? 'QUEUED' : 'STEERING'} - +
+
+ {}} isEditable={false} /> + +
+ {new Date(item.time.created).toLocaleTimeString()} + + {item.delivery === 'queue' ? 'QUEUED' : 'STEERING'} + +
- {}} isEditable={false} /> -
) } @@ -386,70 +400,73 @@ const MessageRow = memo(function MessageRow({ if (!hasRenderableContent(message, simpleChatMode, showReasoning)) return null return ( -
+
-
-
- You - - {new Date(message.time.created).toLocaleTimeString()} - - {canEditUserMessage && nextAssistantMessageId && ( - - )} -
- - {canUndoUserMessage && ( - + {isEditingThisMessage && nextAssistantMessageId ? ( + + ) : simpleChatMode ? ( + {}} + isEditable={false} + /> + ) : ( + handleStartEditUserMessage(message.id, nextAssistantMessageId ?? '')} + isEditable={false} /> )}
- {isEditingThisMessage && nextAssistantMessageId ? ( - - ) : simpleChatMode ? ( - {}} - isEditable={false} - /> - ) : ( - handleStartEditUserMessage(message.id, nextAssistantMessageId ?? '')} - isEditable={false} - /> - )} - + +
+ {new Date(message.time.created).toLocaleTimeString()} + {((canEditUserMessage && nextAssistantMessageId) || canUndoUserMessage) ? ( + + {canEditUserMessage && nextAssistantMessageId && ( + + )} + + {canUndoUserMessage && ( + + )} + + ) : null} +
) @@ -496,66 +513,77 @@ const MessageRow = memo(function MessageRow({ : 0 return ( -
-
-
-
- - {message.model.id} - - - {new Date(message.time.created).toLocaleTimeString()} - +
+
+ {contentItems.map((item, itemIndex) => ( +
+ {item.type === 'exploration' ? ( + + ) : ( + + )}
-
- -
- {contentItems.map((item, itemIndex) => ( -
- {item.type === 'exploration' ? ( - - ) : ( - - )} -
- ))} - {!simpleChatMode && message.snapshot?.files && ( - - )} - {message.retry && } - {message.error && } -
+ ))} + {!simpleChatMode && message.snapshot?.files && ( + + )} + {message.retry && } + {message.error && } +
-
- {!(isFree) && ${(message.cost ?? 0).toFixed(4)} • {totalTokens} tokens} +
+ + {message.agent.charAt(0).toUpperCase() + message.agent.slice(1)} + + + {message.model.id} + {message.time.completed !== undefined ? ( + <> + + {formatDuration(message.time.completed - message.time.created)} + + ) : streaming ? ( + <> + + streaming + + ) : null} + {!(isFree) && ( + <> + + ${(message.cost ?? 0).toFixed(4)} • {totalTokens} tokens + + )} + {messageTextContent && } -
+
) @@ -583,8 +611,8 @@ const MessageRow = memo(function MessageRow({ if (message.type === 'skill') { return ( -
- Skill loaded: {message.name} +
+ ◈ Skill loaded: {message.name}
) } diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index 6185eed5e..63474b893 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' +import { useSessionStatus } from '@/stores/sessionStatusStore' import { createCommandActionsMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ @@ -146,6 +147,22 @@ describe('PromptInput composer layout', () => { mocks.useHasBackgroundActivity.mockReturnValue(false) }) + it('anchors the activity bar in the gap above the controls row', () => { + useSessionStatus.getState().setStatus('test-session', { type: 'busy' }) + + renderComponent() + + const bar = screen.getByRole('progressbar', { name: 'Session working' }) + expect(controlsRow()).toContainElement(bar) + expect(controlsRow().firstElementChild).toBe(bar) + expect(bar).toHaveClass('absolute', 'bottom-full') + expect(promptInput().compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + act(() => { + useSessionStatus.getState().clearStatus('test-session') + }) + }) + describe('primary action placement', () => { it('omits the submit button while the prompt is empty', () => { renderComponent() diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 9359eca09..2af3065b5 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1592,7 +1592,6 @@ return (
{sessionActivityAnnouncement}
- {composerModeStyle && ( )} -
+
+
{mobileVoiceRecorderState ? ( <> diff --git a/frontend/src/components/message/RetryPart.tsx b/frontend/src/components/message/RetryPart.tsx index d64518d10..a24888741 100644 --- a/frontend/src/components/message/RetryPart.tsx +++ b/frontend/src/components/message/RetryPart.tsx @@ -1,6 +1,6 @@ import { memo, useEffect, useState } from 'react' import type { SessionMessageAssistantRetry } from '@opencode-manager/shared/opencode' -import { RefreshCw, AlertTriangle } from 'lucide-react' +import { RefreshCw } from 'lucide-react' interface RetryPartProps { retry: SessionMessageAssistantRetry @@ -31,32 +31,19 @@ export const RetryPart = memo(function RetryPart({ retry }: RetryPartProps) { }, [nextTimestamp]) return ( -
-
-
- - -
-
-
-
- - Retry attempt {retry.attempt} - - {countdown > 0 ? ( - - (retrying in {countdown}s) - - ) : ( - - (retrying...) - - )} -
-

- {retry.error.message} -

-
+
+ + Retry attempt {retry.attempt} + {countdown > 0 ? ( + (retrying in {countdown}s) + ) : ( + (retrying...) + )} + + {retry.error.message}
) }) diff --git a/frontend/src/components/message/StepFileChanges.tsx b/frontend/src/components/message/StepFileChanges.tsx index a68f6f8b3..f673edf7e 100644 --- a/frontend/src/components/message/StepFileChanges.tsx +++ b/frontend/src/components/message/StepFileChanges.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { ChevronDown, ChevronUp } from 'lucide-react' +import { cn } from '@/lib/utils' import { getRelativePath } from './FileToolRender' +import { TRANSCRIPT_DETAIL_CLASS, TRANSCRIPT_ROW_CLASS } from './transcriptStyles' interface StepFileChangesProps { files: string[] @@ -24,22 +26,23 @@ export function StepFileChanges({ files, snapshot, onFileClick }: StepFileChange const hiddenCount = files.length - INITIAL_FILES_SHOWN return ( -
+
-
+
{displayedFiles.map((file) => ( {openWalkthrough ? (
- - - {expanded && ( -
- {part.state.status === 'streaming' && ( -
-
- - - -
- Preparing tool call... -
- )} - - {part.state.status === 'running' && ( - part.name === 'shell' ? ( -
-
-
Command:
- -
-
- $ {displayCommand ?? ''} -
-
- - {isWaitingPermission ? 'Waiting for permission...' : 'Running...'} -
-
- ) : ( -
-
Input:
- -
- - {isWaitingPermission ? 'Waiting for permission...' : 'Running...'} -
-
- ) - )} - - {part.state.status === 'completed' && ( - <> - {part.name === 'shell' ? ( -
-
-
Command:
- -
-
- $ {displayCommand ?? ''} -
-
- ) : ( -
-
Input:
- -
- )} - {output && ( -
-
Output:
-
- - -
-
- )} - {part.time.ran !== undefined && part.time.completed !== undefined && ( -
- Duration: {((part.time.completed - part.time.ran) / 1000).toFixed(2)}s -
- )} - - )} + - {part.state.status === 'error' && ( -
-
Error:
- -
- )} -
- )} + + {detailContent} +
) }) diff --git a/frontend/src/components/message/ToolDetails.test.tsx b/frontend/src/components/message/ToolDetails.test.tsx new file mode 100644 index 000000000..6dea884c7 --- /dev/null +++ b/frontend/src/components/message/ToolDetails.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ShellPreview, ToolDetails, useToolDetailsState } from './ToolDetails' + +const mocks = vi.hoisted(() => ({ + useMobile: vi.fn(), +})) + +vi.mock('@/hooks/useMobile', async (importOriginal) => ({ + ...(await importOriginal()), + useMobile: mocks.useMobile, +})) + +const Details = ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ( + +

detail body

+
+) + +function StateProbe({ defaultOpen }: { defaultOpen: boolean }) { + const [open] = useToolDetailsState(defaultOpen) + return {open ? 'open' : 'closed'} +} + +describe('ToolDetails', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the detail inline on desktop when open', () => { + mocks.useMobile.mockReturnValue(false) + + render(
) + + expect(screen.getByText('detail body')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('renders nothing inline on desktop when closed', () => { + mocks.useMobile.mockReturnValue(false) + + render(
) + + expect(screen.queryByText('detail body')).not.toBeInTheDocument() + }) + + it('renders the detail inside a dialog on mobile when open', () => { + mocks.useMobile.mockReturnValue(true) + + render(
) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('detail body')).toBeInTheDocument() + expect(screen.getByText('$ git status')).toBeInTheDocument() + }) + + it('renders nothing on mobile when closed', () => { + mocks.useMobile.mockReturnValue(true) + + render(
) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('detail body')).not.toBeInTheDocument() + }) +}) + +describe('useToolDetailsState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('starts closed on mobile even when the default is open', () => { + mocks.useMobile.mockReturnValue(true) + + render() + + expect(screen.getByText('closed')).toBeInTheDocument() + }) + + it('starts open on desktop when the default is open', () => { + mocks.useMobile.mockReturnValue(false) + + render() + + expect(screen.getByText('open')).toBeInTheDocument() + }) + + it('starts closed on desktop when the default is closed', () => { + mocks.useMobile.mockReturnValue(false) + + render() + + expect(screen.getByText('closed')).toBeInTheDocument() + }) +}) + +describe('ShellPreview', () => { + const output = Array.from({ length: 25 }, (_, index) => `line ${index + 1}`).join('\n') + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the last 10 output lines on desktop', () => { + mocks.useMobile.mockReturnValue(false) + + render() + + expect(screen.getByText('(15 earlier lines)')).toBeInTheDocument() + }) + + it('shows only the last 4 output lines on mobile', () => { + mocks.useMobile.mockReturnValue(true) + + render() + + expect(screen.getByText('(21 earlier lines)')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/ToolDetails.tsx b/frontend/src/components/message/ToolDetails.tsx new file mode 100644 index 000000000..870a2a987 --- /dev/null +++ b/frontend/src/components/message/ToolDetails.tsx @@ -0,0 +1,86 @@ +import { useState, type ReactNode } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useMobile } from '@/hooks/useMobile' +import { tailLines } from '@/lib/clampDisplayText' +import { cn } from '@/lib/utils' +import { TRANSCRIPT_DETAIL_CLASS } from './transcriptStyles' + +const SHELL_PREVIEW_LINES = 10 +const MOBILE_SHELL_PREVIEW_LINES = 4 + +/** Tool-detail disclosure state that stays closed on mobile unless the user opens it. */ +export function useToolDetailsState(defaultOpen: boolean): [boolean, (open: boolean) => void] { + const isMobile = useMobile() + const [open, setOpen] = useState(() => defaultOpen && !isMobile) + return [open, setOpen] +} + +/** Renders tool detail content inline on desktop and in a fullscreen dialog on mobile. */ +export function ToolDetails({ + open, + onOpenChange, + title, + children, +}: { + open: boolean + onOpenChange: (open: boolean) => void + title: string + children: ReactNode +}) { + const isMobile = useMobile() + + if (isMobile) { + return ( + + + + {title} + +
{children}
+
+
+ ) + } + + return open ?
{children}
: null +} + +/** Compact shell call summary that reveals the full detail when clicked. */ +export function ShellPreview({ + command, + output, + onClick, + status, +}: { + command: string + output: string + onClick: () => void + status?: ReactNode +}) { + const isMobile = useMobile() + const tail = tailLines(output, isMobile ? MOBILE_SHELL_PREVIEW_LINES : SHELL_PREVIEW_LINES) + + return ( + + ) +} diff --git a/frontend/src/components/message/transcriptStyles.ts b/frontend/src/components/message/transcriptStyles.ts new file mode 100644 index 000000000..d52e6013f --- /dev/null +++ b/frontend/src/components/message/transcriptStyles.ts @@ -0,0 +1,5 @@ +/** Row-level layout for a transcript entry header and its summary line. */ +export const TRANSCRIPT_ROW_CLASS = 'flex w-full min-w-0 items-center gap-2 min-h-7 py-0.5 text-left font-mono text-[12.5px] text-muted-foreground' + +/** Indented, left-ruled block that holds detail content beneath a transcript row. */ +export const TRANSCRIPT_DETAIL_CLASS = 'ml-1.5 mt-1 border-l border-border pl-3' diff --git a/frontend/src/components/schedules/schedule-utils.ts b/frontend/src/components/schedules/schedule-utils.ts index f2f068921..a8ca45e26 100644 --- a/frontend/src/components/schedules/schedule-utils.ts +++ b/frontend/src/components/schedules/schedule-utils.ts @@ -2,6 +2,7 @@ import cronstrue from 'cronstrue' import { formatDistanceToNow } from 'date-fns' import type { CreateScheduleJobRequest, ScheduleJob, ScheduleRun, ScheduleWorkspaceMode, UpdateScheduleJobRequest } from '@opencode-manager/shared/types' import type { RadioOption } from '@/components/ui/radio-option-group' +import { formatDuration } from '@/lib/utils' export const intervalOptions = [ { label: '15m', value: 15 }, @@ -350,14 +351,7 @@ export function formatTimestamp(value: number | null): string { } export function formatRunDuration(run: Pick, now: number = Date.now()): string { - const totalSeconds = Math.max(0, Math.round(((run.finishedAt ?? now) - run.startedAt) / 1000)) - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - - if (hours > 0) return `${hours}h ${minutes}m` - if (minutes > 0) return `${minutes}m ${seconds}s` - return `${seconds}s` + return formatDuration((run.finishedAt ?? now) - run.startedAt) } export function getJobStatusTone(job: ScheduleJob): string { diff --git a/frontend/src/lib/clampDisplayText.test.ts b/frontend/src/lib/clampDisplayText.test.ts index 3a432899e..56f00d312 100644 --- a/frontend/src/lib/clampDisplayText.test.ts +++ b/frontend/src/lib/clampDisplayText.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { clampDisplayText } from './clampDisplayText' +import { clampDisplayText, tailLines } from './clampDisplayText' const line = (index: number) => `line ${index} ${'x'.repeat(20)}` @@ -22,3 +22,22 @@ describe('clampDisplayText', () => { expect(clamped.endsWith(lines[lines.length - 1])).toBe(true) }) }) + +describe('tailLines', () => { + it('keeps short text whole', () => { + expect(tailLines('one\ntwo\nthree', 10)).toEqual({ hiddenCount: 0, text: 'one\ntwo\nthree' }) + }) + + it('returns the last lines and counts the hidden ones', () => { + const lines = Array.from({ length: 25 }, (_, index) => `line ${index}`) + + const tail = tailLines(lines.join('\n'), 10) + + expect(tail.hiddenCount).toBe(15) + expect(tail.text).toBe(lines.slice(15).join('\n')) + }) + + it('ignores a single trailing newline', () => { + expect(tailLines('one\ntwo\nthree\n', 2)).toEqual({ hiddenCount: 1, text: 'two\nthree' }) + }) +}) diff --git a/frontend/src/lib/clampDisplayText.ts b/frontend/src/lib/clampDisplayText.ts index d846c6e6c..518ef1efe 100644 --- a/frontend/src/lib/clampDisplayText.ts +++ b/frontend/src/lib/clampDisplayText.ts @@ -8,6 +8,18 @@ function formatOmittedSize(size: number): string { : `${(size / (1024 * 1024)).toFixed(1)} MB` } +/** Returns the last `maxLines` lines of `text` and how many leading lines were dropped. */ +export function tailLines(text: string, maxLines: number): { hiddenCount: number; text: string } { + const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text + if (trimmed === '') return { hiddenCount: 0, text: '' } + const lines = trimmed.split('\n') + if (lines.length <= maxLines) return { hiddenCount: 0, text: trimmed } + return { + hiddenCount: lines.length - maxLines, + text: lines.slice(lines.length - maxLines).join('\n'), + } +} + export function clampDisplayText(text: string): string { if (text.length <= DISPLAY_LIMIT) return text const headCut = text.lastIndexOf('\n', DISPLAY_HEAD_LENGTH) diff --git a/frontend/src/lib/diffLines.test.ts b/frontend/src/lib/diffLines.test.ts new file mode 100644 index 000000000..d5476127e --- /dev/null +++ b/frontend/src/lib/diffLines.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { parseUnifiedDiff } from './diffLines' + +describe('parseUnifiedDiff', () => { + it('numbers context, added, and removed lines from the hunk header', () => { + const rows = parseUnifiedDiff( + ['@@ -10,4 +20,5 @@', ' unchanged', '-removed', '+added', ' trailing'].join('\n'), + ) + + expect(rows).toEqual([ + { kind: 'context', oldLine: 10, newLine: 20, text: 'unchanged' }, + { kind: 'remove', oldLine: 11, text: 'removed' }, + { kind: 'add', newLine: 21, text: 'added' }, + { kind: 'context', oldLine: 12, newLine: 22, text: 'trailing' }, + ]) + }) + + it('emits a gap row between hunks and continues numbering from each header', () => { + const rows = parseUnifiedDiff( + ['@@ -1,2 +1,2 @@', ' first', '+second', '@@ -40,1 +40,1 @@', '-old', '+new'].join('\n'), + ) + + expect(rows.map((row) => row.kind)).toEqual(['context', 'add', 'gap', 'remove', 'add']) + expect(rows[1]).toMatchObject({ newLine: 2 }) + expect(rows[3]).toMatchObject({ oldLine: 40 }) + expect(rows[4]).toMatchObject({ newLine: 40 }) + }) + + it('skips diff headers, index lines, and no-newline markers', () => { + const rows = parseUnifiedDiff( + [ + 'diff --git a/file.ts b/file.ts', + 'index 1234567..89abcde 100644', + '--- a/file.ts', + '+++ b/file.ts', + '@@ -1 +1 @@', + '-before', + '+after', + '\\ No newline at end of file', + ].join('\n'), + ) + + expect(rows).toEqual([ + { kind: 'remove', oldLine: 1, text: 'before' }, + { kind: 'add', newLine: 1, text: 'after' }, + ]) + }) +}) diff --git a/frontend/src/lib/diffLines.ts b/frontend/src/lib/diffLines.ts new file mode 100644 index 000000000..c240ca776 --- /dev/null +++ b/frontend/src/lib/diffLines.ts @@ -0,0 +1,58 @@ +export type DiffRowKind = 'add' | 'remove' | 'context' | 'gap' + +export interface DiffRow { + kind: DiffRowKind + oldLine?: number + newLine?: number + text: string +} + +const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/ + +/** Parses a unified diff patch into numbered rows, emitting a gap row between hunks. */ +export function parseUnifiedDiff(patch: string): DiffRow[] { + const rows: DiffRow[] = [] + let oldLine = 0 + let newLine = 0 + let inHunk = false + + for (const line of patch.split('\n')) { + if (line.startsWith('diff --git')) { + inHunk = false + continue + } + + if (line.startsWith('index ') || line.startsWith('\\ No newline')) continue + + const hunk = HUNK_HEADER.exec(line) + if (hunk) { + oldLine = Number(hunk[1]) + newLine = Number(hunk[2]) + if (inHunk && rows.length > 0) rows.push({ kind: 'gap', text: '' }) + inHunk = true + continue + } + + if (!inHunk) continue + + if (line.startsWith('+')) { + rows.push({ kind: 'add', newLine, text: line.slice(1) }) + newLine += 1 + continue + } + + if (line.startsWith('-')) { + rows.push({ kind: 'remove', oldLine, text: line.slice(1) }) + oldLine += 1 + continue + } + + if (line.startsWith(' ')) { + rows.push({ kind: 'context', oldLine, newLine, text: line.slice(1) }) + oldLine += 1 + newLine += 1 + } + } + + return rows +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 94d6259ae..50a5b4ba8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -53,6 +53,18 @@ export function randomId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}` } +/** Formats a millisecond duration as `Xh Ym`, `Xm Ys`, or `Xs`, clamped to zero. */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + if (hours > 0) return `${hours}h ${minutes}m` + if (minutes > 0) return `${minutes}m ${seconds}s` + return `${seconds}s` +} + export function formatShortRelativeTime(date: Date, now: Date = new Date()): string { const diffMs = now.getTime() - date.getTime() const diffSeconds = Math.floor(diffMs / 1000) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a769f7a7..4935dabd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: jsqr: specifier: ^1.4.0 version: 1.4.0 + lowlight: + specifier: ^3.3.0 + version: 3.3.0 lucide-react: specifier: ^0.546.0 version: 0.546.0(react@19.2.3) From d8495df811d60f2aeb9f0c1499cd1e5d6e96dde4 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:26:33 -0400 Subject: [PATCH 2/7] fix(frontend): keep transcript display settings working with the plain log layout --- .../src/components/message/FileToolRender.tsx | 2 +- .../components/message/MessagePart.test.tsx | 45 ++++++++++++++ .../components/message/MessageThread.test.tsx | 20 ++++++ .../src/components/message/MessageThread.tsx | 12 ++-- .../components/message/ToolCallPart.test.tsx | 62 ++++++++++++++++++- .../src/components/message/ToolCallPart.tsx | 13 ++-- .../components/message/ToolDetails.test.tsx | 54 +++++++++++++++- .../src/components/message/ToolDetails.tsx | 6 +- 8 files changed, 198 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/message/FileToolRender.tsx b/frontend/src/components/message/FileToolRender.tsx index d2d1af66b..6b76d518b 100644 --- a/frontend/src/components/message/FileToolRender.tsx +++ b/frontend/src/components/message/FileToolRender.tsx @@ -69,7 +69,7 @@ function FileToolRender({ part, filediff, filePath, content, toolName, onFileCli const hasExpandableContent = !isReadTool && (filediff || content) const isFileLink = Boolean(onFileClick) && !isRemoteServer - const defaultExpanded = hasDiff + const defaultExpanded = hasDiff || toolName === 'Write' ? (preferences?.expandDiffs ?? true) : (preferences?.expandToolCalls ?? false) const [detailsOpen, setDetailsOpen] = useToolDetailsState(defaultExpanded) diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx index 69a502542..1b4d79db5 100644 --- a/frontend/src/components/message/MessagePart.test.tsx +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -648,6 +648,51 @@ describe('MessagePart', () => { expect(screen.getByText('/test/file.txt')).toBeInTheDocument() }) + it('expands a write tool preview when expand diffs is enabled', () => { + setupSettings({ + simpleChatMode: false, + showReasoning: false, + expandToolCalls: false, + expandDiffs: true, + }) + + renderWithProviders( + , + ) + + expect(screen.getByText('written preview line')).toBeInTheDocument() + }) + + it('collapses a write tool preview when only expand tool calls is enabled', () => { + setupSettings({ + simpleChatMode: false, + showReasoning: false, + expandToolCalls: true, + expandDiffs: false, + }) + + renderWithProviders( + , + ) + + expect(screen.getByText('/test/file.txt')).toBeInTheDocument() + expect(screen.queryByText('written preview line')).toBeNull() + }) + it('makes legacy filePath read, write, and filediff edit calls clickable', () => { const onFileClick = vi.fn() const legacy = (name: string, metadata: Record) => diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 47e95d030..677ef6d46 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -684,6 +684,26 @@ describe('MessageThread', () => { expect(screen.queryByText(/File Changes/)).not.toBeInTheDocument() }) + it('omits the agent name from the assistant footer in simple chat mode', () => { + const messages = [assistantMessage('2', [textPart('Updated files')], { agent: 'build' })] + + setupSettings({ simpleChatMode: true, showReasoning: false }) + const simple = render( + , + ) + + expect(screen.queryByText('Build')).not.toBeInTheDocument() + expect(screen.getByText('test-model')).toBeInTheDocument() + + simple.unmount() + + setupSettings({ simpleChatMode: false, showReasoning: false }) + render() + + expect(screen.getByText('Build')).toBeInTheDocument() + expect(screen.getByText('test-model')).toBeInTheDocument() + }) + it('shows the empty state when there are no messages or pending prompts', () => { setupSettings({ simpleChatMode: false, showReasoning: false }) diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index f25c66402..0eedd6589 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -558,10 +558,14 @@ const MessageRow = memo(function MessageRow({
- - {message.agent.charAt(0).toUpperCase() + message.agent.slice(1)} - - + {!simpleChatMode && ( + <> + + {message.agent.charAt(0).toUpperCase() + message.agent.slice(1)} + + + + )} {message.model.id} {message.time.completed !== undefined ? ( <> diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index c35f60ca4..b0f6b6712 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' @@ -701,6 +701,66 @@ describe('ToolCallPart shell preview', () => { }) }) +describe('ToolCallPart shell details scrolling', () => { + const originalScrollIntoView = Element.prototype.scrollIntoView + const scrollIntoView = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + mocks.useToolCallPermission.mockReturnValue(null) + Element.prototype.scrollIntoView = scrollIntoView + }) + + afterEach(() => { + Element.prototype.scrollIntoView = originalScrollIntoView + }) + + const completedShell = (): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_scroll', + name: 'shell', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { command: 'seq 25' }, + content: [{ type: 'text', text: 'line 24\nline 25' }], + metadata: {}, + }, + }) + + it('does not scroll when the shell details open from the preference', () => { + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: true }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + + renderWithProviders() + + expect(screen.getByText('Command:')).toBeInTheDocument() + expect(scrollIntoView).not.toHaveBeenCalled() + }) + + it('scrolls once when the user opens the shell details', () => { + renderWithProviders() + + expect(screen.queryByText('Command:')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Show shell details' })) + + expect(screen.getByText('Command:')).toBeInTheDocument() + expect(scrollIntoView).toHaveBeenCalledTimes(1) + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' }) + }) +}) + describe('ToolCallPart show artifact action', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 2e31a85de..20d0bbec7 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -102,6 +102,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct const pendingPermission = useToolCallPermission(part.id, messageID) const isWaitingPermission = part.state.status === 'running' && pendingPermission !== null const outputRef = useRef(null) + const userOpenedDetailsRef = useRef(false) const input = toolInput(part) const openWalkthrough = readOpenWalkthroughCall(part) const rawCommand = part.name === 'shell' && typeof input?.command === 'string' @@ -123,10 +124,10 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct const [detailsOpen, setDetailsOpen] = useToolDetailsState(preferences?.expandToolCalls ?? false) useEffect(() => { + if (part.name !== 'shell' || !detailsOpen || !userOpenedDetailsRef.current) return + userOpenedDetailsRef.current = false if (isMobile) return - if (part.name === 'shell' && detailsOpen && outputRef.current) { - outputRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - } + outputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }, [detailsOpen, isMobile, part.name]) const getStatusColor = () => { @@ -380,7 +381,11 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct setDetailsOpen(!detailsOpen)} + onClick={() => { + const nextOpen = !detailsOpen + if (nextOpen) userOpenedDetailsRef.current = true + setDetailsOpen(nextOpen) + }} status={ {getStatusIcon()} diff --git a/frontend/src/components/message/ToolDetails.test.tsx b/frontend/src/components/message/ToolDetails.test.tsx index 6dea884c7..efe5d1613 100644 --- a/frontend/src/components/message/ToolDetails.test.tsx +++ b/frontend/src/components/message/ToolDetails.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { ShellPreview, ToolDetails, useToolDetailsState } from './ToolDetails' const mocks = vi.hoisted(() => ({ @@ -18,8 +18,15 @@ const Details = ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b ) function StateProbe({ defaultOpen }: { defaultOpen: boolean }) { - const [open] = useToolDetailsState(defaultOpen) - return {open ? 'open' : 'closed'} + const [open, setOpen] = useToolDetailsState(defaultOpen) + return ( + <> + {open ? 'open' : 'closed'} + + + ) } describe('ToolDetails', () => { @@ -92,6 +99,47 @@ describe('useToolDetailsState', () => { expect(screen.getByText('closed')).toBeInTheDocument() }) + + it('follows a default that turns open after mount on desktop', () => { + mocks.useMobile.mockReturnValue(false) + + const { rerender } = render() + + expect(screen.getByText('closed')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('open')).toBeInTheDocument() + }) + + it('keeps the user choice when a later default differs', () => { + mocks.useMobile.mockReturnValue(false) + + const { rerender } = render() + + expect(screen.getByText('open')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'close details' })) + + expect(screen.getByText('closed')).toBeInTheDocument() + + rerender() + + expect(screen.getByText('closed')).toBeInTheDocument() + }) + + it('closes when the viewport shrinks to mobile after opening on desktop', () => { + mocks.useMobile.mockReturnValue(false) + + const { rerender } = render() + + expect(screen.getByText('open')).toBeInTheDocument() + + mocks.useMobile.mockReturnValue(true) + rerender() + + expect(screen.getByText('closed')).toBeInTheDocument() + }) }) describe('ShellPreview', () => { diff --git a/frontend/src/components/message/ToolDetails.tsx b/frontend/src/components/message/ToolDetails.tsx index 870a2a987..09fdc49e8 100644 --- a/frontend/src/components/message/ToolDetails.tsx +++ b/frontend/src/components/message/ToolDetails.tsx @@ -8,11 +8,11 @@ import { TRANSCRIPT_DETAIL_CLASS } from './transcriptStyles' const SHELL_PREVIEW_LINES = 10 const MOBILE_SHELL_PREVIEW_LINES = 4 -/** Tool-detail disclosure state that stays closed on mobile unless the user opens it. */ +/** Tool-detail disclosure state that follows the preference until the user toggles it and never auto-opens on mobile. */ export function useToolDetailsState(defaultOpen: boolean): [boolean, (open: boolean) => void] { const isMobile = useMobile() - const [open, setOpen] = useState(() => defaultOpen && !isMobile) - return [open, setOpen] + const [userOpen, setUserOpen] = useState(null) + return [userOpen ?? (defaultOpen && !isMobile), setUserOpen] } /** Renders tool detail content inline on desktop and in a fullscreen dialog on mobile. */ From c4bb104cc0c4ef5fbe50f34257cf649725bc364e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:14:12 -0400 Subject: [PATCH 3/7] fix(frontend): reveal transcript actions on focus and bound the shell preview - expose the user message action buttons to keyboard focus, not just hover - compute the shell preview tail once per output change and clamp very long lines --- frontend/src/components/message/ToolDetails.tsx | 10 +++++++--- .../components/message/UserMessageActionButtons.tsx | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/message/ToolDetails.tsx b/frontend/src/components/message/ToolDetails.tsx index 09fdc49e8..60e596b58 100644 --- a/frontend/src/components/message/ToolDetails.tsx +++ b/frontend/src/components/message/ToolDetails.tsx @@ -1,7 +1,7 @@ -import { useState, type ReactNode } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useMobile } from '@/hooks/useMobile' -import { tailLines } from '@/lib/clampDisplayText' +import { clampDisplayText, tailLines } from '@/lib/clampDisplayText' import { cn } from '@/lib/utils' import { TRANSCRIPT_DETAIL_CLASS } from './transcriptStyles' @@ -58,7 +58,11 @@ export function ShellPreview({ status?: ReactNode }) { const isMobile = useMobile() - const tail = tailLines(output, isMobile ? MOBILE_SHELL_PREVIEW_LINES : SHELL_PREVIEW_LINES) + const lineLimit = isMobile ? MOBILE_SHELL_PREVIEW_LINES : SHELL_PREVIEW_LINES + const tail = useMemo(() => { + const result = tailLines(output, lineLimit) + return { ...result, text: clampDisplayText(result.text) } + }, [output, lineLimit]) return ( - - ); -} - export function Repos() { const navigate = useNavigate(); const [addRepoOpen, setAddRepoOpen] = useState(false); @@ -71,7 +51,7 @@ export function Repos() { ) : ( - + )} - ) : ( - )} +