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
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions client/src/pages/BrainScanReport.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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` <main>), so this
// page must own its own vertical scroll or long reports get clipped below the fold.
function Shell({ children }) {
return (
<div className="h-full overflow-y-auto p-4 md:p-6">
<div className="mx-auto max-w-4xl space-y-4">{children}</div>
</div>
);
}

export default function BrainScanReport() {
const { id } = useParams();
const fetchReport = useCallback(async () => {
Expand All @@ -24,15 +34,21 @@ export default function BrainScanReport() {
const { data, loading, refetch } = useAutoRefetch(fetchReport, 30_000);

if (loading) {
return <div className="flex justify-center py-16"><BrailleSpinner text="Loading scan report" /></div>;
return (
<Shell>
<div className="flex justify-center py-16"><BrailleSpinner text="Loading scan report" /></div>
</Shell>
);
}

if (!data) {
return (
<div className="py-12 text-center">
<p className="text-sm text-gray-400">This scan report is unavailable.</p>
<Link to="/brain/links" className="mt-3 inline-block text-sm text-port-accent hover:underline">Back to links</Link>
</div>
<Shell>
<div className="py-12 text-center">
<p className="text-sm text-gray-400">This scan report is unavailable.</p>
<Link to="/brain/links" className="mt-3 inline-block text-sm text-port-accent hover:underline">Back to links</Link>
</div>
</Shell>
);
}

Expand All @@ -41,7 +57,7 @@ export default function BrainScanReport() {
const VerdictIcon = verdictStyle?.Icon;

return (
<div className="mx-auto max-w-4xl space-y-4">
<Shell>
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link to="/brain/links" className="mb-2 inline-flex items-center gap-1 text-xs text-gray-500 hover:text-white">
Expand Down Expand Up @@ -73,6 +89,6 @@ export default function BrainScanReport() {
<article className="rounded-lg border border-port-border bg-port-card p-4 sm:p-6">
<MarkdownOutput content={data.report} />
</article>
</div>
</Shell>
);
}
66 changes: 66 additions & 0 deletions client/src/pages/BrainScanReport.test.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => <div data-testid="markdown">{content}</div>,
}));

import * as api from '../services/api';
import BrainScanReport from './BrainScanReport';

const renderPage = () => render(
<MemoryRouter initialEntries={['/brain/links/abc/scan-report']}>
<Routes>
<Route path="/brain/links/:id/scan-report" element={<BrainScanReport />} />
</Routes>
</MemoryRouter>
);

// `/brain*` is in Layout's isFullWidth list, so <main> 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);
});
});