chore: commit all changes (automated)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 8m56s

This commit is contained in:
Ahmed Darrazi 2026-05-04 14:09:40 +02:00
parent 6bf8e7f76b
commit a7df5c9adb
21 changed files with 2398 additions and 106 deletions

View File

@ -28,6 +28,7 @@
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Width;
use Filament\Support\Enums\Size;
class ListInventoryItems extends ListRecords
@ -36,6 +37,8 @@ class ListInventoryItems extends ListRecords
protected static string $resource = InventoryItemResource::class;
protected Width|string|null $maxContentWidth = Width::Full;
public function mount(): void
{
app(CanonicalAdminTenantFilterState::class)->sync(

View File

@ -17,10 +17,13 @@
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Livewire\Attributes\On;
class InventoryKpiHeader extends StatsOverviewWidget
{
@ -30,7 +33,26 @@ class InventoryKpiHeader extends StatsOverviewWidget
protected int|string|array $columnSpan = 'full';
protected ?string $pollingInterval = null;
protected function getPollingInterval(): ?string
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof Tenant) {
return null;
}
return ActiveRuns::countForTenantId((int) $tenant->getKey()) > 0 ? '10s' : null;
}
#[On(OpsUxBrowserEvents::RunEnqueued)]
public function onRunEnqueued(?int $tenantId = null): void
{
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenantId !== null && $tenant instanceof Tenant && (int) $tenant->getKey() !== (int) $tenantId) {
return;
}
}
/**
* @return array<Stat>
@ -51,15 +73,10 @@ protected function getStats(): array
$truth = app(TenantCoverageTruthResolver::class)->resolve($tenant);
$activeOps = (int) OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->active()
->count();
$activeOps = ActiveRuns::countForTenantId((int) $tenant->getKey());
$inventoryOps = (int) OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
$inventoryOps = (int) ActiveRuns::queryForTenantId((int) $tenant->getKey())
->whereIn('type', OperationCatalog::rawValuesForCanonical(OperationRunType::InventorySync->value))
->active()
->count();
return [

View File

@ -4,6 +4,7 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament;
use Illuminate\Support\Collection;
@ -13,6 +14,8 @@
class BulkOperationProgress extends Component
{
private const VISIBLE_RUN_LIMIT = 3;
/**
* @var Collection<int, OperationRun>
*/
@ -24,6 +27,10 @@ class BulkOperationProgress extends Component
public bool $hasActiveRuns = false;
public bool $hasVisibleRuns = false;
public int $activeRunCount = 0;
public ?int $tenantId = null;
public function mount(): void
@ -47,7 +54,7 @@ public function onRunEnqueued(?int $tenantId = null): void
}
#[Computed]
public function activeRuns()
public function activeRuns(): Collection
{
return $this->runs;
}
@ -83,15 +90,14 @@ public function refreshRuns(): void
$this->disabled = false;
$query = OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->orderByDesc('created_at');
$activeCount = ActiveRuns::countForTenantId($tenantId);
$visibleCount = ActiveRuns::shellVisibleCountForTenantId($tenantId);
$activeCount = (clone $query)->count();
$this->runs = (clone $query)->limit(6)->get();
$this->overflowCount = max(0, $activeCount - 5);
$this->activeRunCount = $activeCount;
$this->runs = ActiveRuns::shellVisibleForTenantId($tenantId, self::VISIBLE_RUN_LIMIT);
$this->overflowCount = max(0, $visibleCount - self::VISIBLE_RUN_LIMIT);
$this->hasActiveRuns = $activeCount > 0;
$this->hasVisibleRuns = $visibleCount > 0;
}
public function render(): \Illuminate\Contracts\View\View

View File

@ -75,7 +75,7 @@ public function panel(Panel $panel): Panel
fn () => view('filament.partials.context-bar')->render()
)
->renderHook(
PanelsRenderHook::BODY_END,
PanelsRenderHook::CONTENT_START,
fn (): string => $this->shouldRenderBulkOperationProgressWidget()
? view('livewire.bulk-operation-progress-wrapper')->render()
: ''
@ -116,13 +116,11 @@ public function panel(Panel $panel): Panel
Authenticate::class,
]);
if (! app()->runningUnitTests()) {
$theme = PanelThemeAsset::resolve('resources/css/filament/admin/theme.css');
if (is_string($theme)) {
$panel->theme($theme);
}
}
return $panel;
}

View File

@ -6,9 +6,106 @@
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
final class ActiveRuns
{
private const int TERMINAL_SUCCESS_GRACE_SECONDS = 30;
public static function queryForTenantId(?int $tenantId): Builder
{
return OperationRun::query()
->where('tenant_id', $tenantId)
->active()
->orderByDesc('created_at');
}
public static function shellVisibleQueryForTenantId(?int $tenantId): Builder
{
return OperationRun::query()
->where('tenant_id', $tenantId)
->where(function (Builder $query): void {
$query
->where(function (Builder $activeQuery): void {
$activeQuery->active();
})
->orWhere(function (Builder $successQuery): void {
$successQuery
->where('status', OperationRunStatus::Completed->value)
->where('outcome', OperationRunOutcome::Succeeded->value)
->whereNotNull('completed_at')
->where('completed_at', '>=', now()->subSeconds(self::TERMINAL_SUCCESS_GRACE_SECONDS));
})
->orWhere(function (Builder $followUpQuery): void {
$followUpQuery->terminalFollowUp();
});
})
->orderByRaw(
'case when status in (?, ?) then 0 when status = ? and outcome in (?, ?, ?) then 1 when status = ? and outcome = ? then 2 else 3 end',
[
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
OperationRunStatus::Completed->value,
OperationRunOutcome::Blocked->value,
OperationRunOutcome::PartiallySucceeded->value,
OperationRunOutcome::Failed->value,
OperationRunStatus::Completed->value,
OperationRunOutcome::Succeeded->value,
],
)
->orderByRaw('coalesce(completed_at, started_at, created_at) desc')
->orderByDesc('id');
}
/**
* @return Collection<int, OperationRun>
*/
public static function visibleForTenantId(?int $tenantId, int $limit = 3): Collection
{
if (! is_int($tenantId) || $tenantId <= 0) {
return collect();
}
return self::queryForTenantId($tenantId)
->limit(max(1, $limit))
->get();
}
/**
* @return Collection<int, OperationRun>
*/
public static function shellVisibleForTenantId(?int $tenantId, int $limit = 3): Collection
{
if (! is_int($tenantId) || $tenantId <= 0) {
return collect();
}
return self::shellVisibleQueryForTenantId($tenantId)
->limit(max(1, $limit))
->get();
}
public static function countForTenantId(?int $tenantId): int
{
if (! is_int($tenantId) || $tenantId <= 0) {
return 0;
}
return self::queryForTenantId($tenantId)->count();
}
public static function shellVisibleCountForTenantId(?int $tenantId): int
{
if (! is_int($tenantId) || $tenantId <= 0) {
return 0;
}
return self::shellVisibleQueryForTenantId($tenantId)->count();
}
public static function existForTenant(Tenant $tenant): bool
{
return self::existForTenantId((int) $tenant->getKey());

View File

@ -4,6 +4,7 @@
namespace App\Support\OpsUx;
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Livewire\BulkOperationProgress;
use Filament\Facades\Filament;
@ -27,5 +28,6 @@ public static function dispatchRunEnqueued(mixed $livewire): void
// Our progress widget is mounted outside the initiating component's DOM tree,
// so we target it explicitly to ensure it receives the event immediately.
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(BulkOperationProgress::class);
$livewire->dispatch(self::RunEnqueued, tenantId: $tenantId)->to(InventoryKpiHeader::class);
}
}

View File

@ -43,6 +43,34 @@ public static function elapsedHuman(OperationRun $run): string
return $end->diffForHumans($start, true);
}
public static function elapsedCompact(OperationRun $run): string
{
$seconds = self::elapsedSeconds($run);
if (! is_int($seconds) || $seconds <= 0) {
return 'now';
}
if ($seconds < 60) {
return sprintf('%d sec.', $seconds);
}
if ($seconds < 3600) {
return sprintf('%d min.', (int) round($seconds / 60));
}
if ($seconds < 86400) {
return sprintf('%d hr.', (int) round($seconds / 3600));
}
return sprintf('%d day%s', (int) round($seconds / 86400), $seconds < 172800 ? '' : 's');
}
public static function completedRecency(OperationRun $run): string
{
return $run->completed_at?->diffForHumans() ?? 'just now';
}
public static function expectedSeconds(OperationRun $run): ?int
{
$catalog = OperationCatalog::expectedDurationSeconds((string) $run->type);

View File

@ -13,6 +13,7 @@
activeSinceMs: null,
fastUntilMs: null,
teardownObserver: null,
isCollapsed: false,
init() {
this.onVisibilityChange = this.onVisibilityChange.bind(this);
@ -21,6 +22,9 @@
this.onNavigated = this.onNavigated.bind(this);
window.addEventListener('livewire:navigated', this.onNavigated);
this.onRunEnqueued = this.onRunEnqueued.bind(this);
window.addEventListener('ops-ux:run-enqueued', this.onRunEnqueued);
this.teardownObserver = new MutationObserver(() => {
if (!this.$el || this.$el.isConnected !== true) {
this.destroy();
@ -29,6 +33,7 @@
this.teardownObserver.observe(document.body, { childList: true, subtree: true });
this.syncCollapsedState();
this.schedule(0);
},
@ -36,6 +41,7 @@
this.stop();
window.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('livewire:navigated', this.onNavigated);
window.removeEventListener('ops-ux:run-enqueued', this.onRunEnqueued);
if (this.teardownObserver) {
this.teardownObserver.disconnect();
@ -43,6 +49,56 @@
}
},
currentTenantId() {
const tenantId = Number(this.$el?.dataset?.tenantId ?? this.$wire?.tenantId ?? 0);
return Number.isFinite(tenantId) ? tenantId : 0;
},
collapseStorageKey() {
return `tenantpilot:ops-ux-activity-feedback:${this.currentTenantId() || 'global'}:collapsed`;
},
readCollapsedState() {
try {
return window.sessionStorage?.getItem(this.collapseStorageKey()) === '1';
} catch {
return false;
}
},
writeCollapsedState(value) {
try {
if (!window.sessionStorage) {
return;
}
const key = this.collapseStorageKey();
if (value) {
window.sessionStorage.setItem(key, '1');
return;
}
window.sessionStorage.removeItem(key);
} catch {
}
},
syncCollapsedState() {
this.isCollapsed = this.readCollapsedState();
},
collapse() {
this.isCollapsed = true;
this.writeCollapsedState(true);
},
expand() {
this.isCollapsed = false;
this.writeCollapsedState(false);
},
stop() {
if (this.timer) {
clearTimeout(this.timer);
@ -77,6 +133,24 @@
},
onNavigated() {
this.syncCollapsedState();
if (!this.isPaused()) {
this.schedule(0);
}
},
onRunEnqueued(event) {
const eventTenantId = Number(event?.detail?.tenantId ?? 0);
const currentTenantId = this.currentTenantId();
if (eventTenantId > 0 && currentTenantId > 0 && eventTenantId !== currentTenantId) {
return;
}
this.fastUntilMs = Date.now() + 10_000;
this.expand();
if (!this.isPaused()) {
this.schedule(0);
}
@ -95,11 +169,16 @@
return null;
}
if (this.$wire?.hasActiveRuns !== true) {
if (this.$wire?.hasVisibleRuns !== true) {
this.activeSinceMs = null;
return 30_000;
}
if (this.$wire?.hasActiveRuns !== true) {
this.activeSinceMs = null;
return 5_000;
}
if (this.activeSinceMs === null) {
this.activeSinceMs = Date.now();
}

View File

@ -7,3 +7,87 @@ @theme {
@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/filament/**/*.blade.php';
@source '../../../../resources/views/livewire/**/*.blade.php';
@layer components {
.tp-ops-activity-banner .tp-ops-activity-layout {
display: flex !important;
flex-direction: column;
gap: 0.75rem;
}
.tp-ops-activity-banner .tp-ops-activity-header {
display: flex !important;
flex-direction: column;
gap: 0.75rem;
}
.tp-ops-activity-banner .tp-ops-activity-header-copy {
display: flex !important;
align-items: flex-start;
gap: 0.75rem;
min-width: 0;
}
.tp-ops-activity-banner .tp-ops-activity-title {
white-space: nowrap;
}
.tp-ops-activity-banner .tp-ops-activity-helper {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
line-height: 1.35;
}
.tp-ops-activity-banner .tp-ops-activity-summary {
margin-top: 0.75rem;
}
.tp-ops-activity-banner .tp-ops-activity-progress,
.tp-ops-activity-banner .tp-ops-activity-indeterminate {
width: 100%;
}
.tp-ops-activity-banner .tp-ops-activity-actions {
display: flex !important;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 640px) {
.tp-ops-activity-banner .tp-ops-activity-summary {
padding-left: 3.25rem;
}
}
@media (min-width: 1024px) {
.tp-ops-activity-banner .tp-ops-activity-header {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.tp-ops-activity-banner .tp-ops-activity-header-copy {
flex: 1 1 auto;
min-width: 18rem;
}
.tp-ops-activity-banner .tp-ops-activity-helper {
max-width: 48rem;
}
.tp-ops-activity-banner .tp-ops-activity-actions {
flex: none;
flex-wrap: nowrap;
align-self: flex-start;
justify-content: flex-end;
}
.tp-ops-activity-banner .tp-ops-activity-summary {
padding-left: 3.25rem;
}
}
}

View File

@ -2,6 +2,31 @@
$runs = $runs ?? collect();
$overflowCount = (int) ($overflowCount ?? 0);
$tenant = $tenant ?? null;
$visibleRunCount = $runs->count();
$activeRunCount = (int) ($activeRunCount ?? ($runs->filter(fn ($run): bool => $run instanceof \App\Models\OperationRun && $run->isCurrentlyActive())->count() + $overflowCount));
$primaryRun = $runs->first();
$hasActiveVisibleRuns = $runs->contains(fn ($run): bool => $run instanceof \App\Models\OperationRun && $run->isCurrentlyActive());
$hasTerminalFollowUpVisibleRuns = $runs->contains(fn ($run): bool => $run instanceof \App\Models\OperationRun && ! $run->isCurrentlyActive() && $run->requiresOperatorReview());
$hasTerminalVisibleRuns = $runs->contains(fn ($run): bool => $run instanceof \App\Models\OperationRun && ! $run->isCurrentlyActive());
$usesCollectivePrimaryAction = $visibleRunCount > 1;
$operationsCollectionLabel = \App\Support\OperationRunLinks::collectionLabel();
$operationsIndexUrl = $tenant ? \App\Support\OpsUx\OperationRunUrl::index($tenant) : null;
$primaryActionLabel = $usesCollectivePrimaryAction ? 'Review operations' : 'View operation';
$bannerTitle = $hasTerminalVisibleRuns ? 'Operation updates' : 'Active operations';
$bannerHelper = $hasTerminalVisibleRuns
? 'Recent operation updates stay inside the tenant shell until you need the diagnostics view.'
: 'Queued and running work stays inside the tenant shell until you need the diagnostics view.';
$primaryActionUrl = null;
if ($usesCollectivePrimaryAction) {
$primaryActionUrl = $operationsIndexUrl;
} elseif ($tenant && $primaryRun) {
$primaryActionUrl = \App\Support\OpsUx\OperationRunUrl::view($primaryRun, $tenant);
}
$tertiaryActionLabel = $hasActiveVisibleRuns
? 'Hide activity'
: ($hasTerminalFollowUpVisibleRuns ? ($visibleRunCount > 1 ? 'Dismiss updates' : 'Dismiss') : 'Dismiss');
@endphp
{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}}
@ -11,75 +36,227 @@
x-data="opsUxProgressWidgetPoller()"
x-init="init()"
wire:key="ops-ux-progress-widget"
@if (! $disabled && $hasActiveRuns)
data-testid="ops-ux-activity-feedback-root"
data-tenant-id="{{ $tenant?->getKey() }}"
@if (! $disabled && $hasVisibleRuns)
wire:poll.10s="refreshRuns"
@endif
>
@if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
@foreach ($runs->take(5) as $run)
<section
class="tp-ops-activity-banner mt-5 mb-7 w-full rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm sm:px-5 sm:py-3 dark:border-white/10 dark:bg-gray-950/90"
data-testid="ops-ux-activity-feedback-banner"
x-cloak
x-show="$wire.hasVisibleRuns === true && (! isCollapsed || $wire.hasActiveRuns === true)"
>
<div class="tp-ops-activity-layout flex flex-col gap-3">
<div x-cloak x-show="! isCollapsed">
<div class="tp-ops-activity-header flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between lg:gap-4">
<div class="tp-ops-activity-header-copy flex min-w-0 flex-1 items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-50 text-primary-600 ring-1 ring-primary-100 dark:bg-primary-500/15 dark:text-primary-300 dark:ring-primary-400/20">
<x-filament::icon icon="heroicon-m-bolt" class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1 lg:flex lg:items-start lg:gap-3">
<p class="tp-ops-activity-title text-xs font-semibold uppercase tracking-[0.12em] whitespace-nowrap text-primary-700 dark:text-primary-300" data-testid="ops-ux-activity-feedback-title">{{ $bannerTitle }}</p>
<p class="tp-ops-activity-helper mt-1 max-w-3xl text-[13px] leading-[1.45] text-gray-500 lg:mt-0 dark:text-gray-400" data-testid="ops-ux-activity-feedback-helper">{{ $bannerHelper }}</p>
</div>
</div>
<div
class="tp-ops-activity-actions flex shrink-0 flex-wrap items-center gap-1.5 lg:flex-none lg:flex-nowrap lg:justify-end"
data-testid="ops-ux-activity-feedback-actions"
>
@if ($primaryActionUrl)
<a
href="{{ $primaryActionUrl }}"
class="inline-flex items-center justify-center rounded-lg bg-primary-600 px-2.5 py-2 text-xs font-medium text-white shadow-sm transition hover:bg-primary-500 dark:bg-primary-500 dark:hover:bg-primary-400"
data-testid="ops-ux-activity-feedback-primary-action"
>
{{ $primaryActionLabel }}
</a>
@endif
@if ($operationsIndexUrl)
<a
href="{{ $operationsIndexUrl }}"
class="inline-flex items-center justify-center rounded-lg border border-gray-200 px-2.5 py-2 text-xs font-medium text-gray-700 transition hover:border-gray-300 hover:text-gray-900 dark:border-white/10 dark:text-gray-200 dark:hover:border-white/20 dark:hover:text-white"
data-testid="ops-ux-activity-feedback-secondary-action"
>
Show all operations
</a>
@endif
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-transparent px-2.5 py-2 text-xs font-medium text-gray-500 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
x-on:click="collapse()"
data-testid="ops-ux-activity-feedback-toggle"
>
{{ $tertiaryActionLabel }}
</button>
</div>
</div>
<div class="tp-ops-activity-summary mt-3 min-w-0 sm:pl-[3.25rem]" data-testid="ops-ux-activity-feedback-summary">
<div class="space-y-3" data-testid="ops-ux-activity-feedback-list">
@foreach ($runs as $run)
@php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus,
[
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$uxStatus = \App\Support\OpsUx\OperationStatusNormalizer::toUxStatus($run->status, $run->outcome);
$isTerminalRun = ! $run->isCurrentlyActive();
$summaryCounts = is_array($run->summary_counts) ? $run->summary_counts : [];
$hasDeterminateProgress = ! $isTerminalRun
&& $run->status !== 'queued'
&& is_numeric($summaryCounts['total'] ?? null)
&& is_numeric($summaryCounts['processed'] ?? null)
&& (int) $summaryCounts['total'] > 0;
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
$showsLifecycleAttention = $lifecycleAttention !== null
&& ($lifecycleAttention !== 'Likely stale' || $run->status === 'queued' || ! $hasDeterminateProgress);
$progressTotal = $hasDeterminateProgress ? max(1, (int) $summaryCounts['total']) : null;
$progressProcessed = $hasDeterminateProgress
? min(max(0, (int) $summaryCounts['processed']), $progressTotal)
: null;
$progressPercent = $hasDeterminateProgress
? max(0, min(100, (int) round(($progressProcessed / $progressTotal) * 100)))
: null;
$progressLabel = $hasDeterminateProgress
? sprintf('%d / %d processed (%d%%)', $progressProcessed, $progressTotal, $progressPercent)
: null;
$showsIndeterminateProgress = ! $isTerminalRun && ! $hasDeterminateProgress;
$statusLabel = match ($uxStatus) {
'queued' => 'Queued for execution',
'running' => 'In progress',
'succeeded' => 'Completed successfully',
'partial' => 'Completed with follow-up',
'blocked' => 'Blocked by prerequisite',
default => 'Execution failed',
};
$statusClasses = match ($uxStatus) {
'queued' => 'bg-primary-50 text-primary-700 ring-1 ring-inset ring-primary-100 dark:bg-primary-500/10 dark:text-primary-200 dark:ring-primary-400/20',
'running' => 'bg-primary-100 text-primary-800 ring-1 ring-inset ring-primary-200 dark:bg-primary-500/20 dark:text-primary-100 dark:ring-primary-400/25',
'succeeded' => 'bg-success-50 text-success-800 ring-1 ring-inset ring-success-200 dark:bg-success-500/15 dark:text-success-100 dark:ring-success-400/25',
'partial', 'blocked' => 'bg-warning-50 text-warning-800 ring-1 ring-inset ring-warning-200 dark:bg-warning-500/10 dark:text-warning-100 dark:ring-warning-400/25',
default => 'bg-danger-50 text-danger-800 ring-1 ring-inset ring-danger-200 dark:bg-danger-500/10 dark:text-danger-100 dark:ring-danger-400/25',
};
$activityLabel = $run->status === 'queued'
? 'Waiting for worker.'
: 'Progress details pending.';
$elapsedLabel = \App\Support\OpsUx\RunDurationInsights::elapsedCompact($run);
$surfaceGuidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
$activitySummaryLine = $isTerminalRun
? sprintf('Completed · %s', \App\Support\OpsUx\RunDurationInsights::completedRecency($run))
: sprintf(
'%s · %s · %s',
$run->status === 'queued' ? 'Queued' : 'Running',
$elapsedLabel,
$progressLabel ?? $activityLabel,
);
@endphp
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
wire:key="run-{{ $run->id }}">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div
class="tp-ops-activity-item min-w-0 @if (! $loop->last) border-b border-gray-200/70 pb-3 dark:border-white/10 @endif"
wire:key="run-{{ $run->id }}"
data-testid="ops-ux-activity-feedback-item"
>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ \App\Support\OperationCatalog::label((string) $run->type) }}
</h4>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
@if($run->status === 'queued')
Queued {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
@else
Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
@endif
</p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::badge :color="$statusSpec->color" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($lifecycleAttention)
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
<span
class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap {{ $statusClasses }}"
data-testid="ops-ux-activity-feedback-status-pill"
>
{{ $statusLabel }}
</span>
@if ($showsLifecycleAttention)
<span class="inline-flex shrink-0 items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div>
@if ($guidance)
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $guidance }}
<p class="tp-ops-activity-item-meta text-xs text-gray-500 dark:text-gray-400" data-testid="ops-ux-activity-feedback-meta">
{{ $activitySummaryLine }}
</p>
@if ($isTerminalRun && $surfaceGuidance)
<p class="text-xs font-medium text-gray-700 dark:text-gray-200" data-testid="ops-ux-activity-feedback-guidance">
{{ $surfaceGuidance }}
</p>
@endif
</div>
@if ($tenant)
<a
href="{{ \App\Support\OpsUx\OperationRunUrl::view($run, $tenant) }}"
class="shrink-0 text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
>
Open operation
</a>
@if ($progressPercent !== null && $progressLabel !== null)
<div class="tp-ops-activity-progress min-w-0" data-testid="ops-ux-activity-feedback-progress">
<div class="mt-1 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10" style="height: 0.375rem;" data-testid="ops-ux-activity-feedback-track">
<div
class="block h-full rounded-full bg-primary-500 dark:bg-primary-400"
role="progressbar"
aria-label="Progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ $progressPercent }}"
aria-valuetext="{{ $progressLabel }}"
style="width: {{ $progressPercent }}%;"
></div>
</div>
</div>
@elseif ($showsIndeterminateProgress)
<div class="tp-ops-activity-indeterminate min-w-0" data-testid="ops-ux-activity-feedback-indeterminate">
<div class="mt-1 overflow-hidden rounded-full bg-gray-100/90 dark:bg-white/8" style="height: 0.375rem;" data-testid="ops-ux-activity-feedback-track">
<div class="h-full w-[58%] rounded-full bg-primary-400/45 dark:bg-primary-300/35"></div>
</div>
</div>
@endif
</div>
</div>
@endforeach
</div>
@if($overflowCount > 0 && $tenant)
@if ($overflowCount > 0)
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ $overflowCount }} more operation update{{ $overflowCount === 1 ? '' : 's' }} available in
@if ($operationsIndexUrl)
<a href="{{ $operationsIndexUrl }}" class="font-medium text-gray-700 underline decoration-gray-300 underline-offset-2 transition hover:text-gray-900 dark:text-gray-200 dark:decoration-white/20 dark:hover:text-white" data-testid="ops-ux-activity-feedback-overflow-link">{{ $operationsCollectionLabel }}</a>.
@else
{{ $operationsCollectionLabel }}.
@endif
</p>
@endif
</div>
</div>
<div class="w-full" x-cloak x-show="isCollapsed && $wire.hasActiveRuns === true" data-testid="ops-ux-activity-feedback-collapsed">
<div class="flex items-center justify-between gap-4 rounded-xl border border-dashed border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/5">
<div class="text-[15px] font-semibold leading-5 tracking-tight text-gray-900 dark:text-gray-100">
{{ $activeRunCount }} active operation{{ $activeRunCount === 1 ? '' : 's' }}
</div>
<div class="flex items-center gap-2">
@if($tenant)
<a
href="{{ \App\Support\OpsUx\OperationRunUrl::index($tenant) }}"
class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center text-xs font-medium text-gray-700 hover:text-gray-900 dark:text-gray-200 dark:hover:text-white shadow"
class="inline-flex items-center rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-gray-300 hover:text-gray-900 dark:border-white/10 dark:text-gray-200 dark:hover:border-white/20 dark:hover:text-white"
>
+{{ $overflowCount }} more
Show all operations
</a>
@endif
<button
type="button"
class="inline-flex items-center rounded-lg border border-transparent px-3 py-2 text-xs font-medium text-primary-700 transition hover:bg-primary-50 hover:text-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/40 dark:hover:text-primary-200"
x-on:click="expand()"
data-testid="ops-ux-activity-feedback-expand"
>
Show activity
</button>
</div>
</div>
</div>
</div>
</section>
@endif
</div>

View File

@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\InventoryItemResource;
use App\Models\Finding;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->browser()->timeout(20_000);
uses(RefreshDatabase::class);
function operationActivityFeedbackSmokeLoginUrl(User $user, Tenant $tenant, string $redirect = ''): string
{
return route('admin.local.smoke-login', array_filter([
'email' => $user->email,
'tenant' => $tenant->external_id,
'workspace' => $tenant->workspace->slug,
'redirect' => $redirect,
], static fn (?string $value): bool => filled($value)));
}
it('keeps findings row actions reachable while the activity hint collapses and reopens within the browser session', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
InventoryItem::factory()->count(3)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Browser Inventory Item',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'last_seen_at' => now()->subMinute(),
]);
Finding::factory()->count(5)->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_NEW,
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinutes(2),
]);
visit(operationActivityFeedbackSmokeLoginUrl($user, $tenant))
->waitForText('Dashboard')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$inventoryPage = visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant))
->resize(1440, 1200)
->assertScript('window.innerWidth >= 1400', true)
->waitForText('Inventory Items')
->waitForText('Browser Inventory Item')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$shellGeometry = $inventoryPage->script(<<<'JS'
(() => {
const banner = document.querySelector('[data-testid="ops-ux-activity-feedback-banner"]');
const topbar = document.querySelector('.fi-topbar');
const table = document.querySelector('.fi-ta');
const tableContainer = table?.closest('.overflow-x-auto') ?? table?.parentElement ?? null;
const contentShell = document.querySelector('.fi-page') ?? document.querySelector('.fi-main') ?? tableContainer?.parentElement ?? null;
const main = document.querySelector('.fi-main');
const header = banner?.querySelector('.tp-ops-activity-header') ?? null;
const actionGroup = document.querySelector('[data-testid="ops-ux-activity-feedback-actions"]');
const layout = banner?.querySelector('.tp-ops-activity-layout') ?? null;
const headerCopy = banner?.querySelector('.tp-ops-activity-header-copy') ?? null;
const title = banner?.querySelector('[data-testid="ops-ux-activity-feedback-title"]') ?? null;
const middleColumn = banner?.querySelector('.tp-ops-activity-summary') ?? null;
const track = banner?.querySelector('[data-testid="ops-ux-activity-feedback-track"]') ?? null;
if (!banner || !topbar || !tableContainer || !contentShell || !actionGroup || !layout || !header || !headerCopy || !title || !middleColumn || !track) {
return null;
}
const bannerRect = banner.getBoundingClientRect();
const topbarRect = topbar.getBoundingClientRect();
const tableRect = tableContainer.getBoundingClientRect();
const contentShellRect = contentShell.getBoundingClientRect();
const headerRect = header.getBoundingClientRect();
const actionGroupRect = actionGroup.getBoundingClientRect();
const headerCopyRect = headerCopy.getBoundingClientRect();
const titleRect = title.getBoundingClientRect();
const middleRect = middleColumn.getBoundingClientRect();
const trackRect = track.getBoundingClientRect();
const layoutStyle = window.getComputedStyle(layout);
const headerStyle = window.getComputedStyle(header);
const titleStyle = window.getComputedStyle(title);
const titleLineHeight = Number.parseFloat(titleStyle.lineHeight || '0');
return {
viewportWidth: window.innerWidth,
itemCount: banner.querySelectorAll('[data-testid="ops-ux-activity-feedback-item"]').length,
stylesheetCount: document.styleSheets.length,
hasThemeStylesheet: Array.from(document.styleSheets).some((sheet) => (sheet.href || '').includes('/build/assets/theme-')),
mainClassName: main?.className ?? '',
mainWidth: main?.getBoundingClientRect().width ?? 0,
layoutClassName: layout.className,
layoutDisplay: layoutStyle.display,
layoutFlexDirection: layoutStyle.flexDirection,
headerFlexDirection: headerStyle.flexDirection,
bannerTop: bannerRect.top,
topbarBottom: topbarRect.bottom,
bannerTopGap: bannerRect.top - topbarRect.bottom,
bannerHeight: bannerRect.height,
bannerWidth: bannerRect.width,
tableWidth: tableRect.width,
contentWidth: contentShellRect.width,
leftDelta: Math.abs(bannerRect.left - contentShellRect.left),
rightDelta: Math.abs(bannerRect.right - contentShellRect.right),
actionGroupRightDelta: Math.abs(bannerRect.right - actionGroupRect.right),
summaryTopDelta: middleRect.top - headerRect.bottom,
trackSpanRatio: middleRect.width > 0 ? trackRect.width / middleRect.width : 0,
titleOverflows: title.scrollWidth > (title.clientWidth + 1),
titleLineCount: titleLineHeight > 0 ? titleRect.height / titleLineHeight : 0,
headerCopyWidth: headerCopyRect.width,
middleHeight: middleRect.height,
actionHeight: actionGroupRect.height,
};
})()
JS);
expect($shellGeometry)->not->toBeNull()
->and($shellGeometry['viewportWidth'] ?? 0)->toBeGreaterThanOrEqual(1400)
->and($shellGeometry['itemCount'] ?? 0)->toBe(1)
->and($shellGeometry['stylesheetCount'] ?? 0)->toBeGreaterThan(0)
->and($shellGeometry['hasThemeStylesheet'] ?? false)->toBeTrue()
->and($shellGeometry['mainClassName'] ?? '')->toContain('fi-width-full')
->and($shellGeometry['mainWidth'] ?? 0)->toBeGreaterThanOrEqual(900)
->and($shellGeometry['layoutClassName'] ?? '')->toContain('tp-ops-activity-layout')
->and($shellGeometry['layoutClassName'] ?? '')->toContain('flex')
->and($shellGeometry['layoutDisplay'] ?? '')->toBe('flex')
->and($shellGeometry['layoutFlexDirection'] ?? '')->toBe('column')
->and($shellGeometry['headerFlexDirection'] ?? '')->toBe('row')
->and($shellGeometry['bannerTop'] ?? null)->toBeGreaterThanOrEqual($shellGeometry['topbarBottom'] ?? PHP_INT_MAX)
->and($shellGeometry['bannerTopGap'] ?? PHP_INT_MIN)->toBeGreaterThanOrEqual(12)
->and($shellGeometry['bannerWidth'] ?? 0)->toBeGreaterThan(0)
->and($shellGeometry['tableWidth'] ?? 0)->toBeGreaterThan(0)
->and($shellGeometry['contentWidth'] ?? 0)->toBeGreaterThan(0)
->and($shellGeometry['summaryTopDelta'] ?? PHP_INT_MIN)->toBeGreaterThanOrEqual(8)
->and($shellGeometry['trackSpanRatio'] ?? 0)->toBeGreaterThanOrEqual(0.72)
->and($shellGeometry['titleOverflows'] ?? true)->toBeFalse()
->and($shellGeometry['titleLineCount'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(1.2)
->and($shellGeometry['headerCopyWidth'] ?? 0)->toBeGreaterThanOrEqual(240)
->and($shellGeometry['leftDelta'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(32)
->and($shellGeometry['rightDelta'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(32)
->and($shellGeometry['actionGroupRightDelta'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(32)
->and($shellGeometry['bannerHeight'] ?? 0)->toBeGreaterThanOrEqual(96)
->and($shellGeometry['bannerHeight'] ?? PHP_INT_MAX)->toBeLessThanOrEqual(145);
$inventoryPage
->assertScript('document.documentElement.scrollWidth <= window.innerWidth', true)
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$page = visit(FindingResource::getUrl('index', panel: 'tenant', tenant: $tenant))
->resize(1440, 1200);
$page
->waitForText('Triage all matching')
->waitForText('View operation')
->waitForText('Show all operations')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('tbody tr.fi-ta-row:last-of-type [aria-label="More"]')
->waitForText('Triage')
->assertSee('Triage')
->click('[data-testid="ops-ux-activity-feedback-toggle"]')
->waitForText('Show activity')
->refresh()
->waitForText('Show activity');
$page->script(<<<'JS'
window.dispatchEvent(new CustomEvent('ops-ux:run-enqueued', {
detail: {
tenantId: Number(document.querySelector('[data-testid="ops-ux-activity-feedback-root"]')?.dataset.tenantId || 0),
},
}));
JS);
$page
->waitForText('View operation')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->assertDontSee('Show activity');
});

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
use App\Livewire\BulkOperationProgress;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Filament\Widgets\StatsOverviewWidget\Stat;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Livewire\Livewire;
function inventoryKpiValues($component): array
{
$method = new \ReflectionMethod(InventoryKpiHeader::class, 'getStats');
$method->setAccessible(true);
return collect($method->invoke($component->instance()))
->mapWithKeys(fn (Stat $stat): array => [
(string) $stat->getLabel() => (string) $stat->getValue(),
])
->all();
}
it('keeps the inventory active ops KPI aligned with the shell activity banner for the same tenant', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinute(),
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
$banner = Livewire::test(BulkOperationProgress::class)
->call('refreshRuns');
$kpis = inventoryKpiValues(Livewire::test(InventoryKpiHeader::class));
expect($banner->get('hasActiveRuns'))->toBeTrue()
->and($banner->get('runs'))->toHaveCount(1)
->and($kpis)->toMatchArray([
'Active ops' => '1',
]);
});
it('refreshes the inventory active ops KPI and shell banner together after a run is enqueued', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$banner = Livewire::test(BulkOperationProgress::class);
$kpi = Livewire::test(InventoryKpiHeader::class);
expect($banner->get('hasActiveRuns'))->toBeFalse()
->and(inventoryKpiValues($kpi))->toMatchArray([
'Active ops' => '0',
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinute(),
]);
$banner->dispatch(OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
$kpi->dispatch(OpsUxBrowserEvents::RunEnqueued, tenantId: (int) $tenant->getKey());
expect($banner->get('hasActiveRuns'))->toBeTrue()
->and($banner->get('runs'))->toHaveCount(1)
->and(inventoryKpiValues($kpi))->toMatchArray([
'Active ops' => '1',
]);
});

View File

@ -0,0 +1,390 @@
<?php
use App\Filament\Resources\InventoryItemResource;
use App\Livewire\BulkOperationProgress;
use App\Models\OperationRun;
use App\Support\OpsUx\OperationRunUrl;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('renders three visible run summaries while grouping banner actions in one action area', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$firstRun = null;
foreach (range(0, 3) as $offset) {
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'created_at' => now()->subMinutes($offset),
]);
if ($offset === 0) {
$firstRun = $run;
}
}
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = $component->html();
expect(substr_count($html, 'data-testid="ops-ux-activity-feedback-item"'))->toBe(3)
->and($html)->toContain('Active operations')
->and(substr_count($html, 'Review operations'))->toBe(1)
->and(substr_count($html, 'View operation'))->toBe(0)
->and($html)->toContain('data-testid="ops-ux-activity-feedback-actions"')
->and($html)->toContain('data-testid="ops-ux-activity-feedback-primary-action"')
->and($html)->toContain('Show all operations')
->and($html)->toContain('Hide activity')
->and($html)->not->toContain('Dismiss')
->and($html)->not->toContain('Acknowledge')
->and($html)->toContain(OperationRunUrl::index($tenant))
->and($html)->not->toContain($firstRun instanceof OperationRun ? OperationRunUrl::view($firstRun, $tenant) : '')
->and($html)->not->toContain('Open operation');
})->group('ops-ux');
it('renders a single visible operation with a detail primary action', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'created_at' => now()->subSeconds(10),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = $component->html();
expect(substr_count($html, 'data-testid="ops-ux-activity-feedback-item"'))->toBe(1)
->and($html)->toContain('Active operations')
->and(substr_count($html, 'View operation'))->toBe(1)
->and($html)->not->toContain('Review operations')
->and($html)->toContain(OperationRunUrl::view($run, $tenant))
->and($html)->toContain(OperationRunUrl::index($tenant))
->and($html)->toContain('Hide activity')
->and($html)->not->toContain('Dismiss')
->and($html)->not->toContain('Acknowledge');
})->group('ops-ux');
it('renders a recent terminal success state with dismiss instead of hide activity', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'succeeded',
'started_at' => now()->subMinutes(2),
'completed_at' => now()->subSeconds(8),
'summary_counts' => [
'total' => 10,
'processed' => 10,
],
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($component->get('hasActiveRuns'))->toBeFalse()
->and($html)->toContain('Operation updates')
->and($pageText)->toContain('Completed successfully')
->and($pageText)->toContain('No action needed.')
->and($html)->toContain('View operation')
->and($html)->not->toContain('Review operations')
->and($html)->toContain('Dismiss')
->and($html)->not->toContain('Hide activity')
->and($html)->not->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
->and($html)->not->toContain('role="progressbar"');
})->group('ops-ux');
it('renders terminal follow-up states with dismiss instead of hide activity', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'started_at' => now()->subMinutes(3),
'completed_at' => now()->subSeconds(6),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
expect($component->get('hasActiveRuns'))->toBeFalse()
->and($html)->toContain('Operation updates')
->and($html)->toContain('View operation')
->and($html)->not->toContain('Review operations')
->and($html)->toContain('Dismiss')
->and($html)->not->toContain('Acknowledge')
->and($html)->not->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
->and($html)->not->toContain('role="progressbar"')
->and($html)->not->toContain('Hide activity');
})->group('ops-ux');
it('uses a collective primary action when active and terminal follow-up items are both visible', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$runningRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinute(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'started_at' => now()->subMinutes(3),
'completed_at' => now()->subSeconds(6),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($html)->toContain('Review operations')
->and($html)->toContain('Operation updates')
->and($html)->not->toContain('View operation')
->and($html)->toContain('Hide activity')
->and($html)->toContain(OperationRunUrl::index($tenant))
->and($html)->not->toContain(OperationRunUrl::view($runningRun, $tenant))
->and($pageText)->toContain('Execution failed');
})->group('ops-ux');
it('renders an indeterminate running indicator when processed totals are not trustworthy', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'summary_counts' => [
'processed' => 4,
],
'started_at' => now()->subMinute(),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($html)->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
->and($html)->toContain('Active operations')
->and($pageText)->toMatch('/Running · .* · Progress details pending\./')
->and($html)->not->toContain('role="progressbar"')
->and($html)->toContain('View operation')
->and($html)->not->toContain('Review operations')
->and($html)->toContain('Hide activity');
})->group('ops-ux');
it('shows determinate progress with truthful processed totals and percent when summary counts are valid', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'summary_counts' => [
'total' => 10,
'processed' => 4,
],
'started_at' => now()->subWeeks(2),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($html)->toContain('role="progressbar"')
->and($html)->toContain('aria-valuenow="40"')
->and($html)->toContain('Active operations')
->and($pageText)->toMatch('/Running · .* · 4 \/ 10 processed \(40%\)/')
->and($html)->toContain('View operation')
->and($html)->not->toContain('Review operations')
->and($html)->not->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
->and($html)->not->toContain('Likely stale');
})->group('ops-ux');
it('renders an indeterminate queued indicator without fake determinate progress', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'summary_counts' => [
'processed' => 4,
],
'created_at' => now()->subSeconds(15),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($html)->toContain('data-testid="ops-ux-activity-feedback-indeterminate"')
->and($html)->toContain('Active operations')
->and($pageText)->toContain('Queued · now · Waiting for worker.')
->and($html)->not->toContain('aria-valuenow=')
->and($html)->toContain('View operation')
->and($html)->not->toContain('Review operations')
->and($html)->toContain('Hide activity')
->and(strip_tags($html))->not->toContain('processed (');
})->group('ops-ux');
it('keeps the queued status pill on one line for the compact banner layout', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'created_at' => now()->subSeconds(20),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$html = $component->html();
preg_match('/class="([^"]+)"[^>]*data-testid="ops-ux-activity-feedback-status-pill"/m', $html, $matches);
expect($matches[1] ?? '')->toContain('whitespace-nowrap')
->and($html)->toContain('Queued for execution');
})->group('ops-ux');
it('renders the activity banner inside the tenant shell instead of before the application chrome', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinute(),
]);
$response = $this->actingAs($user)
->get(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant));
$response->assertOk();
$content = $response->getContent();
expect(strpos($content, 'Tenant-Dashboard'))->toBeLessThan(strpos($content, 'Active operations'))
->and($content)->not->toContain('fixed bottom-4 right-4 z-[999999] w-96 space-y-2');
})->group('ops-ux');
it('registers browser-session collapse affordances and run-enqueued reopen wiring for active hints', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinute(),
]);
$component = Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns');
$script = file_get_contents(public_path('js/tenantpilot/ops-ux-progress-widget-poller.js'));
expect($component->html())->toContain('data-testid="ops-ux-activity-feedback-toggle"')
->and($component->html())->toContain('data-testid="ops-ux-activity-feedback-expand"')
->and($script)->toContain('sessionStorage')
->and($script)->toContain('ops-ux:run-enqueued');
})->group('ops-ux');

View File

@ -45,35 +45,105 @@
});
})->group('ops-ux');
it('removes terminal runs from the progress overlay on the next refresh cycle without a new enqueue event', function () {
it('keeps a just-completed successful run visible briefly as terminal success', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'succeeded',
'started_at' => now()->subMinutes(2),
'completed_at' => now()->subSeconds(10),
]);
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns')
->assertSet('hasActiveRuns', false)
->assertSee('Operation updates')
->assertSee('Inventory sync')
->assertSee('View operation')
->assertDontSee('Review operations')
->assertSee('Completed successfully')
->assertSee('No action needed.')
->assertSee('Dismiss')
->assertDontSee('Waiting for worker.')
->assertDontSee('Hide activity');
})->group('ops-ux');
it('keeps unresolved terminal follow-up runs visible instead of dropping them silently', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->set('tenantId', (int) $tenant->getKey())
->call('refreshRuns')
->assertSet('hasActiveRuns', false)
->assertDontSee('Inventory sync');
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'started_at' => now()->subMinutes(3),
'completed_at' => now()->subSeconds(5),
]);
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns')
->assertSet('hasActiveRuns', false)
->assertSee('Operation updates')
->assertSee('Inventory sync')
->assertSee('View operation')
->assertDontSee('Review operations')
->assertSee('Dismiss')
->assertDontSee('Acknowledge')
->assertDontSee('Waiting for worker.')
->assertDontSee('Hide activity');
})->group('ops-ux');
it('uses a collective primary action when multiple shell-visible operation updates are shown', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'running',
'outcome' => 'pending',
'started_at' => now()->subMinute(),
]);
$component = Livewire::actingAs($user)
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'completed',
'outcome' => 'failed',
'started_at' => now()->subMinutes(3),
'completed_at' => now()->subSeconds(5),
]);
Livewire::actingAs($user)
->test(BulkOperationProgress::class)
->call('refreshRuns')
->assertSet('hasActiveRuns', true)
->assertSee('Inventory sync');
$run->forceFill([
'status' => 'completed',
'outcome' => 'failed',
'completed_at' => now(),
])->save();
$component
->call('refreshRuns')
->assertSet('hasActiveRuns', false)
->assertDontSee('Inventory sync');
->assertSee('Operation updates')
->assertSee('Review operations')
->assertDontSee('View operation')
->assertSee('Show all operations')
->assertSee('Hide activity');
})->group('ops-ux');
it('shows likely stale runs in the progress overlay and keeps polling when only stale runs remain', function () {
@ -97,7 +167,7 @@
->assertSet('hasActiveRuns', true)
->assertSee('Inventory sync')
->assertSee('Likely stale')
->assertSee('This operation is past its lifecycle window.');
->assertSee('Waiting for worker.');
})->group('ops-ux');
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {

View File

@ -2,15 +2,16 @@
use App\Livewire\BulkOperationProgress;
use App\Models\OperationRun;
use App\Support\OpsUx\OperationRunUrl;
use Filament\Facades\Filament;
use Livewire\Livewire;
test('progress widget limits to five active runs and exposes overflow count', function () {
test('progress widget limits visible active runs to three and exposes overflow count', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
OperationRun::factory()->count(4)->create([
OperationRun::factory()->count(2)->create([
'tenant_id' => $tenant->id,
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
@ -19,7 +20,7 @@
'created_at' => now(),
]);
OperationRun::factory()->count(3)->create([
OperationRun::factory()->count(2)->create([
'tenant_id' => $tenant->id,
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
@ -32,8 +33,16 @@
->test(BulkOperationProgress::class)
->call('refreshRuns');
expect($component->get('runs'))->toHaveCount(6);
expect($component->get('overflowCount'))->toBe(2);
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
$pageText = preg_replace('/\s+/', ' ', strip_tags($html));
expect($component->get('runs'))->toHaveCount(3);
expect($component->get('overflowCount'))->toBe(1);
expect($component->get('runs')->map(fn (OperationRun $run): string => $run->freshnessState()->value)->unique()->values()->all())
->toEqualCanonicalizing(['fresh_active', 'likely_stale']);
expect($html)->toContain('Review operations')
->and($html)->not->toContain('View operation')
->and($html)->toContain('data-testid="ops-ux-activity-feedback-overflow-link"')
->and($html)->toContain(OperationRunUrl::index($tenant));
expect($pageText)->toContain('1 more operation update available in Operations.');
})->group('ops-ux');

View File

@ -1,10 +1,10 @@
# Spec Candidates
> **Status:** Active
> **Last reviewed:** 2026-05-03
> **Last reviewed:** 2026-05-04
> **Use for:** The active repo-based queue of spec candidates that may still need new or refreshed specs
> **Do not use for:** Proof that a candidate is already specced, implemented, or prioritized above the roadmap without repo verification
> **Scoped maintenance:** 2026-05-03 OperationRun activity feedback candidate intake plus the 2026-05-02 repo-based queue re-audit and enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264.
> **Scoped maintenance:** 2026-05-04 OperationRun progress maturity plus Tenant Dashboard active-operations summary candidate intake; 2026-05-03 OperationRun activity feedback candidate intake plus the 2026-05-02 repo-based queue re-audit and enterprise-SaaS deep-research alignment against current `specs/` truth, including Specs 263 and current-branch 264.
>
> Repo-based next-spec queue for TenantPilot.
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
@ -159,7 +159,7 @@ ## Promotable Candidate Backlog
### OperationRun Activity Feedback & UI Governance candidate group
- **Priority posture**: immediate manual-promotion pair, then sequenced strategic follow-ups
- **Priority posture**: immediate manual-promotion pair, then sequenced execution-truth maturity follow-ups, then longer-horizon adjacent work
- **Repo truth**: `OperationRun` is already the execution-truth layer in repo-real code and tests through `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, `OperationRunUrl`, active-run coverage, notification link contracts, dashboard drill-throughs, and the existing `BulkOperationProgress` Livewire plus poller path. The current progress widget is also a known overlap and UI-drift seam rather than a neutral pattern.
- **Why promotable now**: this is the clearest repo-based UX consistency gap around platform execution truth and the best bounded way to stop more surfaces from inventing their own run-state cards, fake progress patterns, or dismiss semantics.
- **Why manual promotion only**: the right cut is product-sensitive. The first safe promotion should bundle one bounded v1 execution slice with one durable UI guardrail, while keeping the larger tray, lifecycle, and acknowledgement questions as explicit follow-up candidates instead of hiding them inside the first spec.
@ -181,7 +181,248 @@ ### OperationRun Activity Feedback & UI Governance candidate group
- `failed`, `blocked`, `attention_required`, and equivalent follow-up states must not become permanently dismissible.
- Dashboard and start surfaces stay decision-first; Operations Hub and run detail stay diagnostics-first.
- Every run hint uses the canonical `View operation` link contract.
- **Strategic follow-up candidates (after the primary pair)**:
#### OperationRun / Execution Truth maturity follow-up queue
- **Repo starting point**: Spec 268 created the in-shell active operation surface, but the repo audit still shows follow-up maturity gaps around terminal outcome semantics, canonical progress truth, counted-progress rollout, and truthful phase/composite treatment.
- **Recommended promotion order**:
1. `269 — OperationRun Terminal Outcome Feedback`
2. `273 — Tenant Dashboard Active Operations Summary Card`, if dashboard feedback drift is visible after Spec 268; otherwise keep it as a small follow-up after the core progress semantics queue
3. `270 — OperationRun Progress Contract v1`
4. `271 — Counted Progress Rollout v1`
5. `272 — OperationRun Phase & Composite Progress v1`
- **Rationale**: `269` closes the immediate post-Spec-268 UX semantics gap. `273` keeps the Tenant Dashboard calm and governance-first while restoring a trustworthy tenant-scoped execution signal if operators lose confidence after starting work. `270` formalizes the reusable contract and blocks future fake progress. `271` adds real counted progress where stable work units exist. `272` is optional and should follow only after counted and terminal semantics are stable.
##### 269 — OperationRun Terminal Outcome Feedback
- **Classification**:
- repo-verified foundation: `OperationRun` Truth Layer, `ActiveRuns`, `BulkOperationProgress`, `OperationUxPresenter`
- gap: terminal outcome semantics in the activity surface
- maturity: small productization fix
- sellability impact: medium, because it increases operator trust
- **Problem**: Spec 268 correctly introduced activity feedback for queued and running work, but terminal states still need stricter UX semantics. Completed, failed, blocked, cancelled, or follow-up-required runs must not look like they are still progressing.
- **Goal**: complete the `OperationRun` feedback loop by separating active work from terminal outcome.
- **Scope**:
- active queued and running work may show activity or progress
- running with trustworthy `processed` and `total` may show determinate progress
- queued or running without trustworthy counts may show indeterminate activity
- completed successfully shows a short success confirmation, not a progress or activity line
- failed, blocked, cancelled, or follow-up-required states show decision or follow-up guidance, not progress
- tertiary action wording depends on lifecycle:
- queued or running: `Hide activity`
- completed successfully: `Close` or `Dismiss`
- failed, blocked, cancelled, or follow-up-required: `Acknowledge` or `Review`
- successful terminal state may auto-dismiss after a short grace period
- failed, blocked, cancelled, or follow-up-required states must not disappear silently
- **Out of scope**:
- new `OperationRun` widget system
- new top-level page
- fake percentage progress
- broad layout redesign
- run-type count rollout
- **Acceptance criteria**:
- terminal success does not render determinate or indeterminate progress
- terminal failed, blocked, cancelled, or follow-up-required states do not render progress
- active queued or running work without counts still renders indeterminate activity
- active running work with `processed` and `total` still renders determinate progress
- action wording matches lifecycle state
- existing placement, KPI agreement, overflow, and browser row-action tests remain green
##### 273 — Tenant Dashboard Active Operations Summary Card
- **Classification**:
- repo-verified foundation: `OperationRun` Truth Layer, `ActiveRuns`, `OperationRunLinks`, `OperationUxPresenter`, Tenant Dashboard Productization
- gap: Tenant Dashboard currently lacks a dashboard-native active operations summary when the full shell banner is intentionally not shown
- maturity: small productization follow-up
- sellability impact: medium, because it improves operator confidence without turning the dashboard into an operations console
- **Problem**: The Tenant Dashboard is a governance overview, not an operations console. Showing the full Spec 268 shell activity banner there can make the dashboard noisy and too operational. But showing nothing when `OperationRun` values are queued or running creates feedback drift: an operator may start an Inventory Sync or other tenant-scoped operation and then see no clear signal on the dashboard.
- **Goal**: add a dashboard-native Active Operations summary card that keeps the Tenant Dashboard calm while still reflecting `OperationRun` execution truth.
- **Scope**:
- add a compact dashboard card or panel for active tenant-scoped `OperationRun` values
- do not render the full Spec 268 expanded shell activity banner on the Tenant Dashboard by default
- use the same `OperationRun` truth source as the shell activity feedback, preferably the existing `ActiveRuns`, `OperationRunLinks`, and `OperationUxPresenter` family
- show a concise summary:
- `1 active operation` or localized equivalent
- primary active run label, for example `Inventory sync`
- status chip, for example `Waiting for worker`, `In progress`, `Likely stale`, or `Follow-up required`
- short guidance, for example `No action needed yet.` or `Review required.`
- primary CTA: `View operation`
- secondary link: `Show all operations`
- keep normal queued and running work calm and small
- make failed, blocked, stale, or follow-up-required runs more visible than healthy queued and running work
- respect workspace and tenant isolation
- respect operation-view permissions and capabilities
- avoid raw diagnostics by default
- **Out of scope**:
- new Operations page
- new `OperationRun` widget system
- full shell activity banner redesign
- counted progress rollout
- phase and composite progress model
- customer-facing review workspace changes
- raw logs or payloads on the dashboard
- **UX rules**:
- the Tenant Dashboard remains decision-first and governance-focused
- active operations are a secondary status signal, not the primary page focus
- the card should fit naturally into the dashboard grid, preferably near operational health, recent operations, or the right-side status column
- do not create a large full-width activity banner on the dashboard
- do not hide active operations completely
- terminal failed, blocked, stale, or follow-up-required states should remain actionable
- completed successful runs may appear briefly or be represented in Recent Operations, but should not clutter the dashboard
- **Acceptance criteria**:
- Tenant Dashboard shows a compact Active Operations summary when one or more tenant-scoped `OperationRun` values are queued, running, or require follow-up
- normal queued and running work is calm and non-obstructive
- failed, blocked, stale, or follow-up-required work receives clearer visual priority
- the summary card uses canonical `OperationRun` links
- `View operation` opens the relevant run detail
- `Show all operations` opens the canonical Operations view filtered to the current tenant or context where applicable
- the card respects RBAC and capabilities
- the card is hidden or replaced by an empty or calm state when there are no active or follow-up `OperationRun` values
- existing Tenant Dashboard KPI and action layout remains stable
- no duplicate or conflicting `OperationRun` truth source is introduced
- tests cover:
- no active operations state
- queued or running operation state
- stale or follow-up-required state
- canonical links
- permission and capability visibility
- tenant and workspace isolation
- **Priority note**: place this after Spec 268 terminal semantics and before the broader progress-contract and counted-progress rollout if dashboard feedback drift is visible in the product. Otherwise keep it as a small follow-up candidate after the core progress semantics specs.
- **Rationale**: this keeps Spec 268 narrow and avoids mixing global shell activity feedback with Tenant Dashboard information architecture. The Tenant Dashboard should stay calm and governance-first, but it still needs a trustworthy `OperationRun` signal so users do not lose confidence after starting tenant-scoped work.
##### 270 — OperationRun Progress Contract v1
- **Classification**:
- repo-verified foundation: `OperationRunService`, `summary_counts`, `SummaryCountsNormalizer`, `OperationSummaryKeys`, `ActiveRuns`
- gap: progress semantics are not yet formalized as a reusable contract
- maturity: enterprise foundation
- sellability impact: medium-high, because it improves execution-truth credibility
- **Problem**: progress rendering is currently mostly encoded in the activity feedback view and `summary_counts` conventions. Enterprise maturity requires a canonical product contract that defines when progress is allowed, what it means, and how each run type declares its progress capability.
- **Goal**: create a canonical `OperationRun` progress semantics layer so the UI never invents progress and future run types use one consistent contract.
- **Scope**:
- introduce or formalize a progress contract or presenter for `OperationRun`
- define progress capability categories:
- `none`: no progress representation
- `activity`: running or queued only, no measurable progress
- `counted`: trustworthy `processed` and `total` exists
- `phased`: true persisted phase or step state exists
- `composite`: parent run with child or fan-out progress
- determine render model:
- terminal outcome
- indeterminate activity
- determinate counted progress
- phase text
- composite or child summary
- keep `summary_counts.processed` and `summary_counts.total` as the only v1 determinate progress source
- do not derive percentage from status, duration, stale state, `succeeded`, `failed`, `skipped`, or lifecycle heuristics
- define how outcome counters relate to progress counters:
- `processed` = progress
- `succeeded`, `failed`, and `skipped` = outcome
- outcome counters must not silently replace `processed`
- add tests that prevent fake progress
- update UI standards and developer guidance
- **Out of scope**:
- broad rollout of new counts across all run types
- new database-heavy telemetry model unless strictly required
- AI summaries
- customer-facing review changes
- **Acceptance criteria**:
- one canonical helper or presenter decides progress display semantics
- the activity banner no longer owns progress semantics ad hoc
- tests prove:
- no fake percentage from running status
- no fake percentage from `succeeded`, `failed`, or `skipped`
- no progress line for terminal outcome
- determinate progress only from trustworthy `processed` and `total`
- phased and composite states do not masquerade as counted percentages
- docs describe the progress contract clearly
##### 271 — Counted Progress Rollout v1
- **Classification**:
- repo-verified foundation: `OperationRunService::incrementSummaryCounts()`, `OperationRunService::maybeCompleteBulkRun()`, backup and restore fan-out patterns, `summary_counts` whitelist
- gap: many run types are terminal-summary-only
- maturity: productization and operational maturity
- sellability impact: medium-high for MSP and operator trust
- **Problem**: many `OperationRun` types currently have lifecycle truth and terminal summaries, but not live counted progress. The shell can only show real progress when run writers persist trustworthy `processed` and `total` counts. Enterprise operators benefit from real progress on long-running or high-value jobs.
- **Goal**: add real counted progress to selected high-value run types where stable work units exist.
- **Prioritized v1 run types**:
1. inventory sync
2. review pack generation and export
3. evidence snapshot generation
4. baseline compare
5. restore and backup fan-out standardization where already partially present
- **Scope**:
- for each selected run type, identify stable work units
- set `summary_counts.total` before or at the start of measurable processing where possible
- increment `summary_counts.processed` as units complete
- keep `succeeded`, `failed`, and `skipped` as outcome counters
- do not use outcome counters as hidden progress substitutes unless explicitly mapped through the progress contract
- use existing `OperationRunService` helpers where possible
- add run-type-specific tests for count integrity
- ensure terminal outcomes still summarize final counts
- preserve workspace and tenant isolation
- **Out of scope**:
- forcing counts onto short or non-quantifiable jobs
- fake totals for API operations where the total is unknowable
- rewriting all `OperationRun` writers
- new UI redesign beyond consuming the progress contract
- **Acceptance criteria**:
- selected run types emit trustworthy `processed` and `total` counts during execution
- activity feedback shows determinate progress only for those run types when counts exist
- jobs with unknown totals still show indeterminate activity
- tests cover:
- `total` initialized correctly
- `processed` increments safely
- `processed` never exceeds `total`
- `failed`, `skipped`, and `succeeded` remain outcome counters
- terminal summary remains correct
- existing fan-out backup and restore progress behavior is standardized, not duplicated
##### 272 — OperationRun Phase & Composite Progress v1
- **Classification**:
- repo-verified foundation: `OperationRun` context, `OperationRunService`, `OperationUxPresenter`, child and fan-out patterns
- gap: no canonical persisted phase or composite progress contract
- maturity: optional enterprise hardening
- sellability impact: medium, especially for complex MSP operations
- **Problem**: some `OperationRun` values are not naturally countable but are still long-running or complex. For those, forcing `processed` and `total` creates fake precision. Enterprise UX needs a separate way to show real phase or composite progress without pretending it is percentage progress.
- **Goal**: introduce a truthful phase and composite progress model for long-running non-countable or orchestrated `OperationRun` values.
- **Scope**:
- define persisted phase or step metadata for selected run types:
- preparing
- fetching
- processing
- persisting
- finalizing
- define phase labels as user-safe operator copy, not raw technical internals
- add optional composite progress for parent and child `OperationRun` values:
- parent status
- child counts
- failed child summary
- next action
- UI shows phase text and optional child summary
- do not convert phases into fake percentages unless a real step contract exists and is explicitly approved
- add tests that phase and composite progress are not confused with counted progress
- **Candidate run types**:
- evidence snapshot generation
- tenant review compose
- review pack generation
- baseline compare and capture
- provider health and support diagnostics
- **Out of scope**:
- full workflow engine
- new top-level monitoring page
- customer-facing raw diagnostics
- AI-generated progress explanations
- **Acceptance criteria**:
- non-countable long-running jobs can expose true phase or status copy
- composite parent jobs can summarize child state without fake percentages
- activity feedback can render phase or composite state through the progress contract
- terminal outcome behavior remains separate from progress
- tests prove phase states do not render counted progressbars unless `processed` and `total` exist
- **Adjacent longer-horizon follow-up candidates (after the execution-truth queue)**:
1. **Host Widget Operation State Migration Pass**
- migrate run-state snippets in host widgets onto the shared inline component without flattening the widget's business role
2. **Polling & UI Calmness Standard for Operations**

View File

@ -1283,6 +1283,72 @@ # TenantPilot Enterprise UI Standards**Status:** Active **Owner:** Product / En
Examples:
CapabilityAllowed CopyAvoidReview Pack existsReview-Paket öffnenKunden-Workspace öffnenExport artifact existsExport-Artefakte anzeigenCustomer Portal öffnenEvidence existsNachweise anzeigenAudit abgeschlossenProvider permissions missingBerechtigungen öffnenFix automaticallyCustomer Review Workspace not implementedKundensichere Ausgabe nicht verfügbarOpen customer workspace
29.1 OperationRun Activity Feedback Pattern
The tenant-shell OperationRun activity hint is a bounded start-surface hint, not a second monitoring page.
It MUST:
derive rows from existing OperationRun truth only
show queued or running rows plus only the bounded terminal states that complete the same-shell feedback loop:
- just-completed successful runs that remain visible briefly
- unresolved terminal follow-up runs that were blocked, partial, failed, or automatically reconciled
render inside the TenantPilot application shell, directly below the topbar/context bar or inside the main content shell as a compact in-flow banner
stay in-flow and non-obstructive instead of floating over row actions or page CTAs
show at most three items in the shell
keep the visible shell order deterministic by using the existing shell ordering contract:
- active queued or running rows first
- unresolved terminal follow-up rows next
- recent terminal success rows only as the bounded close-the-loop state
use one grouped banner action area with a dominant action that reflects whether the banner is about one operation or multiple operation updates:
- when exactly one visible operation update is shown, use View operation to open that run detail
- when multiple visible operation updates are shown, use Review operations to open the canonical Operations collection instead of any single run detail
use Active operations as the header label only when all visible rows are active queued or running work. If any visible row is terminal, switch the header label to Operation updates.
keep a secondary action labeled Show all operations and one lifecycle-sensitive tertiary affordance for the current browser session:
- Hide activity for queued or running rows
- Dismiss or Close for terminal-success rows
- Dismiss for a single unresolved terminal follow-up row
- Dismiss updates when multiple unresolved terminal follow-up rows are visible and no active work remains
use Show all operations to open the canonical Operations collection with the current tenant encoded as the contextual tenant_id prefilter
keep overflow copy collective and calm, for example 19 more operation updates available in Operations.
show at most one concise next-step cue per item and leave diagnostics, logs, raw payloads, and failure detail to the canonical Operations pages
show determinate progress only when summary_counts.total and summary_counts.processed are real numeric values, clamp the progressbar to 0-100, and only show processed or percentage text when it is derived from those repo-real values
switch terminal-success rows to success-state copy instead of showing active progress after completion
keep hide or dismiss behavior browser-session-only and re-open the hint when a new run-enqueued event is accepted for the current tenant
It MUST NOT:
render as a detached document-level BODY_START banner above the TenantPilot application chrome
turn the shell hint into a permanent terminal inbox, tray, or second monitoring page
use raw route strings or non-canonical OperationRun links
show fake percentages, guessed completion, or fake progress
show active progress UI after a run is already terminal
persist hide or dismiss state in the database or on the OperationRun record
render as a fixed overlay that can cover findings row actions, table actions, or page-primary actions
30. Maintenance Rules
This document is a living standard.
Changes to this document SHOULD happen when:

View File

@ -0,0 +1,59 @@
# Specification Quality Checklist: OperationRun Activity Feedback v1
**Purpose**: Validate specification completeness and repo fit before implementation
**Created**: 2026-05-03
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The spec stays on one bounded manual-promotion slice over current `OperationRun` truth instead of widening into an activity center, notification rewrite, or acknowledgement workflow.
- [x] The package is product- and behavior-oriented and does not read like a low-level implementation diff.
- [x] The spec explicitly names the repo-real foundations it builds on: `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, the current tenant shell hint, and the verified overlap seam.
- [x] Mandatory repo sections for scope, RBAC, Ops-UX, testing, proportionality, and candidate rationale are completed.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain.
- [x] Requirements are testable and stay bounded to current `OperationRun` truth and the current global active-ops shell widget only.
- [x] The package explicitly requires canonical helper-generated `View operation` / `Show all operations` links instead of raw route strings.
- [x] The package explicitly requires the shell overflow route to open Operations with the selected tenant as the initial `tenant_id` contextual prefilter, and any restored local filters may only narrow within that tenant scope.
- [x] The package makes progressbar eligibility explicit and forbids fake percentages when deterministic counts are unavailable.
- [x] The package makes the guidance contract explicit: the shell may show at most one concise next-step cue when it materially clarifies inspection and must not expand into diagnostics prose.
- [x] The package explains what remains in scope versus what is intentionally deferred.
- [x] Acceptance scenarios cover shell feedback, active-only/honest-progress behavior, and browser-session collapse rules.
- [x] Edge cases cover no tenant context, no visibility, overflow ordering, terminal-run exclusion, and the non-obstructive shell contract.
## Candidate Selection Gate
- [x] The selected candidate exists in `docs/product/spec-candidates.md` and is consistent with the broader roadmap direction.
- [x] The active queue is explicitly empty, so this package records itself as a deliberate manual promotion rather than an automatic next-best-prep target.
- [x] No existing spec package already covers this bounded activity-feedback slice; the package narrows the candidate intentionally to the current global active-ops shell widget instead of re-opening unrelated dashboard productization work.
- [x] The chosen slice is smaller and more repo-ready than deferred alternatives because the repo already contains truthful Ops-UX helpers, current host surfaces, and verified overlap tests.
## Feature Readiness
- [x] The package reuses current `OperationRun` truth and current Ops-UX helpers instead of introducing a second lifecycle or persisted projection.
- [x] The package describes `OperationRun` ownership with tenant-bound operational semantics and preserves the existing nullable-tenant monitoring exception instead of reclassifying the model as workspace-owned.
- [x] The spec explicitly keeps collection/detail Monitoring pages diagnostics-first and keeps the shell decision-first and active-only.
- [x] The spec forbids new panel, provider, global-search, asset, queue, notification-policy, and persistence changes.
- [x] The plan and tasks keep hide/collapse browser-session-only and reject DB-backed acknowledgement semantics.
- [x] The package explicitly includes the durable UI guardrail by requiring an update to `docs/ui/tenantpilot-enterprise-ui-standards.md` in the same implementation slice.
- [x] The package names the inherited Operations page state-contract seam and its existing proof owner for tenant-prefilter precedence instead of treating that behavior as shell-local.
## Test Governance
- [x] Planned proof stays bounded to focused Feature coverage plus one named browser smoke for the overlap/non-obstruction contract.
- [x] No new heavy-governance family is introduced.
- [x] Runtime proof commands stay consistent across spec, plan, and tasks, and all commands run through Sail with the workspace PATH workaround.
## Notes
- Reviewed against `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `.specify/memory/constitution.md`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`, `apps/platform/app/Livewire/BulkOperationProgress.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, and the current Ops-UX test families on 2026-05-03.
- No application implementation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Outcome**: `keep`
- **Reason**: The queue is intentionally empty, but this manual promotion is tightly bounded, repo-real, and smaller than the full candidate group because it limits v1 to the current shell hint and one durable standards update while staying inside the Ops-UX 3-surface contract.
- **Workflow result**: Ready for implementation.

View File

@ -0,0 +1,257 @@
# Implementation Plan: OperationRun Activity Feedback v1
**Branch**: `268-operationrun-activity-feedback` | **Date**: 2026-05-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/268-operationrun-activity-feedback/spec.md`
## Summary
This plan prepares one bounded Ops-UX slice over the repo's existing `OperationRun` truth. The implementation path is to refactor the current tenant-shell activity widget so it becomes non-obstructive, honest about active progress, and capable of showing a brief terminal-success or unresolved terminal follow-up handoff in the same surface, then record the durable shell guardrail in `docs/ui/tenantpilot-enterprise-ui-standards.md`. The slice must stay on current `OperationRun` truth, current start toasts, current Operations drill-through pages, and current badge/link helpers with no new persistence, no new lifecycle, no new notification policy, no new active-awareness host surface, and no tray/inbox expansion.
Filament remains on Livewire v4, no panel-provider registration changes are required (`apps/platform/bootstrap/providers.php` remains authoritative), no globally searchable resource is added, and no asset registration change is expected.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- `OperationRun` already provides the execution-truth model and current tenant/workspace scoping.
- `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, and `OperationRunUrl` already provide truthful run guidance, badge semantics, and canonical links.
- `BulkOperationProgress` plus the current poller already provide tenant-shell activity surfacing, albeit in an obstructive floating form.
- `OperationRunLinkContractGuardTest` and the current Ops-UX Feature tests already enforce parts of the link and host-surface contract.
### Explicit delta in this plan
- narrow the current `BulkOperationProgress` shell contract to one bounded activity surface and end its obstructive overlay behavior
- keep progress treatment honest and constitution-safe, including no fake active progress after terminal transition
- add a brief terminal-success handoff and unresolved terminal follow-up visibility on the same surface
- add browser-session hide/dismiss/acknowledge behavior without introducing persistence
- update `docs/ui/tenantpilot-enterprise-ui-standards.md` with the durable shell activity-feedback guardrail
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: current Ops-UX support classes, native Filament widgets/Blade, current badge infrastructure, Pest v4
**Storage**: PostgreSQL via existing `operation_runs`; browser-session state only for allowed collapse behavior; no new DB storage
**Testing**: Pest Feature coverage plus one required browser smoke
**Validation Lanes**: fast-feedback, confidence, browser
**Target Platform**: existing Laravel monolith in `apps/platform`, admin/operator plane only
**Project Type**: Web application (Laravel monolith with Filament)
**Performance Goals**: bounded shell/start-surface rendering, no new query families beyond current run reads, no duplicate polling loops, and max-three item presentation on the shell host
**Constraints**: no new persistence, no new lifecycle/state family, no new notification policy, no tray v2, and no panel/provider/asset changes
**Scale/Scope**: one current host surface, one standards update, one browser-session calmness rule
## Likely Affected Repo Surfaces
- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
- `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`
- `apps/platform/app/Support/OpsUx/OperationRunUrl.php`
- `apps/platform/app/Support/OperationRunLinks.php`
- `apps/platform/app/Support/OpsUx/ActiveRuns.php`
- `apps/platform/app/Livewire/BulkOperationProgress.php`
- `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`
- `apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php`
- `apps/platform/app/Providers/Filament/TenantPanelProvider.php`
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` (inherited tenant-prefilter precedence seam)
- `apps/platform/public/js/tenantpilot/ops-ux-progress-widget-poller.js`
- `apps/platform/tests/Feature/OpsUx/...`
- `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`
- `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php` (inherited state-contract coverage)
- `apps/platform/tests/Browser/OpsUx/...`
- `docs/ui/tenantpilot-enterprise-ui-standards.md`
## UI / Filament & Livewire Fit
- Keep the changed shell host Filament-native. It may remain a Livewire shell entrypoint, but the refactor should stay local to the existing shell component and views instead of introducing a second host surface.
- The shell host stays decision-first. It shows at most three visible items, one dominant `View operation` action per item, one canonical overflow path, and only the minimum run state needed for the next operator decision.
- Monitoring collection/detail pages remain diagnostics-first drill-through targets. The shell must not re-state their evidence-heavy content.
- Browser-session hide/dismiss/acknowledge stays local to the current browser session and does not create a server-side acknowledgement model.
- Keep custom markup narrow and standards-aligned. Avoid page-local progress bar colors, host-specific card systems, fake percentages, or percentage text.
- No new asset registration, panel configuration, or provider registration change is planned.
## RBAC / Policy Fit
- Existing `OperationRun` policies remain the first and only visibility gate.
- The tenant shell host derives rows only after tenant context and policy filtering.
- The shell overflow path must preserve the selected tenant as the initial `tenant_id` contextual prefilter on the canonical Operations page; restored session filters may narrow inside that tenant scope but must not broaden beyond it.
- Tenant/admin plane behavior stays unchanged: no cross-plane expansion and no new authorization surface.
- No new mutation or retry action is introduced, so current confirmation/authorization behavior stays on existing start surfaces and run detail pages.
## Audit / Logging Fit
- Existing queued toasts and terminal DB notifications remain authoritative and unchanged.
- Existing run audit/Monitoring behavior remains the only audit trail. No new page-view audit stream is introduced.
- Because the slice changes presentation only, `OperationRun.status` / `OperationRun.outcome` ownership remains service-owned and unchanged.
## Data & Query Fit
- The shell host continues to derive rows from current-tenant `OperationRun` truth, limits visible presentation to three items, and uses overflow navigation for the rest.
- Determinate progress must derive from truthful run-owned counts only while the run is active. If a run becomes terminal, the shell switches to terminal-success or terminal-follow-up semantics instead of reusing the active progress UI.
- Hide, dismiss, and acknowledge state remain browser-session scoped and must not be persisted in the database or on the run record.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament plus a bounded local shell refactor
- **Shared-family relevance**: Ops UX start feedback, canonical run links
- **State layers in scope**: shell, page, browser-session
- **Audience modes in scope**: operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first on the shell host, diagnostics-first on Operations collection/detail
- **Raw/support gating plan**: raw/support evidence stays on the existing diagnostics surfaces only
- **One-primary-action / duplicate-truth control**: each shell item keeps one dominant `View operation` action, overflow navigation stays collection-only, the tertiary affordance is lifecycle-sensitive, and the shell does not duplicate detail-surface diagnostics
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: global-context-shell
- **Required tests**: functional-core, state-contract, named-browser-smoke
- **Exception path and spread control**: none planned; any attempt to add host-specific run-state cards or persisted hide semantics resolves as `reject-or-split`
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: current shell hint, canonical run links, current run guidance, current activity poller, UI standards
- **Shared abstractions reused**: `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, `OperationRunUrl`, `ActiveRuns`, `OpsUxBrowserEvents`, current badge infrastructure
- **New abstraction introduced? why?**: none planned by default; keep the change local to the existing shell component unless a tiny helper is strictly needed for readability
- **Why the existing abstraction was sufficient or insufficient**: the repo already owns truthful state and links; the missing piece is a constitution-aligned shell contract, not a new framework
- **Bounded deviation / spread control**: do not create a multi-profile registry, strategy system, or separate persisted activity model
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: yes, for start-surface follow-through, terminal handoff, and drill-through links only
- **Central contract reused**: current Ops-UX start contract via `OperationUxPresenter`, `OperationRunLinks`, `OperationRunUrl`, and `OpsUxBrowserEvents`
- **Delegated UX behaviors**: queued toast wording, canonical view/collection links, current browser-event dispatch, and existing terminal DB notifications remain delegated to the shared contract
- **Surface-owned behavior kept local**: bounded shell layout, brief terminal-success/follow-up visibility, and browser-session hide/dismiss/acknowledge only
- **Queued DB-notification policy**: `N/A` - unchanged
- **Terminal notification path**: unchanged central lifecycle mechanism
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing `OperationRun` truth, links, and operator vocabulary only
- **Neutral platform terms / contracts preserved**: `Operation`, `View operation`, `Show all operations`, `queued`, `running`, `active`
- **Retained provider-specific semantics and why**: none
- **Bounded extraction or follow-up path**: none
## Constitution Check
*GATE: Must pass before implementation begins and again before merge.*
- Inventory-first: PASS. The slice is fully derived from existing `OperationRun` truth.
- Read/write separation: PASS. No new write path or retry surface is introduced.
- Graph contract path: PASS. No Graph/provider interaction is added.
- Deterministic capabilities: PASS. Existing `OperationRun` policies remain authoritative.
- RBAC-UX: PASS. No plane expansion; tenant/admin visibility stays on current guards and deny-as-not-found semantics.
- Run observability: PASS. Existing start contract, terminal notifications, and Monitoring ownership remain unchanged while the shell adds only a bounded terminal handoff.
- Ops-UX lifecycle: PASS. No change to service-owned status/outcome transitions or `summary_counts` semantics.
- Data minimization: PASS. Hosts stay compact and do not surface raw evidence by default.
- Test governance: PASS. Proof stays bounded to Feature plus one named browser smoke for the overlap contract.
- Proportionality / no premature abstraction: PASS. The default implementation stays local to the existing shell host and standards doc.
- Persisted truth / behavioral state: PASS. No new table, no new lifecycle, no new persisted hide/dismiss/acknowledge state.
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing helpers and badge semantics stay central, and the shell host moves closer to the existing Ops-UX contract.
- Provider boundary: PASS. No provider/platform seam changes.
- Filament/Laravel panel safety: PASS. Filament v5 stays on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, and no new assets are planned.
**Gate evaluation**: PASS.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for shell behavior; one required browser smoke for the overlap/non-obstruction contract
- **Affected validation lanes**: fast-feedback, confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: Feature coverage proves real tenant context, active plus bounded terminal visibility, links, lifecycle-sensitive tertiary copy, and progress rules on the current shell host; browser proof is reserved for the one contract that depends on real clickability/layout
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/OpsUx/OperationActivityFeedbackSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate; reuse current operation-run factories and tenant helpers instead of introducing new provider-heavy defaults or persistence fixtures
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `global-context-shell`
- **Closing validation and reviewer handoff**: reviewers should rerun the focused commands above, then manually verify that the shell hint no longer covers row-level actions on a page with active runs. They should also review the standards-doc update for canonical link/progress rules.
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local increase
- **Review-stop questions**: did the shell stay bounded, did terminal success/follow-up remain truthful, did it remain non-obstructive, did any raw route strings reappear, and did hide/dismiss/acknowledge stay browser-session-only?
- **Escalation path**: `reject-or-split` for any persisted hide model, activity center expansion, or notification-policy change
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: this package already carries the standards-doc guardrail for the chosen v1 slice; larger tray/lifecycle topics remain explicit follow-up specs rather than hidden work here.
## Project Structure
### Documentation (this feature)
```text
specs/268-operationrun-activity-feedback/
├── spec.md
├── plan.md
├── tasks.md
└── checklists/
└── requirements.md
```
This preparation package intentionally stays on the core artifacts plus the readiness checklist. The repo already contains the relevant Ops-UX truth, current host surfaces, and adjacent tests, so no extra research, data-model, or contract package is required for a bounded implementation handoff.
### Source Code (expected implementation surfaces)
```text
apps/platform/app/Livewire/BulkOperationProgress.php
apps/platform/resources/views/livewire/bulk-operation-progress.blade.php
apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php
apps/platform/app/Providers/Filament/TenantPanelProvider.php
apps/platform/public/js/tenantpilot/ops-ux-progress-widget-poller.js
apps/platform/tests/Feature/OpsUx/...
apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php
apps/platform/tests/Browser/OpsUx/...
docs/ui/tenantpilot-enterprise-ui-standards.md
```
**Structure Decision**: keep the implementation local to the existing Ops-UX Livewire shell seams. Do not introduce a new multi-host activity framework in this slice.
## Data / Migration Implications
- No migration or new table is planned.
- No new persisted user preference is allowed.
- No new cache layer, backfill, or asset/deploy step should be required for v1.
## Rollout Considerations
- Filament remains v5 on Livewire v4. No panel-provider change is required, and provider registration remains in `apps/platform/bootstrap/providers.php`.
- No global search change is required because the slice changes widgets/shell feedback, not resources.
- No destructive action is added. Existing start/retry surfaces remain the only mutation owners.
- No new asset registration is expected.
## Risk Controls
- Reject any implementation that introduces a new `OperationRun` lifecycle, persisted hide model, or persistent acknowledgement workflow.
- Reject any implementation that widens the slice into tray v2, notification policy changes, or any new host-surface migration beyond the current global active-ops shell widget.
- Reject any implementation that keeps or reintroduces a fixed overlay that covers page actions.
- Reject any implementation that uses fake progress percentages, percentage text, terminal runs in the shell, or raw route strings.
## Implementation Phases
### Phase 0 - Confirm Current Ops-UX Truth
- Verify the current shell host, current link helpers, and current overlap/regression tests.
### Phase 1 - Encode The Shell Activity Contract
- Keep the shell query and rendering derived from current `OperationRun` truth only.
- Keep active, terminal-success, and terminal-follow-up semantics bounded to the same shell host.
### Phase 2 - Migrate The Shell Hint
- End the obstructive floating-overlay behavior while keeping bounded polling.
### Phase 3 - Record The Guardrail And Calmness Rules
- Update the UI standards doc with canonical link, bounded terminal-success/follow-up scope, progress, and hide/dismiss/acknowledge rules.
### Phase 4 - Harden Browser-Session Calmness And Validation
- Add browser-session hide/dismiss/acknowledge only for allowed informational items.
- Run the focused test suite and browser smoke.
## Proportionality Review
- **Current operator problem**: the repo has truthful run-state helpers, but the current shell hint is obstructive and does not encode the active-only, honest-progress contract clearly.
- **Existing structure is insufficient because**: the current shell implementation leaves key Ops-UX rules implicit and therefore easy to regress even if the underlying run truth is correct.
- **Narrowest correct implementation**: refactor the existing shell component and views locally, keep the query active-only, add browser-session calmness, and update the standards doc.
- **Ownership cost created**: one browser-session calmness rule and focused test/doc updates.
- **Alternative intentionally rejected**: a shell-only CSS adjustment was rejected because it would not encode the active-only and honest-progress rules or leave a durable guardrail for future work.
- **Release truth**: current-release truth. The repo already owns the active-ops widget and the Ops-UX constitution; this slice aligns the current shell to that truth.

View File

@ -0,0 +1,250 @@
# Feature Specification: OperationRun Activity Feedback v1
**Feature Branch**: `268-operationrun-activity-feedback`
**Created**: 2026-05-03
**Status**: Ready for implementation
**Input**: Manual promotion from `docs/product/spec-candidates.md` after the 2026-05-03 repo-based next-best-prep re-audit.
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: `OperationRun` is already the repo-real execution truth, but the current tenant-shell activity hint drops the feedback loop as soon as work becomes terminal, so operators can see work running and then lose the confirmation/follow-up state immediately.
- **Today's failure**: Operators can queue work and still get feedback that can cover primary actions. Once a run finishes, the current `BulkOperationProgress` shell widget disappears immediately and does not provide a short terminal-success confirmation or an explicit unresolved follow-up state in the same surface.
- **User-visible improvement**: Operators get one calm, truthful activity hint on tenant-scoped start surfaces, with at most three visible items, canonical `View operation` links, honest progress treatment, a brief terminal-success confirmation, and unresolved terminal follow-up states that do not disappear silently.
- **Smallest enterprise-capable version**: refactor the current tenant-shell activity hint to align with the existing Ops-UX 3-surface contract, add a bounded terminal-success/follow-up phase to the same surface, and update the UI standards so future hosts do not drift.
- **Explicit non-goals**: no activity tray v2, no DB-backed hide preferences, no new `OperationRun` types or status families, no notification lifecycle rewrite, no permanent terminal inbox, and no host-widget migration beyond the current global activity widget.
- **Permanent complexity imported**: no new persisted model; one browser-session rule set for active hide plus terminal dismiss/acknowledge affordances, focused Pest/browser coverage, one standards-document addition, and only a tiny local helper if the existing shell component cannot stay readable without it.
- **Why now**: the overlap bug is already verified, and the constitution already defines a non-negotiable three-surface contract for `OperationRun`. The shell hint needs to align before more local run snippets drift from that contract.
- **Why not local**: a pure CSS tweak would not encode the terminal feedback-loop, honest-progress, canonical-link, and calmness rules in either the implementation or the standards guidance.
- **Approval class**: Workflow Compression
- **Red flags triggered**: shared interaction family, shell-level surface, session-local UI state
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**:
- `/admin/t/{tenant}/...` tenant-scoped start surfaces that currently receive the shared activity hint from the tenant-panel shell
- `/admin/operations` and `/admin/operations/{run}` remain the diagnostics drill-through targets and canonical collection/detail routes; shell overflow opens `/admin/operations` with the selected tenant as a contextual `tenant_id` prefilter, and that query-param prefilter wins over session/default state on initial mount
- **Data Ownership**: existing `OperationRun` truth with tenant-bound operational ownership semantics enforced through workspace + tenant entitlement checks; the current nullable-tenant exception remains limited to canonical workspace-context monitoring views; no new persisted projection or preference store
- **RBAC**: existing `OperationRun` policies remain authoritative. Non-members or out-of-scope tenant contexts remain hidden (`404` semantics through the current tenant/admin boundaries); in-scope actors only see hints for runs they can already view.
## Cross-Cutting / Shared Pattern Reuse *(mandatory)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, navigation links, shell active-awareness hints
- **Systems touched**: `BulkOperationProgress`, tenant-panel render-hook shell feedback, canonical run links, current queued toasts, current activity poller, UI standards
- **Existing pattern(s) to extend**: `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, `OperationRunUrl`, `ActiveRuns`, `OpsUxBrowserEvents`, current active-ops shell widget
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\OperationStatusNormalizer`, `App\Support\OperationRunLinks`, `App\Support\OpsUx\OperationRunUrl`, `App\Support\OpsUx\ActiveRuns`, `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`
- **Why the existing shared path is sufficient or insufficient**: the repo already has truthful run-state, guidance, badge, and link semantics. What is missing is a constitution-aligned implementation of the shell contract: honest progress while active, a bounded terminal-success/follow-up handoff, canonical links, and non-obstructive placement.
- **Allowed deviation and why**: none by default. Keep the refactor local to the current shell component and its views; only extract a tiny helper if the existing component cannot remain readable otherwise.
- **Consistency impact**: `View operation` wording, overflow/`Show all operations` affordances, progressbar eligibility, lifecycle-sensitive tertiary action labels, bounded terminal success/follow-up visibility, and browser-session hide/dismiss/acknowledge behavior must stay aligned with the Ops-UX constitution and the standards doc.
- **Review focus**: verify that the shell keeps queued/running truth, shows only bounded terminal-success/follow-up states, never shows fake progress after completion, introduces no raw route strings, and remains non-obstructive.
## OperationRun UX Impact *(mandatory)*
- **Touches OperationRun start/completion/link UX?**: yes
- **Shared OperationRun UX contract/layer reused**: current Ops-UX start contract through `OperationUxPresenter`, `OperationRunLinks`, `OperationRunUrl`, `OpsUxBrowserEvents`, and current DB-notification lifecycle
- **Delegated start/completion UX behaviors**: current queued toast wording, canonical `View operation` link generation, tenant-safe URL resolution, current `run-enqueued` browser event, and existing terminal DB-notification lifecycle all remain delegated to the shared path
- **Local surface-owned behavior that remains**: the shell surface owns bounded layout, recent terminal-success/follow-up visibility, and browser-session hide/dismiss/acknowledge affordances only; it does not own lifecycle truth, severity mapping, or run-link routing
- **Queued DB-notification policy**: `N/A` - unchanged, explicit opt-in policy remains as-is
- **Terminal notification path**: unchanged central lifecycle mechanism
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory)*
- **Shared provider/platform boundary touched?**: no
- **Boundary classification**: `N/A`
- **Seams affected**: `N/A`
- **Neutral platform terms preserved or introduced**: `Operation`, `View operation`, `Show all operations`, `queued`, `running`, `active`
- **Provider-specific semantics retained and why**: none
- **Why this does not deepen provider coupling accidentally**: the feature only reshapes execution feedback over existing platform-owned `OperationRun` truth and current link helpers
- **Follow-up path**: none
## UI / Surface Guardrail Impact *(mandatory)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Tenant shell activity hint | yes | Native Filament + existing Livewire/Blade surface | Ops UX start feedback, terminal handoff, canonical run links | shell, page, browser-session | no | Must stop covering primary actions on row-action-heavy pages and must keep terminal success/follow-up bounded |
## Decision-First Surface Role *(mandatory)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Tenant shell activity hint | Primary Decision Surface | Decide whether an active or just-finished operation needs immediate inspection or no further action | Operation label, lifecycle state, one next-step cue, `View operation`, progress only when valid, and brief terminal confirmation/follow-up | Full run detail, logs, evidence, diagnostics stay in Operations pages | Primary because it appears immediately after a start action and must support the next operator decision without opening Monitoring first | Follows start-surface workflow, not storage structure | Replaces a floating overlay and closes the feedback loop without turning the shell into a monitoring page |
## Audience-Aware Disclosure *(mandatory)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Tenant shell activity hint | operator-MSP | Operation label, queued/running state, recent terminal-success/follow-up state, elapsed or completion age, canonical open link, honest progress if valid | One concise guidance line only when it clarifies the next step | Raw payloads, failure summaries, logs, debug context | `View operation` | Raw/support detail stays in Operations detail; browser-session hide/dismiss/acknowledge remains local to the shell | The hint states only what is needed for the next decision and does not restate diagnostics-first detail prose |
## UI/UX Surface Classification *(mandatory)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant shell activity hint | Monitoring hint | Activity shell hint | Open the most relevant active or just-finished operation | One explicit `View operation` link per item | forbidden | Overflow navigation only (`Show all operations`) plus one lifecycle-sensitive tertiary affordance on the grouped action row | none | `/admin/operations?tenant_id={currentTenant}` as a contextual prefilter on initial mount | `/admin/operations/{run}` with current tenant context | Current tenant context from the selected tenant shell | Operations / Operation | Queued/running state, completion/follow-up state, elapsed/completion age, valid progress only while active | none |
## Operator Surface Contract *(mandatory)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant shell activity hint | Tenant operator | Decide whether a queued/running or just-completed operation needs inspection, acknowledgement, or no action | Start-surface hint | What operation state do I need to react to right now? | Operation label, lifecycle state, elapsed/completion age, canonical open link, valid progress only while active | Detailed run diagnostics and evidence on Operations pages | lifecycle, progress-availability | none | `View operation`, `Show all operations`, lifecycle-sensitive tertiary affordance when needed | none |
**UI Action Matrix**: `N/A - no Filament Resource, RelationManager, or Page action surface is being introduced or reclassified. The changed surface remains a shell/widget hint with one dominant navigation action and no destructive controls.`
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no by default
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: queued/running operations do not have one calm, constitution-aligned shell contract today, and the current overlay both blocks primary actions and drops the completion/follow-up loop immediately.
- **Existing structure is insufficient because**: the repo already centralizes links, badges, and guidance semantics, but the current shell implementation does not enforce bounded terminal handoff, honest post-completion semantics, or non-obstructive placement clearly.
- **Narrowest correct implementation**: refactor the existing shell component and views locally, keep `OperationRun` truth authoritative, add a brief terminal-success/follow-up phase plus browser-session hide/dismiss/acknowledge behavior, and update the standards doc.
- **Ownership cost**: one browser-session state rule set, focused Feature/browser coverage, and a standards-doc update.
- **Alternative intentionally rejected**: a shell-only CSS adjustment was rejected because it would not encode the active-only, honest-progress, and canonical-link rules or leave a durable guardrail for future hosts.
- **Release truth**: current-release truth. The repo already owns the active-ops widget and the Ops-UX constitution; this slice aligns the current shell to that truth.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory)*
- **Test purpose / classification**: Feature plus one required browser smoke for the non-obstructive shell contract
- **Validation lane(s)**: fast-feedback, confidence, browser
- **Why this classification and these lanes are sufficient**: the real contract is shell behavior on the existing Livewire/Filament host. Feature tests prove active plus bounded terminal selection, links, progress rules, and lifecycle-sensitive tertiary copy; one browser smoke is the narrowest credible proof that the shell remains non-obstructive.
- **New or expanded test families**: extend `apps/platform/tests/Feature/OpsUx/`, extend `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, and add one named browser smoke
- **Fixture / helper cost impact**: low to moderate. Reuse existing operation-run factories, tenant context helpers, and current Livewire widget tests; do not add provider-heavy browser setup or new default seeds.
- **Heavy-family visibility / justification**: no heavy-governance lane. Browser proof is explicit and limited to the overlap/non-obstruction contract.
- **Special surface test profile**: global-context-shell
- **Standard-native relief or required special coverage**: standard Feature coverage is primary; one browser smoke is required for the row-action overlap contract
- **Reviewer handoff**: reviewers must confirm the shell routes through canonical link helpers, shows no fake percentage or percentage text, keeps successful completion visible briefly, does not silently drop follow-up failures, and passes the named browser smoke.
- **Budget / baseline / trend impact**: small feature-local increase only
- **Escalation needed**: `reject-or-split` if implementation expands into tray v2, notification lifecycle changes, or a new persisted hide/acknowledgement model
- **Active feature PR close-out entry**: Guardrail / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/OpsUx/OperationActivityFeedbackSmokeTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Calm Active-Operations Feedback After Starting Work (Priority: P1)
As a tenant operator, I need the shell-level active-ops hint to surface the most relevant queued/running operations without covering page actions, so I can decide whether to inspect a run immediately or keep working.
**Why this priority**: this is the direct fix for the known overlap seam and the most valuable current-release improvement.
**Independent Test**: create queued/running runs for a tenant, open a tenant-scoped start surface, and verify the shell hint shows at most three active items, preserves one canonical `View operation` link per item, and remains non-obstructive.
**Acceptance Scenarios**:
1. **Given** a tenant has queued and running runs, **When** the operator opens a tenant-scoped start surface, **Then** the shell hint shows at most three active items with one canonical `View operation` link each and an overflow path to `Show all operations`.
2. **Given** a row-action-heavy page is visible while the shell hint is active, **When** the operator targets a primary page action, **Then** the activity hint does not cover or block that action.
3. **Given** an active item needs brief context for the next decision, **When** the shell hint renders it, **Then** it may show at most one concise next-step cue and does not expand into diagnostics prose.
---
### User Story 2 - Keep The Feedback Loop Honest Across Terminal Transitions (Priority: P1)
As a tenant operator, I need the shell hint to stay honest when work becomes terminal, so I see a brief success confirmation or an unresolved follow-up state in the same surface instead of losing the feedback loop immediately.
**Why this priority**: the overlap fix is not sufficient unless the shell also completes the run-state feedback loop without inventing a second lifecycle surface.
**Independent Test**: seed queued/running runs alongside just-completed successful runs and terminal follow-up runs, open a tenant-scoped start surface, and verify the shell keeps recent success visible briefly, keeps unresolved follow-up visible, and never shows fake progress after completion.
**Acceptance Scenarios**:
1. **Given** a queued/running run transitions to successful completion, **When** the shell hint refreshes shortly afterward, **Then** it keeps that run visible briefly with a terminal-success state, `View operation`, `Show all operations`, and a `Dismiss` or `Close` tertiary affordance.
2. **Given** a queued/running run transitions to blocked, partial, failed, or reconciled terminal follow-up, **When** the shell hint refreshes, **Then** it keeps that follow-up visible instead of dropping it silently and uses a lifecycle-appropriate tertiary affordance such as `Acknowledge`.
3. **Given** a run exposes deterministic progress counts while active, **When** the shell hint renders that run, **Then** it may show clamped determinate progress, and **When** the run becomes terminal, **Then** it switches to terminal state copy instead of showing fake progress after completion.
---
### User Story 3 - Keep Hide / Dismiss / Acknowledge Browser-Session Only (Priority: P1)
As a tenant operator, I need queued/running, successful, and unresolved terminal hints to use the right local-only affordance for the current browser session, so the activity surface stays calm without inventing a server-side acknowledgement system.
**Why this priority**: it is the smallest safe calmness control that keeps lifecycle semantics explicit while staying within the existing Ops-UX contract.
**Independent Test**: open a tenant-scoped start surface with active and terminal items, use the lifecycle-sensitive tertiary affordances, and verify active items use `Hide activity`, successful terminal items use `Dismiss`/`Close`, unresolved terminal items use `Acknowledge`, and all of those controls remain browser-session-only.
**Acceptance Scenarios**:
1. **Given** queued/running items are visible, **When** the operator uses the tertiary affordance, **Then** it is labeled `Hide activity` and the current browser session remembers that choice without creating any database-backed preference.
2. **Given** only recent successful terminal items are visible, **When** the operator uses the tertiary affordance, **Then** it is labeled `Dismiss` or `Close` and the shell hint may also auto-dismiss after the short terminal-success grace window.
3. **Given** only unresolved terminal follow-up items are visible, **When** the operator uses the tertiary affordance, **Then** it is labeled `Acknowledge` and remains browser-session-only instead of persisting on the `OperationRun` record.
4. **Given** the shell hint is hidden and a new run is accepted in the current browser session, **When** the shared `run-enqueued` event fires, **Then** the shell hint reappears so the operator does not miss newly started work.
### Edge Cases
- No selected tenant or no `viewAny OperationRun` capability means the shell hint stays inert and does not poll or leak rows.
- More than three qualifying shell-visible rows must produce deterministic ordering and an overflow path that routes through the canonical collection helper.
- Just-completed successful runs remain visible only for the short terminal-success grace window and never keep an active progressbar after completion.
- Terminal follow-up runs (`blocked`, `partial`, `failed`, reconciled terminal failures) must not auto-disappear silently from the shell immediately after transition, even though the canonical dashboard/Operations follow-up surfaces remain authoritative.
- The shell hint must not introduce raw route strings; all run detail/collection links continue to flow through the canonical helper family.
- Browser-session hide, dismiss, or acknowledge state must not persist across browser sessions or mutate the `OperationRun` record.
## Requirements *(mandatory)*
**Constitution alignment summary**: This feature adds no Graph calls, no new write path, no new `OperationRun` lifecycle state, and no new persistence. It reuses the existing Ops-UX start contract, keeps `OperationRun.status` / `OperationRun.outcome` service-owned, leaves terminal DB notifications unchanged, and narrows the current global active-ops shell widget to the constitution-safe active-awareness contract.
### Functional Requirements
- **FR-001**: The implementation MUST derive shell activity items from existing `OperationRun` truth plus current shared helpers such as `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, and badge semantics. It MUST NOT create a second lifecycle, severity model, or persisted projection.
- **FR-002**: The tenant shell activity hint MUST always surface current active runs (`queued|running`) for the current tenant from existing `OperationRun` truth.
- **FR-003**: When a queued/running run transitions to successful completion, the shell hint MUST keep a compact terminal-success state visible briefly in the same surface instead of removing it immediately.
- **FR-004**: The terminal-success state MUST show the operation label, a success pill such as `Completed successfully`, a completion-recency line, concise no-action-needed guidance, canonical `View operation` and `Show all operations` links, and a tertiary `Dismiss` or `Close` affordance. It MUST NOT show fake active progress after completion.
- **FR-005**: Blocked, partial, failed, and reconciled terminal follow-up runs MUST NOT auto-disappear silently from the shell immediately after transition. The shell hint MUST keep them visible on the existing surface until they are acknowledged or otherwise handled through the canonical follow-up surfaces.
- **FR-006**: The tenant shell activity hint MUST show at most three prioritized items for the current tenant and MUST expose one canonical `View operation` link per item plus one canonical overflow path to `Show all operations` when more qualifying items exist. The overflow path MUST open the canonical Operations collection with the selected tenant encoded as the contextual `tenant_id` prefilter; on initial mount that requested tenant prefilter takes precedence over session/default state, and any restored local filters may only narrow within that tenant scope rather than broaden it.
- **FR-007**: The tenant shell activity hint MUST stop behaving as a fixed overlay that covers primary actions. Non-obstructive placement is part of the contract, not an optional polish step.
- **FR-008**: Determinate progress MUST appear only when `summary_counts.total` and `summary_counts.processed` are valid numeric values while the run is active. Determinate progress MUST be clamped to `0-100`, and the shell hint MUST NOT display fake active progress after terminal transition.
- **FR-009**: The lifecycle-sensitive tertiary affordance MUST use state-matching wording: `Hide activity` for queued/running shell hints, `Dismiss` or `Close` for terminal-success hints, and `Acknowledge` for unresolved terminal follow-up hints.
- **FR-010**: Browser-session hide, dismiss, and acknowledge behavior MUST stay browser-session-only. A newly accepted run in the current session MUST re-open the shell hint.
- **FR-011**: The implementation MUST update `docs/ui/tenantpilot-enterprise-ui-standards.md` with the shell activity-feedback pattern, including canonical links, bounded terminal-success/follow-up scope, progressbar eligibility, item limits, lifecycle-sensitive tertiary actions, hide/dismiss/acknowledge boundaries, and anti-patterns such as fake percentages or overlays that cover actions.
- **FR-012**: Operations collection/detail pages remain diagnostics-first drill-through targets. The shell hint MAY show at most one concise next-step cue when it materially clarifies whether inspection is needed, and it MUST NOT duplicate full diagnostics, logs, raw payloads, or support-only evidence.
### Authorization and Safety Requirements
- **AR-001**: Tenant/admin-plane authorization semantics stay unchanged: out-of-scope tenant access remains deny-as-not-found (`404` semantics through current tenant/admin boundaries), while in-scope visibility continues to reuse server-side `OperationRun` policies.
- **AR-002**: No surface in this slice may emit a run row or link the actor cannot already view through the current canonical Operations routes.
- **AR-003**: No destructive or mutating action is introduced. Existing run start surfaces and existing detail pages remain responsible for initiation/retry behavior and any confirmation requirements they already own.
### Non-Functional Requirements
- **NFR-001**: The slice MUST stay Filament-native and Livewire v4-compatible. No panel-provider registration change is allowed; `apps/platform/bootstrap/providers.php` remains authoritative.
- **NFR-002**: No new panel, no new globally searchable resource, and no new asset registration strategy are allowed.
- **NFR-003**: Polling must remain intentional and bounded. The shell hint may continue to use the current poller family, but the implementation must not add parallel uncoordinated polling loops or additional active-awareness hosts in this slice.
- **NFR-004**: Badge semantics remain centralized through existing badge infrastructure; no host surface may add its own status/outcome color mapping.
## Deferred Follow-Ups / Explicit Non-Goals
- Host-widget migration pass beyond the current global shell activity widget
- Polling and UI calmness standard beyond the v1 scope
- Notification/activity lifecycle standardization beyond the current start contract
- Any permanent inbox or tray treatment for terminal/follow-up operation states beyond the bounded shell grace/follow-up contract
- Activity tray / center v2
- Persistent reviewed / investigated semantics over failed or blocked runs
## Key Entities
- **Shell activity item**: a derived, non-persisted summary of one visible `OperationRun` in the shell, including operation label, lifecycle state, canonical detail/collection links, and progress treatment only when truthful.
- **Terminal success shell item**: a derived, non-persisted shell item for a just-completed successful run that remains visible briefly to close the feedback loop and confirm that no further action is needed.
- **Terminal follow-up shell item**: a derived, non-persisted shell item for a blocked, partial, failed, or reconciled terminal run that remains visible on the shell until the operator acknowledges it locally or handles it on the canonical follow-up surfaces.
- **Browser-session shell state**: a non-persisted shell-level state that remembers hide, dismiss, and acknowledge choices only for the current browser session.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In the focused validation fixture with more than three qualifying tenant rows, the shell activity hint shows no more than three visible items plus exactly one overflow path to `Show all operations`.
- **SC-002**: On the validated row-action-heavy smoke surface, primary page actions remain clickable while the shell activity hint is visible.
- **SC-003**: In validation coverage where a queued/running run transitions to successful completion, the shell activity hint keeps that run visible briefly with success semantics and no fake active progress after completion.
- **SC-004**: In validation coverage where a queued/running run transitions to blocked, partial, failed, or reconciled terminal follow-up, the shell activity hint does not drop that follow-up silently from the shell immediately after transition.
- **SC-005**: The shell hint routes through the canonical helper family and keeps the guard coverage free of new raw operation-link bypasses.

View File

@ -0,0 +1,176 @@
---
description: "Task list for OperationRun Activity Feedback v1"
---
# Tasks: OperationRun Activity Feedback v1
**Input**: Design documents from `specs/268-operationrun-activity-feedback/`
**Prerequisites**: `specs/268-operationrun-activity-feedback/spec.md`, `specs/268-operationrun-activity-feedback/plan.md`, `specs/268-operationrun-activity-feedback/checklists/requirements.md`
**Tests**: REQUIRED (Pest). Keep proof bounded to Feature coverage for the current tenant shell host plus one named browser smoke for the non-obstructive shell contract.
**Operations**: No new `OperationRun` type, no new queue family, no DB-notification policy change, and no new lifecycle/status ownership. Existing queued toasts, terminal notifications, `run-enqueued` browser events, `OperationRunLinks`, and Operations collection/detail pages remain authoritative.
**RBAC**: Reuse current `OperationRun` policies and tenant context guards. No tenantless leakage from tenant surfaces; the shell hint stays inert when no selected tenant or `viewAny` capability exists.
**Shared Pattern Reuse**: Reuse `OperationUxPresenter`, `OperationStatusNormalizer`, `OperationRunLinks`, `OperationRunUrl`, `ActiveRuns`, `OpsUxBrowserEvents`, current badge semantics, `BulkOperationProgress`, and `docs/ui/tenantpilot-enterprise-ui-standards.md`. Do not create a second lifecycle, a second run-severity model, a persistent acknowledgement model, or a new host-surface activity framework.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`. No new panel, resource, or asset strategy is allowed. The tenant-panel `BODY_END` render hook may change implementation, but it must stop covering primary actions and must stay active-only.
**Organization**: Tasks are grouped by user story so the shell contract, the honest-progress rules, and the browser-session calmness rules remain independently reviewable.
## Test Governance Notes
- Lane mix stays Feature plus one named browser smoke for overlap/non-obstruction proof.
- Prefer extending `BulkOperationProgressDbOnlyTest`, `ProgressWidgetOverflowTest`, `ActivityFeedbackSurfaceTest`, and `OperationRunLinkContractGuardTest` before adding broader families.
- Browser proof is required for the non-obstructive shell contract and must stay explicit in naming.
- Validation commands must stay file-scoped and run through Sail.
## Phase 1: Setup (Shared Context)
**Purpose**: confirm the bounded slice, the current Ops-UX truth, and the shell-only guardrail before runtime edits begin.
- [ ] T001 Review `specs/268-operationrun-activity-feedback/spec.md`, `specs/268-operationrun-activity-feedback/plan.md`, `specs/268-operationrun-activity-feedback/checklists/requirements.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `docs/ui/tenantpilot-enterprise-ui-standards.md`, and `.specify/memory/constitution.md` together so the slice stays on repo-real Ops-UX truth and the manual-promotion guardrail remains explicit.
- [ ] T002 [P] Confirm the current shared seams in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/OperationStatusNormalizer.php`, `apps/platform/app/Support/OpsUx/OperationRunUrl.php`, `apps/platform/app/Support/OpsUx/ActiveRuns.php`, `apps/platform/app/Support/OpsUx/OpsUxBrowserEvents.php`, and `apps/platform/app/Support/OperationRunLinks.php`.
- [ ] T003 [P] Confirm the current shell host and overlap seam in `apps/platform/app/Livewire/BulkOperationProgress.php`, `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`, `apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php`, `apps/platform/app/Providers/Filament/TenantPanelProvider.php`, `apps/platform/public/js/tenantpilot/ops-ux-progress-widget-poller.js`, and the inherited tenant-prefilter precedence seam in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`.
- [ ] T004 [P] Confirm current proof and guard coverage in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringPageStateContractTest.php`, and `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php`, then record the missing shell-surface proof seam that T005 will introduce through `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php`.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: settle the shell contract and proof owners before runtime edits widen.
**Critical**: no user-story runtime work should begin until this phase is complete.
- [ ] T005 [P] Create or extend failing Feature coverage in `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` for hidden tenant/no-capability suppression, canonical overflow navigation with `tenant_id` prefilter precedence over restored session state, terminal-success/follow-up visibility, lifecycle-sensitive tertiary copy, and browser-session hide/dismiss/acknowledge rules.
- [ ] T006 [P] Extend `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` only as needed so the shell continues to use canonical helper families rather than raw route strings.
- [ ] T007 [P] Add a named browser smoke in `apps/platform/tests/Browser/OpsUx/OperationActivityFeedbackSmokeTest.php` for the non-obstructive shell-hint contract on a row-action-heavy page.
**Checkpoint**: the active-only shell contract, link rules, and browser-smoke proof owner are settled before implementation begins.
---
## Phase 3: User Story 1 - See Calm Active-Operations Feedback After Starting Work (Priority: P1)
**Goal**: give tenant-scoped start surfaces one calm, truthful active-ops hint that never covers primary actions.
**Independent Test**: create queued/running runs, open a tenant-scoped start surface, and verify the shell hint shows at most three active items, one canonical `View operation` link per item, an honest overflow path, and no action obstruction.
### Tests for User Story 1
- [ ] T008 [P] [US1] Extend `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` for hidden/no-capability suppression, active-only visibility, and top-three item limits.
- [ ] T009 [P] [US1] Extend `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php` for the new visible-item limit and overflow link behavior when more than three active runs exist.
### Implementation for User Story 1
- [ ] T010 [US1] Refactor `apps/platform/app/Livewire/BulkOperationProgress.php` so the shell host queries only queued/running runs and keeps the active-awareness contract local to the existing component.
- [ ] T011 [US1] Refactor `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php` and `apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php` to enforce the new visible-item limit, one dominant `View operation` action per item, and non-obstructive shell placement.
- [ ] T012 [US1] Update `apps/platform/app/Providers/Filament/TenantPanelProvider.php` and `apps/platform/public/js/tenantpilot/ops-ux-progress-widget-poller.js` only as needed to preserve bounded polling and shell lifecycle behavior without reintroducing overlap or duplicate polling loops.
**Checkpoint**: User Story 1 is independently functional when the shell shows active operations calmly and without overlap.
---
## Phase 4: User Story 2 - Keep The Feedback Loop Honest Across Terminal Transitions (Priority: P1)
**Goal**: keep the shell honest when runs become terminal, with a bounded success confirmation and unresolved follow-up visibility in the same surface.
**Independent Test**: seed queued/running runs alongside just-completed successful runs and terminal follow-up runs, open a tenant-scoped start surface, and verify the shell keeps recent success visible briefly, keeps unresolved follow-up visible, and never shows fake active progress after completion.
### Tests for User Story 2
- [ ] T013 [P] [US2] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` for recent terminal-success visibility, terminal follow-up persistence, indeterminate fallback when counts are invalid, the absence of fake active progress after completion, and the at-most-one concise next-step cue rule.
- [ ] T014 [P] [US2] Extend `apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php` only if needed so shell and overflow links stay on canonical helper families after the refactor.
### Implementation for User Story 2
- [ ] T015 [US2] Ensure `apps/platform/app/Livewire/BulkOperationProgress.php`, `apps/platform/app/Support/OpsUx/ActiveRuns.php`, and the shell views preserve canonical `View operation` / `Show all operations` navigation while adding bounded terminal-success and terminal-follow-up visibility without breaking the inherited tenant-prefilter precedence contract in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`.
- [ ] T016 [US2] Implement honest progress treatment in the shell: determinate only with valid numeric counts while active, clamped to `0-100`, and never showing fake active progress after terminal transition.
**Checkpoint**: User Story 2 is independently functional when the shell closes the feedback loop without inventing a second lifecycle surface.
---
## Phase 5: User Story 3 - Keep Hide / Dismiss / Acknowledge Browser-Session Only (Priority: P1)
**Goal**: support calmness for queued/running, successful, and unresolved terminal hints without creating an acknowledgement system or server-side preference store.
**Independent Test**: use the lifecycle-sensitive tertiary affordance during a browser session, navigate within that session, and verify active items use `Hide activity`, successful terminal items use `Dismiss` or `Close`, unresolved terminal items use `Acknowledge`, and the choice remains local to that browser session.
### Tests for User Story 3
- [ ] T017 [P] [US3] Extend `apps/platform/tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php` for lifecycle-sensitive tertiary labels, browser-session hide/dismiss/acknowledge persistence, and re-open-on-enqueue behavior.
### Implementation for User Story 3
- [ ] T018 [US3] Add browser-session hide/dismiss/acknowledge behavior to the shell host using a bounded browser-session store only; do not persist state in the database or on the `OperationRun` itself.
- [ ] T019 [US3] Update `docs/ui/tenantpilot-enterprise-ui-standards.md` with the shell activity-feedback pattern, including canonical links, bounded terminal-success/follow-up scope, tenant-prefiltered overflow navigation, progressbar eligibility, item limits, lifecycle-sensitive tertiary actions, hide/dismiss/acknowledge boundaries, and anti-patterns such as overlays that cover actions or fake percentages.
- [ ] T020 [US3] Review the resulting implementation to confirm it introduces no second run lifecycle, no new notification policy, no new host-surface activity family, and no database-backed acknowledgement or hide/dismiss model.
**Checkpoint**: User Story 3 is independently functional when browser-session calmness stays local and the standards doc records the durable guardrail.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: validate the bounded slice, stop drift, and hand off a clean implementation path.
- [ ] T021 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php tests/Feature/OpsUx/ActivityFeedbackSurfaceTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php`.
- [ ] T022 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/OpsUx/OperationActivityFeedbackSmokeTest.php`.
- [ ] T023 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched platform files.
- [ ] T024 [P] Review touched code against `docs/ui/tenantpilot-enterprise-ui-standards.md` and confirm the shell remains decision-first, diagnostics-light, Filament-native, limited to one dominant `View operation` action per item, and bounded to at most one concise next-step cue plus the approved terminal-success/follow-up handoff.
- [ ] T025 [P] Review touched code to confirm Filament stays on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, no new assets were registered, and no new `OperationRun` lifecycle or notification path was introduced.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks user-story work.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the active shell contract.
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 so the shell is both calm and closes the feedback loop honestly.
- **Phase 5 (US3)**: depends on Phase 2 and hardens lifecycle-sensitive calmness/guardrail behavior after the shell contract exists.
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: independently testable after Phase 2 and delivers the direct product fix for the overlap seam.
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 so the shell aligns with the Ops-UX constitution rather than only moving around visually.
- **US3 (P1)**: independently testable after Phase 2 and is still required for package completion because lifecycle-sensitive browser-session calmness and the standards-doc guardrail are part of the approved scope.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap.
- Land shell-host runtime changes before widening browser-session calmness behavior.
- Re-run the narrowest affected validation command after each story checkpoint before moving on.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **US1 + US2 + US3 together**. The manual-promotion target is only complete when the shell is non-obstructive, constitution-safe, closes the terminal feedback loop, and carries the browser-session calmness plus standards-doc guardrail required by the approved scope.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1.
3. Deliver US2 on top of the shell refactor.
4. Add US3 calmness/standards hardening.
5. Finish with focused validation and the named browser smoke.
### Team Strategy
1. Settle the shell contract and browser proof owner first.
2. Parallelize Feature coverage updates while keeping the browser smoke isolated and explicit.
3. Serialize merges around `BulkOperationProgress` and the tenant-panel render hook so shell behavior stays coherent.
---
## Deferred Follow-Ups / Non-Goals
- Host-widget operation-state migration beyond the current global active-ops shell widget
- Polling and UI calmness standard beyond this slice
- Notification/activity lifecycle standardization beyond the existing start contract
- Any permanent shell or dashboard inbox treatment for terminal/follow-up operation states beyond the bounded handoff defined here
- Activity center / tray v2
- Reviewed / investigated semantics for failed or blocked runs