diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index de1060456..e17887055 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -29,6 +29,7 @@ - **CoS agent PRs no longer open with TUI startup noise as their description.** A PR that PortOS opened for a Codex/Antigravity/OpenCode agent led with the session's own lifecycle log — `📟 TUI session started: … (codex …)`, `💡 Open the Shell tab…`, `📟 Prompt pasted…` — before getting to what the agent actually did. The PR description now starts at the agent's completion summary and drops PortOS's status lines, along with the trailing `## Branch` / `## PR` section that only repeats what the PR page already shows. - **Review Hub success-rate alerts now require recent runs.** Low lifetime rates for task types that have been idle no longer appear as current health anomalies; alerts are shown only after enough runs occur in the rolling 30-day performance window. - **Malware scan reports now open inside PortOS.** Scan links from Brain, the Review Hub, and completed CoS agents render the markdown in a mobile-friendly PortOS page instead of navigating a Home Screen install to the raw `.md` API response. Brain Links also has a Scan Reports filter, so clean and caution results are just as discoverable as dangerous findings. +- **Malware scan report pages scroll again.** Opening a scan report from a Brain link card showed the top of the report and nothing else — the page couldn't be scrolled, so anything past the first screenful was unreachable. Long reports now scroll normally on desktop and mobile. - **Branch reconciler no longer hands long-lived shared branches to the coordinator agent.** `gh-pages` (the GitHub Pages publishing branch) is now protected alongside `main`/`master`/`dev`/`develop`/`release`, so the scheduled `branch-reconcile` task never tries to "open a PR" merging it into the default branch (which would break the published site). The reconciler now reuses the single canonical `PROTECTED_BRANCHES` set in `server/lib/gitArgs.js` instead of its own narrower list, so it also picks up `dev`/`develop` protection. ## Changed diff --git a/client/src/pages/BrainScanReport.jsx b/client/src/pages/BrainScanReport.jsx index dbea14167..3af693f45 100644 --- a/client/src/pages/BrainScanReport.jsx +++ b/client/src/pages/BrainScanReport.jsx @@ -12,6 +12,16 @@ const VERDICT_STYLES = { DANGEROUS: { Icon: Skull, className: 'bg-port-error/15 text-port-error border-port-error/30' } }; +// `/brain*` is a full-width Layout route (bare `overflow-hidden`
), so this +// page must own its own vertical scroll or long reports get clipped below the fold. +function Shell({ children }) { + return ( +
+
{children}
+
+ ); +} + export default function BrainScanReport() { const { id } = useParams(); const fetchReport = useCallback(async () => { @@ -24,15 +34,21 @@ export default function BrainScanReport() { const { data, loading, refetch } = useAutoRefetch(fetchReport, 30_000); if (loading) { - return
; + return ( + +
+
+ ); } if (!data) { return ( -
-

This scan report is unavailable.

- Back to links -
+ +
+

This scan report is unavailable.

+ Back to links +
+
); } @@ -41,7 +57,7 @@ export default function BrainScanReport() { const VerdictIcon = verdictStyle?.Icon; return ( -
+
@@ -73,6 +89,6 @@ export default function BrainScanReport() {
-
+ ); } diff --git a/client/src/pages/BrainScanReport.test.jsx b/client/src/pages/BrainScanReport.test.jsx new file mode 100644 index 000000000..3362dfbc3 --- /dev/null +++ b/client/src/pages/BrainScanReport.test.jsx @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +vi.mock('../services/api', () => ({ + getBrainLink: vi.fn(), + getBrainScanReport: vi.fn(), +})); + +vi.mock('../components/cos/MarkdownOutput', () => ({ + default: ({ content }) =>
{content}
, +})); + +import * as api from '../services/api'; +import BrainScanReport from './BrainScanReport'; + +const renderPage = () => render( + + + } /> + + +); + +// `/brain*` is in Layout's isFullWidth list, so
is `overflow-hidden` and +// this page must supply its own scroll container — otherwise a long report is +// clipped with no way to reach the rest of it. +const expectOwnScrollContainer = (container) => { + const scroller = container.querySelector('.overflow-y-auto'); + expect(scroller).not.toBeNull(); + expect(scroller.className).toContain('h-full'); +}; + +describe('BrainScanReport', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('scrolls its own content in the loaded state', async () => { + api.getBrainLink.mockResolvedValue({ title: 'Example Link', url: 'https://example.com', malwareScan: { verdict: 'CLEAN' } }); + api.getBrainScanReport.mockResolvedValue('# Report\n\nlong body'); + + const { container } = renderPage(); + await waitFor(() => expect(screen.getByText('Example Link')).toBeInTheDocument()); + + expectOwnScrollContainer(container); + expect(screen.getByTestId('markdown')).toHaveTextContent('long body'); + }); + + it('scrolls its own content in the loading state', () => { + api.getBrainLink.mockReturnValue(new Promise(() => {})); + api.getBrainScanReport.mockReturnValue(new Promise(() => {})); + + const { container } = renderPage(); + expectOwnScrollContainer(container); + }); + + it('scrolls its own content in the unavailable state', async () => { + api.getBrainLink.mockRejectedValue(new Error('nope')); + api.getBrainScanReport.mockRejectedValue(new Error('nope')); + + const { container } = renderPage(); + await waitFor(() => expect(screen.getByText('This scan report is unavailable.')).toBeInTheDocument()); + expectOwnScrollContainer(container); + }); +});