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/App.tsx b/frontend/src/App.tsx index fccd63160..e3f049fae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,7 +35,7 @@ import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNav import { PermissionRequestDialog } from './components/session/PermissionRequestDialog' import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' -import { getSwipeBackTarget } from '@/lib/navigation' +import { getSwipeBackTarget, isSessionDetailRoute } from '@/lib/navigation' import { onNotificationClick } from '@/lib/serviceWorker' import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' @@ -198,7 +198,7 @@ function AppShell() { ) const canOpenMoreWithSwipe = () => { - return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(location.pathname) && !openSheet + return isSessionDetailRoute(location.pathname) && !openSheet } const { bind: bindMoreSwipe } = useRightEdgeSwipe( 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..6b76d518b 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 || toolName === 'Write' ? (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..1b4d79db5 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', () => { @@ -582,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) => @@ -631,6 +742,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..677ef6d46 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 }) @@ -626,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 021ff1e54..0eedd6589 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,81 @@ 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} +
+ {!simpleChatMode && ( + <> + + {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 +615,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..93b818f1a 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..efe5d1613 --- /dev/null +++ b/frontend/src/components/message/ToolDetails.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent, 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, setOpen] = 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() + }) + + 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', () => { + 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..60e596b58 --- /dev/null +++ b/frontend/src/components/message/ToolDetails.tsx @@ -0,0 +1,90 @@ +import { useMemo, useState, type ReactNode } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useMobile } from '@/hooks/useMobile' +import { clampDisplayText, 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 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 [userOpen, setUserOpen] = useState(null) + return [userOpen ?? (defaultOpen && !isMobile), setUserOpen] +} + +/** 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 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 ( + + ) +} diff --git a/frontend/src/components/message/UserMessageActionButtons.tsx b/frontend/src/components/message/UserMessageActionButtons.tsx index 19d0c262f..0d9d034d5 100644 --- a/frontend/src/components/message/UserMessageActionButtons.tsx +++ b/frontend/src/components/message/UserMessageActionButtons.tsx @@ -34,7 +34,7 @@ export const UserMessageActionButtons = memo(function UserMessageActionButtons({ } return ( -
+
- - ); -} - export function Repos() { const navigate = useNavigate(); const [addRepoOpen, setAddRepoOpen] = useState(false); @@ -60,7 +40,7 @@ export function Repos() {
- {isLocalServer ? ( + {isLocalServer && ( - ) : ( - )} +