Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -198,7 +198,7 @@ function AppShell() {
)

const canOpenMoreWithSwipe = () => {
return /^\/repos\/[^/]+\/sessions\/[^/]+$/.test(location.pathname) && !openSheet
return isSessionDetailRoute(location.pathname) && !openSheet
}

const { bind: bindMoreSwipe } = useRightEdgeSwipe(
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/message/CodePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/message/ComposerActivityBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ComposerActivityBar sessionID="session-a" />)

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', () => {
Expand All @@ -58,7 +58,7 @@ describe('ComposerActivityBar', () => {
render(<ComposerActivityBar sessionID="session-a" />)

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)
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/components/message/ComposerActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ const ACTIVITY_PRESENTATION = {
} as const satisfies Record<string, { label: string; tone: ScannerTone }>

/**
* 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)
Expand All @@ -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"
>
<ScannerBars tone={activity.tone} />
<ScannerBars tone={activity.tone} className="h-1.5 md:h-2" />
</div>
)
}
82 changes: 82 additions & 0 deletions frontend/src/components/message/DiffLines.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof lowlight.highlight>['children'][number]

const ROW_CLASS: Record<DiffRow['kind'], string> = {
add: 'bg-diff-add/15',
remove: 'bg-diff-delete/15',
context: '',
gap: '',
}

const MARKER_CLASS: Record<DiffRow['kind'], string> = {
add: 'text-diff-add',
remove: 'text-diff-delete',
context: 'text-muted-foreground',
gap: 'text-muted-foreground',
}

const MARKER_TEXT: Record<DiffRow['kind'], string> = {
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 (
<span key={key} className={className}>
{node.children.map((child, index) => renderHighlightedNode(child, index))}
</span>
)
}

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 (
<span className="block font-mono text-xs leading-5">
{rows.map((row, index) => (
<span
key={index}
className={cn('grid', ROW_CLASS[row.kind])}
style={{ gridTemplateColumns }}
>
<span className="select-none pr-2 text-right text-muted-foreground">
{row.kind === 'gap' ? '' : row.newLine ?? row.oldLine}
</span>
<span className={cn('select-none', MARKER_CLASS[row.kind])}>{MARKER_TEXT[row.kind]}</span>
<span className="whitespace-pre-wrap break-all">{highlighted[index]}</span>
</span>
))}
</span>
)
}
Loading