diff --git a/bun_client/frontend/src/components/PluginOutput.tsx b/bun_client/frontend/src/components/PluginOutput.tsx
index fd28508..62fb8e3 100644
--- a/bun_client/frontend/src/components/PluginOutput.tsx
+++ b/bun_client/frontend/src/components/PluginOutput.tsx
@@ -1,7 +1,12 @@
import { useState, useEffect } from 'react';
import { rpcCall } from '../api';
import { Button, Badge, Card, mapStatusToVariant, Theme } from '../design';
-import { resolvePluginBody, sortedAnnotations, type PluginResult } from '../plugin_utils';
+import {
+ canRerunPlugin,
+ resolvePluginBody,
+ sortedAnnotations,
+ type PluginResult,
+} from '../plugin_utils';
import PluginAnnotations from './PluginAnnotations';
import PluginBodyView from './PluginBodyView';
@@ -159,6 +164,17 @@ export default function PluginOutput({
gap: '10px',
}}
>
+ {canRerunPlugin(plugin) && (
+
+ )}
{plugin.status}
diff --git a/bun_client/frontend/src/components/review/PluginsPanel.tsx b/bun_client/frontend/src/components/review/PluginsPanel.tsx
index a9ade61..9b84942 100644
--- a/bun_client/frontend/src/components/review/PluginsPanel.tsx
+++ b/bun_client/frontend/src/components/review/PluginsPanel.tsx
@@ -1,5 +1,5 @@
import { Button, colors, shadows } from '../../design';
-import { resolvePluginBody, sortedAnnotations } from '../../plugin_utils';
+import { canRerunPlugin, resolvePluginBody, sortedAnnotations } from '../../plugin_utils';
import PluginAnnotations from '../PluginAnnotations';
import PluginBodyView from '../PluginBodyView';
import type { PluginResult } from './types';
@@ -115,39 +115,58 @@ export default function PluginsPanel({
{name}
-
- {data.status.toUpperCase()}
-
- {data.status.toLowerCase() === 'deferred' && (
-
- )}
+ {data.status.toUpperCase()}
+
+ {data.status.toLowerCase() === 'deferred' && (
+ onExecutePlugin(name)}
+ loading={executingPlugins.has(name)}
+ variant="secondary"
+ size="sm"
+ >
+ Execute
+
+ )}
+
{
});
});
+describe('canRerunPlugin', () => {
+ test('offers a re-run for plugins that finished executing', () => {
+ expect(canRerunPlugin({ result: 'summary', status: 'success' })).toBe(true);
+ expect(canRerunPlugin({ result: 'Error: boom', status: 'error' })).toBe(true);
+ });
+
+ test('does not offer a re-run for a deferred plugin that never ran', () => {
+ expect(canRerunPlugin({ result: '', status: 'deferred' })).toBe(false);
+ expect(canRerunPlugin({ result: '', status: 'DEFERRED' })).toBe(false);
+ });
+
+ test('does not offer a re-run while a plugin is still in flight', () => {
+ expect(canRerunPlugin({ result: '', status: 'pending' })).toBe(false);
+ });
+
+ test('treats a missing status as not re-runnable', () => {
+ expect(canRerunPlugin({ result: '', status: '' })).toBe(false);
+ });
+});
+
describe('totalAnnotationCount', () => {
test('sums annotations across plugins and ignores plugins without any', () => {
const plugins: Record = {
diff --git a/bun_client/frontend/src/plugin_utils.ts b/bun_client/frontend/src/plugin_utils.ts
index 968aada..e78a5bd 100644
--- a/bun_client/frontend/src/plugin_utils.ts
+++ b/bun_client/frontend/src/plugin_utils.ts
@@ -90,6 +90,19 @@ export function annotationSeverityVariant(severity: string): StatusVariant {
}
}
+/**
+ * Whether a plugin can be re-run from the UI.
+ *
+ * Only plugins that have finished executing offer a re-run: a `deferred`
+ * plugin has never run (it gets an Execute button instead) and a `pending`
+ * one is still in flight. Everything else — success, error, or a status a
+ * newer server invents — has a result on screen worth refreshing.
+ */
+export function canRerunPlugin(plugin: PluginResult): boolean {
+ const status = (plugin.status || '').toLowerCase();
+ return status !== '' && status !== 'deferred' && status !== 'pending';
+}
+
/** Total number of annotations across every plugin, for summary counts. */
export function totalAnnotationCount(plugins: Record): number {
return Object.values(plugins).reduce((sum, p) => sum + (p.annotations?.length ?? 0), 0);