diff --git a/ui/packages/plugins/atw/src/IncidentWorkspacePage.tsx b/ui/packages/plugins/atw/src/IncidentWorkspacePage.tsx
index 19f691a4c7..7599258e20 100644
--- a/ui/packages/plugins/atw/src/IncidentWorkspacePage.tsx
+++ b/ui/packages/plugins/atw/src/IncidentWorkspacePage.tsx
@@ -30,6 +30,7 @@ import LockOpenOutlinedIcon from '@mui/icons-material/LockOpenOutlined';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '@sep/api';
+import { ReadOnlyNotice } from '@sep/framework';
import { CollectPane } from './CollectPane';
import { ResultsPane } from './ResultsPane';
import { useAtwIncident, useAtwIncidentLifecycle } from './hooks';
@@ -38,6 +39,12 @@ import { useAtwIncident, useAtwIncidentLifecycle } from './hooks';
* Incident workspace rendered at ``/atw/:incidentId``. Two side-by-side panes —
* Collect (browse, select, and batch-execute snippets) and Results (each
* execution's status, logs, and file listing) — stacked on narrow screens.
+ *
+ * A read-only session gets Results alone, full width. Collect exists only to
+ * start an execution, and every unsafe ATW route requires an administrator, so
+ * leaving the pane mounted with its execute form withheld offered a snippet
+ * picker that could never run anything. Withheld rather than disabled because a
+ * role, unlike a closed incident, is not something the viewer can undo.
*/
export function IncidentWorkspacePage() {
const { incidentId } = useParams<{ incidentId: string }>();
@@ -135,6 +142,16 @@ export function IncidentWorkspacePage() {
)}
+ {!canMutate && (
+
+
+
+ )}
+
-
-
-
+ {/* PMM divergence from upstream SEP — keep on the next sync. */}
+ {canMutate && (
+
+
+
+ )}
diff --git a/ui/packages/plugins/atw/src/ResultsPane.tsx b/ui/packages/plugins/atw/src/ResultsPane.tsx
index eb48c7620b..2c8d2df3c5 100644
--- a/ui/packages/plugins/atw/src/ResultsPane.tsx
+++ b/ui/packages/plugins/atw/src/ResultsPane.tsx
@@ -273,8 +273,9 @@ export function ResultsPane({ incidentId }: ResultsPaneProps) {
{!isLoading && !error && (!rows || rows.length === 0) && (
- No executions yet. Run snippets from the Collect pane to see results
- here.
+ {canMutate
+ ? 'No executions yet. Run snippets from the Collect pane to see results here.'
+ : 'No executions yet.'}
)}
diff --git a/ui/packages/plugins/atw/tests/IncidentWorkspacePage.test.tsx b/ui/packages/plugins/atw/tests/IncidentWorkspacePage.test.tsx
index 1d365ac519..9eefa87d46 100644
--- a/ui/packages/plugins/atw/tests/IncidentWorkspacePage.test.tsx
+++ b/ui/packages/plugins/atw/tests/IncidentWorkspacePage.test.tsx
@@ -210,4 +210,37 @@ describe('IncidentWorkspacePage — write access', () => {
screen.queryByRole('button', { name: /Reopen incident/i })
).not.toBeInTheDocument();
});
+
+ it('shows the Collect pane for a session that may mutate', async () => {
+ renderWorkspace();
+
+ await waitFor(() => expect(screen.getByText('Collect')).toBeTruthy());
+ expect(screen.getByText('Results')).toBeTruthy();
+ expect(
+ screen.queryByTestId('atw-collect-read-only')
+ ).not.toBeInTheDocument();
+ });
+
+ it('withholds the Collect pane from a non-admin, leaving Results', async () => {
+ mockCanMutate = false;
+ renderWorkspace();
+
+ await waitFor(() => expect(screen.getByText('Results')).toBeTruthy());
+ expect(screen.queryByText('Collect')).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('combobox', { name: 'Snippets' })
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId('atw-collect-read-only')).toBeTruthy();
+ expect(
+ screen.getByText(/permission to collect diagnostics for this incident/i)
+ ).toBeTruthy();
+ });
+
+ it('fetches no snippet categories for a non-admin', async () => {
+ mockCanMutate = false;
+ renderWorkspace();
+
+ await waitFor(() => expect(screen.getByText('Results')).toBeTruthy());
+ expect(mockedApi.get).not.toHaveBeenCalledWith('/apps/atw/');
+ });
});
diff --git a/ui/packages/plugins/atw/tests/ResultsPane.test.tsx b/ui/packages/plugins/atw/tests/ResultsPane.test.tsx
index 3b1314b4e4..dba53a44c8 100644
--- a/ui/packages/plugins/atw/tests/ResultsPane.test.tsx
+++ b/ui/packages/plugins/atw/tests/ResultsPane.test.tsx
@@ -100,9 +100,23 @@ describe('ResultsPane', () => {
renderPane();
+ await waitFor(() => {
+ expect(
+ screen.getByText(/Run snippets from the Collect pane/i)
+ ).toBeTruthy();
+ });
+ });
+
+ it('does not point a read-only session at the withheld Collect pane', async () => {
+ mockCanMutate = false;
+ mockedApi.get.mockResolvedValue(paginated([]));
+
+ renderPane();
+
await waitFor(() => {
expect(screen.getByText(/No executions yet/i)).toBeTruthy();
});
+ expect(screen.queryByText(/Collect pane/i)).not.toBeInTheDocument();
});
it('renders an Unknown chip when the task status could not be hydrated', async () => {