Browse Source

feat: implement vision analysis module including chatbot, frame inspector, and batch report components

Dr-Swopt 2 weeks ago
parent
commit
5911ae149b

+ 67 - 158
src/src.palm.vision/analyzer/analyzer.component.html

@@ -13,30 +13,32 @@
       <mat-label>Inference Engine</mat-label>
       <mat-select [(ngModel)]="mode" [disabled]="loading$ | async" panelClass="engine-select-dropdown-panel">
         @for (opt of engineOptions; track opt.value) {
-          <mat-option [value]="opt.value">
-            <mat-icon>{{ opt.icon }}</mat-icon>
-            {{ opt.label }}
-          </mat-option>
+        <mat-option [value]="opt.value">
+          <mat-icon>{{ opt.icon }}</mat-icon>
+          {{ opt.label }}
+        </mat-option>
         }
       </mat-select>
     </mat-form-field>
 
     <span class="engine-status-badge" [ngClass]="mode">
       @switch (mode) {
-        @case ('local-onnx') { ONNX Runtime }
-        @case ('local-tflite') { TFLite Runtime }
-        @case ('remote') { Edge Server Active }
+      @case ('local-onnx') { ONNX Runtime }
+      @case ('local-tflite') { TFLite Runtime }
+      @case ('remote') { Edge Server Active }
       }
     </span>
   </div>
 
   <!-- Input mode toggle -->
   <div class="input-mode-toggle">
-    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'file'" (click)="switchInputMode('file')">
+    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'file'"
+      (click)="switchInputMode('file')">
       <mat-icon>upload_file</mat-icon>
       File Drop
     </button>
-    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'camera'" (click)="switchInputMode('camera')">
+    <button mat-flat-button [disabled]="loading$ | async" [class.active]="inputMode === 'camera'"
+      (click)="switchInputMode('camera')">
       <mat-icon>videocam</mat-icon>
       Live Camera
     </button>
@@ -44,165 +46,72 @@
 
   <!-- File drop zone -->
   @if (inputMode === 'file') {
-    <div
-      class="drop-zone"
-      [class.drag-over]="isDragOver"
-      (dragover)="onDragOver($event)"
-      (dragleave)="onDragLeave()"
-      (drop)="onDrop($event)"
-    >
-      @if ((loading$ | async) && !isCameraActive) {
-        <div class="loading-overlay">
-          <mat-spinner diameter="52"></mat-spinner>
-          <span class="loading-label">Analyzing batch&hellip;</span>
-        </div>
-      } @else {
-        <mat-icon class="drop-icon">image_search</mat-icon>
-        <p class="drop-primary">Drag &amp; drop images here</p>
-        <p class="drop-secondary">or</p>
-        <button mat-raised-button color="primary" type="button" [disabled]="loading$ | async" (click)="fileInput.click()">
-          <mat-icon>upload_file</mat-icon>
-          Select Images
-        </button>
-        <input
-          #fileInput
-          type="file"
-          accept="image/*"
-          multiple
-          hidden
-          (change)="onFileInput($event)"
-        />
-      }
+  <div class="drop-zone" [class.drag-over]="isDragOver" (dragover)="onDragOver($event)" (dragleave)="onDragLeave()"
+    (drop)="onDrop($event)">
+    @if ((loading$ | async) && !isCameraActive) {
+    <div class="loading-overlay">
+      <mat-spinner diameter="52"></mat-spinner>
+      <span class="loading-label">Analyzing batch&hellip;</span>
     </div>
+    } @else {
+    <mat-icon class="drop-icon">image_search</mat-icon>
+    <p class="drop-primary">Drag &amp; drop images here</p>
+    <p class="drop-secondary">or</p>
+    <button mat-raised-button color="primary" type="button" [disabled]="loading$ | async" (click)="fileInput.click()">
+      <mat-icon>upload_file</mat-icon>
+      Select Images
+    </button>
+    <input #fileInput type="file" accept="image/*" multiple hidden (change)="onFileInput($event)" />
+    }
+  </div>
   }
 
   <!-- Live camera section -->
   @if (inputMode === 'camera') {
-    <div class="camera-section">
-      <video
-        #videoEl
-        autoplay
-        playsinline
-        class="camera-preview"
-        [class.visible]="isCameraActive"
-      ></video>
-
-      @if ((loading$ | async) && isCameraActive) {
-        <div class="camera-loading">
-          <mat-spinner diameter="52"></mat-spinner>
-          <span class="loading-label">Analyzing frame&hellip;</span>
-        </div>
-      }
-
-      <div class="camera-controls">
-        @if (!isCameraActive) {
-          <button mat-raised-button color="primary" [disabled]="loading$ | async" (click)="startCamera()">
-            <mat-icon>videocam</mat-icon>
-            Start Camera
-          </button>
-        } @else {
-          <button
-            mat-raised-button
-            color="accent"
-            (click)="captureWebcamFrame()"
-            [disabled]="(loading$ | async) === true"
-          >
-            <mat-icon>camera</mat-icon>
-            Capture Frame
-          </button>
-          <button mat-stroked-button [disabled]="loading$ | async" (click)="stopCamera()">
-            <mat-icon>videocam_off</mat-icon>
-            Stop
-          </button>
-        }
-      </div>
-    </div>
-  }
+  <div class="camera-section">
+    <video #videoEl autoplay playsinline class="camera-preview" [class.visible]="isCameraActive"></video>
 
-  <!-- Studio workspace: vertical stack -->
-  <div class="studio-workspace">
-
-    <!-- Canvas bounding box overlay (always in DOM so ViewChild resolves) -->
-    <div class="canvas-container" [class.has-content]="!!currentFrame">
-      <canvas #resultCanvas class="result-canvas"></canvas>
+    @if ((loading$ | async) && isCameraActive) {
+    <div class="camera-loading">
+      <mat-spinner diameter="52"></mat-spinner>
+      <span class="loading-label">Analyzing frame&hellip;</span>
     </div>
-
-    <!-- Batch carousel (visible when batch has multiple frames) -->
-    @if (batchFrames.length > 1) {
-      <div class="carousel-container">
-        <button class="carousel-nav prev" (click)="prevFrame()">&#9664;</button>
-        <div class="carousel-track">
-          @for (frame of batchFrames; track frame.frameId; let i = $index) {
-            <div class="carousel-card" [class.active]="i === selectedFrameIndex" (click)="selectFrame(i)">
-              <span class="card-index">#{{ i + 1 }}</span>
-              <span class="card-meta">{{ frame.detections.length }} det.</span>
-            </div>
-          }
-        </div>
-        <button class="carousel-nav next" (click)="nextFrame()">&#9654;</button>
-      </div>
     }
 
-    <!-- Detection results -->
-    @if (currentFrame) {
-      <div class="results-panel">
-
-        <div class="results-header">
-          <span class="results-title">
-            Batch Result &mdash;
-            <strong>{{ currentFrame.total_count }}</strong> detection(s)
-          </span>
-          <span class="timing-info">
-            Inference: {{ currentFrame.inference_ms | number:'1.0-0' }}&nbsp;ms
-            &nbsp;|&nbsp;
-            Processing: {{ currentFrame.processing_ms | number:'1.0-0' }}&nbsp;ms
-          </span>
-        </div>
-
-        <div class="results-body">
-          <div class="detections-col">
-
-            @if (currentFrame.detections.length) {
-              @for (det of currentFrame.detections; track det.bunch_id) {
-                <mat-card class="detection-card" [class.health-alert]="det.is_health_alert">
-                  <mat-card-content>
-                    <span
-                      class="grade-dot"
-                      [ngStyle]="{ background: gradeColor(det.class) }"
-                    ></span>
-                    <span class="grade-label">{{ det.class }}</span>
-                    <span class="confidence-value">{{ confidencePercent(det.confidence) }}</span>
-                    @if (det.is_health_alert) {
-                      <mat-icon class="alert-icon" color="warn">warning</mat-icon>
-                    }
-                  </mat-card-content>
-                </mat-card>
-              }
-            } @else {
-              <p class="no-detections">No detections in this frame.</p>
-            }
-
-            @if (currentFrame.total_count > 0) {
-              <div class="summary-block">
-                <h4 class="summary-title">Industrial Summary</h4>
-                <div class="summary-chips">
-                  @for (entry of currentFrame.industrial_summary | keyvalue; track entry.key) {
-                    <span
-                      class="summary-chip"
-                      [ngStyle]="{ 'border-color': gradeColor(entry.key) }"
-                    >
-                      {{ entry.key }}: <strong>{{ entry.value }}</strong>
-                    </span>
-                  }
-                </div>
-              </div>
-            }
+    <div class="camera-controls">
+      @if (!isCameraActive) {
+      <button mat-raised-button color="primary" [disabled]="loading$ | async" (click)="startCamera()">
+        <mat-icon>videocam</mat-icon>
+        Start Camera
+      </button>
+      } @else {
+      <button mat-raised-button color="accent" (click)="captureWebcamFrame()" [disabled]="(loading$ | async) === true">
+        <mat-icon>camera</mat-icon>
+        Capture Frame
+      </button>
+      <button mat-stroked-button [disabled]="loading$ | async" (click)="stopCamera()">
+        <mat-icon>videocam_off</mat-icon>
+        Stop
+      </button>
+      }
+    </div>
+  </div>
+  }
 
-          </div>
-        </div>
+  <!-- Live batch report view -->
+  @if (activeBatchId) {
+    <div class="live-report-wrapper">
+      <div class="live-report-header">
+        <mat-icon class="report-icon">assessment</mat-icon>
+        <span class="report-label">Batch Complete</span>
+        <button mat-stroked-button class="new-scan-btn" (click)="resetScan()">
+          <mat-icon>refresh</mat-icon>
+          Clear Report
+        </button>
       </div>
-    }
+      <app-batch-report [batchId]="activeBatchId" [startCollapsed]="true" [lightMode]="true"></app-batch-report>
+    </div>
+  }
 
-  </div>
 
-</div>
+</div>

+ 32 - 215
src/src.palm.vision/analyzer/analyzer.component.scss

@@ -201,238 +201,55 @@
   }
 }
 
-// ── Canvas bounding box overlay ──────────────────────────────────────────────
+// ── Live batch report wrapper ────────────────────────────────────────────────
 
-.canvas-container {
+.live-report-wrapper {
+  border: 1px solid #a5d6a7;
   border-radius: 12px;
   overflow: hidden;
-  display: none;
-
-  &.has-content {
-    display: inline-flex;
-    justify-content: center;
-    align-items: center;
-    width: auto;
-    max-width: 100%;
-    background-color: transparent;
-  }
-
-  .result-canvas {
-    display: block;
-    width: 100%;
-    height: auto;
-    max-height: 70vh;
-  }
-}
-
-// ── Studio workspace vertical stack ──────────────────────────────────────────
-
-.studio-workspace {
-  display: flex;
-  flex-direction: column;
-  gap: 1rem;
-}
-
-// ── Batch carousel ────────────────────────────────────────────────────────────
-
-.carousel-container {
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-  width: 100%;
-}
-
-.carousel-nav {
-  flex-shrink: 0;
-  width: 32px;
-  height: 32px;
-  border: 1.5px solid #a5d6a7;
-  border-radius: 50%;
-  background: #f9fbe7;
-  color: #2e7d32;
-  font-size: 0.8rem;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  transition: background 0.15s, border-color 0.15s;
-
-  &:hover {
-    background: #e8f5e9;
-    border-color: #2e7d32;
-  }
-}
-
-.carousel-track {
-  flex: 1;
-  display: flex;
-  gap: 0.5rem;
-  overflow-x: auto;
-  padding-bottom: 4px;
-  scrollbar-width: thin;
-  scrollbar-color: #a5d6a7 transparent;
-
-  &::-webkit-scrollbar {
-    height: 4px;
-  }
-
-  &::-webkit-scrollbar-thumb {
-    background: #a5d6a7;
-    border-radius: 2px;
-  }
-}
-
-.carousel-card {
-  flex-shrink: 0;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 2px;
-  padding: 0.35rem 0.6rem;
-  border-radius: 8px;
-  border: 1.5px solid #c8e6c9;
-  background: #f9fbe7;
-  cursor: pointer;
-  transition: border-color 0.15s, background 0.15s;
-  min-width: 52px;
-
-  &:hover {
-    border-color: #81c784;
-    background: #e8f5e9;
-  }
-
-  &.active {
-    border-color: #2e7d32;
-    background: #e8f5e9;
-  }
-
-  .card-index {
-    font-size: 0.82rem;
-    font-weight: 700;
-    color: #2e7d32;
-  }
-
-  .card-meta {
-    font-size: 0.65rem;
-    color: #607d8b;
-    white-space: nowrap;
-  }
-}
-
-// ── Results panel ────────────────────────────────────────────────────────────
-
-.results-panel {
-  border: 1px solid #c8e6c9;
-  border-radius: 12px;
-  background: #fff;
-  overflow: hidden;
+  background: #ffffff;
 }
 
-.results-header {
+.live-report-header {
   display: flex;
   align-items: center;
-  justify-content: space-between;
-  padding: 0.75rem 1.25rem;
-  background: #e8f5e9;
+  gap: 0.6rem;
+  padding: 0.65rem 1rem;
+  background: #f1f8e9;
   border-bottom: 1px solid #c8e6c9;
 
-  .results-title {
-    font-size: 0.95rem;
-    color: #1b5e20;
-  }
-
-  .timing-info {
-    font-size: 0.78rem;
-    color: #607d8b;
-  }
-}
-
-.results-body {
-  display: flex;
-  gap: 1.25rem;
-  padding: 1.25rem;
-}
-
-// ── Detection cards ──────────────────────────────────────────────────────────
-
-.detections-col {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  gap: 0.5rem;
-}
-
-.detection-card {
-  &.health-alert {
-    border-left: 4px solid #f44336;
-  }
-
-  mat-card-content {
-    display: flex;
-    align-items: center;
-    gap: 0.5rem;
-    padding: 0.5rem 0.75rem !important;
-  }
-
-  .grade-dot {
-    width: 12px;
-    height: 12px;
-    border-radius: 50%;
-    flex-shrink: 0;
-  }
-
-  .grade-label {
-    flex: 1;
-    font-weight: 500;
-    font-size: 0.9rem;
-  }
-
-  .confidence-value {
-    font-size: 0.85rem;
-    color: #546e7a;
-    font-variant-numeric: tabular-nums;
-  }
-
-  .alert-icon {
+  .report-icon {
+    color: #558b2f;
     font-size: 1.1rem;
     width: 1.1rem;
     height: 1.1rem;
   }
-}
 
-.no-detections {
-  font-size: 0.9rem;
-  color: #90a4ae;
-  font-style: italic;
-  margin: 0.5rem 0;
-}
+  .report-label {
+    font-size: 0.82rem;
+    font-weight: 600;
+    color: #37474f;
+    flex: 1;
+  }
 
-// ── Industrial summary ───────────────────────────────────────────────────────
+  .new-scan-btn {
+    color: #546e7a;
+    border-color: #a5d6a7;
+    font-size: 0.78rem;
+    height: 30px;
+    line-height: 30px;
 
-.summary-block {
-  margin-top: 0.75rem;
+    mat-icon {
+      font-size: 0.9rem;
+      width: 0.9rem;
+      height: 0.9rem;
+    }
 
-  .summary-title {
-    font-size: 0.8rem;
-    text-transform: uppercase;
-    letter-spacing: 0.06em;
-    color: #607d8b;
-    margin: 0 0 0.4rem;
+    &:hover {
+      color: #2e7d32;
+      border-color: #2e7d32;
+    }
   }
+}
 
-  .summary-chips {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 0.4rem;
-  }
 
-  .summary-chip {
-    padding: 2px 10px;
-    border-radius: 10px;
-    border: 1.5px solid;
-    font-size: 0.78rem;
-    background: #fafafa;
-    color: #37474f;
-  }
-}

+ 12 - 125
src/src.palm.vision/analyzer/analyzer.component.ts

@@ -1,25 +1,23 @@
 import {
-  AfterViewInit,
   Component,
   ElementRef,
   OnDestroy,
   OnInit,
   ViewChild,
 } from '@angular/core';
-import { AsyncPipe, DecimalPipe, KeyValuePipe, NgClass, NgStyle } from '@angular/common';
+import { AsyncPipe, NgClass } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Select, Store } from '@ngxs/store';
 import { Observable, Subscription } from 'rxjs';
-import { filter } from 'rxjs/operators';
 import { MatSelectModule } from '@angular/material/select';
 import { MatFormFieldModule } from '@angular/material/form-field';
 import { MatButtonModule } from '@angular/material/button';
 import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatCardModule } from '@angular/material/card';
 import { MatIconModule } from '@angular/material/icon';
 import { VisionState } from '../store/vision.state';
 import { SubmitBatchAnalysis } from '../store/vision.actions';
 import { InferenceFrame } from '../services/inference.service';
+import { BatchReportComponent } from '../history/batch-report/batch-report.component';
 
 type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
 
@@ -28,35 +26,29 @@ type EngineMode = 'local-onnx' | 'local-tflite' | 'remote';
   standalone: true,
   imports: [
     AsyncPipe,
-    DecimalPipe,
-    KeyValuePipe,
     NgClass,
-    NgStyle,
     FormsModule,
     MatSelectModule,
     MatFormFieldModule,
     MatButtonModule,
     MatProgressSpinnerModule,
-    MatCardModule,
     MatIconModule,
+    BatchReportComponent,
   ],
   templateUrl: './analyzer.component.html',
   styleUrl: './analyzer.component.scss',
 })
-export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
+export class AnalyzerComponent implements OnInit, OnDestroy {
   @Select(VisionState.loading) loading$!: Observable<boolean>;
 
-  @ViewChild('resultCanvas') resultCanvasRef!: ElementRef<HTMLCanvasElement>;
   @ViewChild('videoEl') videoElRef!: ElementRef<HTMLVideoElement>;
 
   mode: EngineMode = 'remote';
-  isViewInitialized = false;
   inputMode: 'file' | 'camera' = 'file';
   isDragOver = false;
   isCameraActive = false;
-  currentFrame: InferenceFrame | null = null;
   batchFrames: InferenceFrame[] = [];
-  selectedFrameIndex = 0;
+  activeBatchId: string | null = null;
 
   readonly engineOptions: { value: EngineMode; label: string; icon: string }[] = [
     { value: 'local-onnx', label: 'Local — ONNX WASM', icon: 'memory' },
@@ -65,38 +57,20 @@ export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
   ];
 
   private mediaStream: MediaStream | null = null;
-  private inferenceSub!: Subscription;
   private batchSub!: Subscription;
 
   constructor(private store: Store) {}
 
   ngOnInit(): void {
-    this.inferenceSub = (this.store.select(VisionState.currentInference) as Observable<InferenceFrame | null>)
-      .pipe(filter((f): f is InferenceFrame => !!f))
-      .subscribe(frame => {
-        this.currentFrame = frame;
-        if (this.isViewInitialized) {
-          this.renderPredictionsWithBoxes(frame);
-        }
-      });
-
     this.batchSub = (this.store.select((state: any) => state.visionState?.batchFrames ?? []) as Observable<InferenceFrame[]>)
       .subscribe(frames => {
         this.batchFrames = frames;
-        this.selectedFrameIndex = 0;
+        this.activeBatchId = frames.length > 0 ? (frames[0]?.batchId ?? null) : null;
       });
   }
 
-  ngAfterViewInit(): void {
-    this.isViewInitialized = true;
-    if (this.currentFrame) {
-      this.renderPredictionsWithBoxes(this.currentFrame);
-    }
-  }
-
   ngOnDestroy(): void {
     this.stopCamera();
-    this.inferenceSub?.unsubscribe();
     this.batchSub?.unsubscribe();
   }
 
@@ -172,99 +146,12 @@ export class AnalyzerComponent implements OnInit, AfterViewInit, OnDestroy {
     }, 'image/jpeg');
   }
 
-  // ── Canvas renderer ───────────────────────────────────────────────────────
-
-  renderPredictionsWithBoxes(frame: InferenceFrame): void {
-    setTimeout(() => {
-      const canvas = this.resultCanvasRef?.nativeElement;
-      if (!canvas || !frame.imageDataUrl) return;
-
-      const img = new Image();
-      img.onload = () => {
-        const container = this.resultCanvasRef.nativeElement.parentElement;
-        const targetWidth = container?.clientWidth || img.naturalWidth;
-        const targetHeight = container?.clientHeight || img.naturalHeight;
-        const scale = Math.min(targetWidth / img.naturalWidth, targetHeight / img.naturalHeight);
-
-        canvas.width = img.naturalWidth * scale;
-        canvas.height = img.naturalHeight * scale;
-        const ctx = canvas.getContext('2d')!;
-        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
-
-        for (const det of frame.detections) {
-          let x: number, y: number, w: number, h: number;
-          if (det.norm_box) {
-            const [nx1, ny1, nx2, ny2] = det.norm_box;
-            x = nx1 * canvas.width;
-            y = ny1 * canvas.height;
-            w = (nx2 - nx1) * canvas.width;
-            h = (ny2 - ny1) * canvas.height;
-          } else if (det.box) {
-            const [x1, y1, x2, y2] = det.box;
-            x = x1 * scale;
-            y = y1 * scale;
-            w = (x2 - x1) * scale;
-            h = (y2 - y1) * scale;
-          } else {
-            continue;
-          }
-          const color = this.gradeColor(det.class);
-
-          ctx.strokeStyle = color;
-          ctx.lineWidth = 2;
-          ctx.strokeRect(x, y, w, h);
-
-          const label = `${det.class} ${(det.confidence * 100).toFixed(1)}%`;
-          ctx.font = 'bold 12px sans-serif';
-          const tw = ctx.measureText(label).width;
-          ctx.fillStyle = color;
-          ctx.fillRect(x, y - 20, tw + 8, 20);
-          ctx.fillStyle = '#fff';
-          ctx.fillText(label, x + 4, y - 5);
-        }
-      };
-      img.src = frame.imageDataUrl;
-    }, 0);
-  }
-
-  // ── Helpers ───────────────────────────────────────────────────────────────
-
-  confidencePercent(value: number): string {
-    return (value * 100).toFixed(1) + '%';
-  }
-
-  gradeColor(cls: string): string {
-    const palette: Record<string, string> = {
-      Ripe: '#4caf50',
-      Unripe: '#ff9800',
-      Underripe: '#ffeb3b',
-      Overripe: '#9c27b0',
-      Abnormal: '#f44336',
-      Empty_Bunch: '#607d8b',
-    };
-    return palette[cls] ?? '#757575';
-  }
-
-  selectFrame(index: number): void {
-    this.selectedFrameIndex = index;
-    this.currentFrame = this.batchFrames[index];
-    if (this.isViewInitialized) {
-      this.renderPredictionsWithBoxes(this.batchFrames[index]);
-    }
-  }
-
-  prevFrame(): void {
-    const next = this.selectedFrameIndex > 0
-      ? this.selectedFrameIndex - 1
-      : this.batchFrames.length - 1;
-    this.selectFrame(next);
-  }
-
-  nextFrame(): void {
-    const next = this.selectedFrameIndex < this.batchFrames.length - 1
-      ? this.selectedFrameIndex + 1
-      : 0;
-    this.selectFrame(next);
+  resetScan(): void {
+    this.stopCamera();
+    this.activeBatchId = null;
+    this.currentFrame = null;
+    this.batchFrames = [];
+    this.selectedFrameIndex = 0;
   }
 
   private submit(files: File[]): void {

+ 229 - 0
src/src.palm.vision/chatbot/chatbot.component.scss

@@ -0,0 +1,229 @@
+/* ── Theme tokens — driven by ancestor Material M2 theme class ─────────────── */
+/* Default resolves to dark-theme (indigo dark baseline)                        */
+
+:host {
+  --chat-bg:         #121212;
+  --chat-header-bg:  #283593;   /* indigo 800 — dark-readable primary */
+  --chat-surface:    #1e1e1e;
+  --chat-border:     rgba(255, 255, 255, 0.08);
+  --chat-text:       rgba(255, 255, 255, 0.87);
+  --chat-text-sub:   rgba(255, 255, 255, 0.45);
+  --chat-user-bg:    rgba(63, 81, 181, 0.32);
+  --chat-user-fg:    #e8eaf6;
+  --chat-bot-bg:     rgba(255, 255, 255, 0.05);
+  --chat-bot-fg:     rgba(255, 255, 255, 0.72);
+  --chat-accent:     #7986cb;   /* indigo 300 */
+  --chat-header-fg:  #ffffff;
+  --chat-muted:      rgba(255, 255, 255, 0.28);
+
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  overflow: hidden;
+  background: var(--chat-bg);
+  color: var(--chat-text);
+}
+
+:host-context(.dark-theme) {
+  /* matches the default block above — explicit for clarity */
+  --chat-bg:         #121212;
+  --chat-header-bg:  #283593;
+  --chat-surface:    #1e1e1e;
+  --chat-border:     rgba(255, 255, 255, 0.08);
+  --chat-text:       rgba(255, 255, 255, 0.87);
+  --chat-text-sub:   rgba(255, 255, 255, 0.45);
+  --chat-user-bg:    rgba(63, 81, 181, 0.32);
+  --chat-user-fg:    #e8eaf6;
+  --chat-bot-bg:     rgba(255, 255, 255, 0.05);
+  --chat-bot-fg:     rgba(255, 255, 255, 0.72);
+  --chat-accent:     #7986cb;
+  --chat-header-fg:  #ffffff;
+  --chat-muted:      rgba(255, 255, 255, 0.28);
+}
+
+:host-context(.dark-theme-pink) {
+  --chat-bg:         #120e14;
+  --chat-header-bg:  #880e4f;   /* pink 900 */
+  --chat-surface:    #1e1520;
+  --chat-border:     rgba(255, 255, 255, 0.08);
+  --chat-text:       rgba(255, 255, 255, 0.87);
+  --chat-text-sub:   rgba(255, 255, 255, 0.45);
+  --chat-user-bg:    rgba(233, 30, 99, 0.28);
+  --chat-user-fg:    #fce4ec;
+  --chat-bot-bg:     rgba(255, 255, 255, 0.05);
+  --chat-bot-fg:     rgba(255, 255, 255, 0.72);
+  --chat-accent:     #f48fb1;   /* pink 200 */
+  --chat-header-fg:  #ffffff;
+  --chat-muted:      rgba(255, 255, 255, 0.28);
+}
+
+:host-context(.theme) {
+  --chat-bg:         #f5f5f7;
+  --chat-header-bg:  #3f51b5;   /* indigo 500 — matches toolbar */
+  --chat-surface:    #ffffff;
+  --chat-border:     rgba(0, 0, 0, 0.09);
+  --chat-text:       rgba(0, 0, 0, 0.87);
+  --chat-text-sub:   rgba(0, 0, 0, 0.54);
+  --chat-user-bg:    #3f51b5;
+  --chat-user-fg:    #ffffff;
+  --chat-bot-bg:     #ffffff;
+  --chat-bot-fg:     rgba(0, 0, 0, 0.75);
+  --chat-accent:     #3f51b5;
+  --chat-header-fg:  #ffffff;
+  --chat-muted:      rgba(0, 0, 0, 0.36);
+}
+
+:host-context(.theme-pink) {
+  --chat-bg:         #fdf0f4;
+  --chat-header-bg:  #c2185b;   /* pink 700 — matches toolbar */
+  --chat-surface:    #ffffff;
+  --chat-border:     rgba(0, 0, 0, 0.09);
+  --chat-text:       rgba(0, 0, 0, 0.87);
+  --chat-text-sub:   rgba(0, 0, 0, 0.54);
+  --chat-user-bg:    #c2185b;
+  --chat-user-fg:    #ffffff;
+  --chat-bot-bg:     #ffffff;
+  --chat-bot-fg:     rgba(0, 0, 0, 0.75);
+  --chat-accent:     #c2185b;
+  --chat-header-fg:  #ffffff;
+  --chat-muted:      rgba(0, 0, 0, 0.36);
+}
+
+/* ── Shell ─────────────────────────────────────────────────────────────────── */
+
+.chat-shell {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  overflow: hidden;
+}
+
+/* ── Header ────────────────────────────────────────────────────────────────── */
+
+.chat-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  background: var(--chat-header-bg);
+  color: var(--chat-header-fg);
+  border-bottom: 1px solid var(--chat-border);
+  flex-shrink: 0;
+}
+
+.header-icon {
+  font-size: 20px;
+  height: 20px;
+  width: 20px;
+  opacity: 0.88;
+}
+
+.header-title {
+  font-size: 13px;
+  font-weight: 600;
+  letter-spacing: 0.4px;
+  flex: 1;
+}
+
+.session-tag {
+  font-size: 10px;
+  font-family: 'Courier New', monospace;
+  opacity: 0.55;
+  letter-spacing: 0.3px;
+  user-select: none;
+}
+
+.reset-btn {
+  color: var(--chat-header-fg) !important;
+  opacity: 0.75;
+
+  &:hover { opacity: 1; }
+}
+
+/* ── Feed ──────────────────────────────────────────────────────────────────── */
+
+.chat-feed {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  padding: 20px max(16px, calc((100% - 760px) / 2)) 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  scrollbar-width: thin;
+  scrollbar-color: var(--chat-border) transparent;
+}
+
+/* ── Message bubbles ───────────────────────────────────────────────────────── */
+
+.chat-msg {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  align-items: flex-start;
+
+  &.chat-msg--user { align-items: flex-end; }
+}
+
+.chat-msg__label {
+  font-size: 9.5px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.7px;
+  color: var(--chat-muted);
+}
+
+.chat-msg__bubble {
+  max-width: min(88%, 600px);
+  padding: 10px 14px;
+  border-radius: 14px;
+  font-size: 13.5px;
+  line-height: 1.65;
+  white-space: pre-wrap;
+  word-break: break-word;
+
+  .chat-msg--user & {
+    background: var(--chat-user-bg);
+    color: var(--chat-user-fg);
+    border-bottom-right-radius: 3px;
+  }
+
+  .chat-msg:not(.chat-msg--user) & {
+    background: var(--chat-bot-bg);
+    color: var(--chat-bot-fg);
+    border: 1px solid var(--chat-border);
+    border-bottom-left-radius: 3px;
+  }
+}
+
+.chat-thinking {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  font-size: 12px;
+  color: var(--chat-text-sub);
+  padding: 2px 0;
+}
+
+/* ── Composer ──────────────────────────────────────────────────────────────── */
+
+.chat-composer {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 8px max(10px, calc((100% - 760px) / 2));
+  border-top: 1px solid var(--chat-border);
+  background: var(--chat-surface);
+  flex-shrink: 0;
+}
+
+.chat-input {
+  flex: 1;
+}
+
+.send-btn {
+  color: var(--chat-accent) !important;
+  flex-shrink: 0;
+
+  &[disabled] { color: var(--chat-muted) !important; }
+}

+ 213 - 14
src/src.palm.vision/chatbot/chatbot.component.ts

@@ -1,22 +1,221 @@
-import { Component } from '@angular/core';
-import { ChatComponent } from 'angularlib/chat/chat.component';
+import {
+  AfterViewChecked,
+  Component,
+  ElementRef,
+  inject,
+  OnDestroy,
+  OnInit,
+  signal,
+  ViewChild,
+} from '@angular/core';
+import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
+import { Subject, takeUntil } from 'rxjs';
+import { MatButtonModule } from '@angular/material/button';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { DpService } from 'dp-ui/dp.service';
+import { FisAppMessage, MessageHeader, AppMessageType } from 'dp-ui/fisappmessage/apprequestmessagetype';
+
+interface ChatMessage {
+  id: string;
+  sender: 'user' | 'assistant';
+  content: string;
+  timestamp: number;
+}
+
+function makeWelcome(): ChatMessage {
+  return {
+    id: 'system-welcome',
+    sender: 'assistant',
+    content:
+      'Welcome to the Industrial Intelligence Portal. Ask me about batch yield summaries, ripeness distributions, ABW trends, or anomaly flags from your production data.',
+    timestamp: Date.now(),
+  };
+}
 
 @Component({
   selector: 'app-chatbot',
   standalone: true,
-  imports: [ChatComponent],
+  imports: [
+    ReactiveFormsModule,
+    MatButtonModule,
+    MatFormFieldModule,
+    MatIconModule,
+    MatInputModule,
+    MatProgressSpinnerModule,
+    MatTooltipModule,
+  ],
+  styleUrl: './chatbot.component.scss',
   template: `
-    <div class="chatbot-container">
-      <chat title="Industrial Intelligence Portal"></chat>
+    <div class="chat-shell">
+
+      <!-- Header -->
+      <div class="chat-header">
+        <mat-icon class="header-icon">psychology</mat-icon>
+        <span class="header-title">Intelligence Portal</span>
+        <span class="session-tag" [title]="'Session: ' + sessionId()">
+          SID·{{ sessionId().slice(-6) }}
+        </span>
+        <button mat-icon-button
+                class="reset-btn"
+                type="button"
+                (click)="resetSession()"
+                matTooltip="Reset session context"
+                [disabled]="loading()">
+          <mat-icon>refresh</mat-icon>
+        </button>
+      </div>
+
+      <!-- Message feed -->
+      <div class="chat-feed" #messagesEl>
+        @for (msg of messages(); track msg.id) {
+          <div class="chat-msg" [class.chat-msg--user]="msg.sender === 'user'">
+            <span class="chat-msg__label">
+              {{ msg.sender === 'user' ? 'You' : 'Intelligence' }}
+            </span>
+            <div class="chat-msg__bubble">{{ msg.content }}</div>
+          </div>
+        }
+        @if (loading()) {
+          <div class="chat-thinking">
+            <mat-spinner diameter="14"></mat-spinner>
+            <span>Processing…</span>
+          </div>
+        }
+      </div>
+
+      <!-- Composer -->
+      <form class="chat-composer" (submit)="send($event)">
+        <mat-form-field appearance="outline" subscriptSizing="dynamic" class="chat-input">
+          <mat-label>Ask the intelligence layer…</mat-label>
+          <input matInput [formControl]="inputControl" autocomplete="off" />
+        </mat-form-field>
+        <button mat-icon-button
+                type="submit"
+                class="send-btn"
+                [disabled]="loading() || inputControl.invalid">
+          <mat-icon>send</mat-icon>
+        </button>
+      </form>
+
     </div>
   `,
-  styles: [`
-    .chatbot-container {
-      width: 100%;
-      height: calc(100vh - 64px);
-      padding: 16px;
-      box-sizing: border-box;
-    }
-  `]
 })
-export class ChatbotComponent {}
+export class ChatbotComponent implements OnInit, AfterViewChecked, OnDestroy {
+  private readonly dpService = inject(DpService);
+
+  @ViewChild('messagesEl') messagesEl!: ElementRef<HTMLDivElement>;
+
+  inputControl = new FormControl('', {
+    nonNullable: true,
+    validators: [Validators.required, Validators.minLength(1)],
+  });
+
+  messages = signal<ChatMessage[]>([]);
+  loading = signal(false);
+  sessionId = signal(crypto.randomUUID());
+
+  private readonly destroy$ = new Subject<void>();
+  private pendingScroll = false;
+
+  ngOnInit(): void {
+    this.messages.set([makeWelcome()]);
+  }
+
+  ngAfterViewChecked(): void {
+    if (this.pendingScroll) {
+      const el = this.messagesEl?.nativeElement;
+      if (el) el.scrollTop = el.scrollHeight;
+      this.pendingScroll = false;
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  resetSession(): void {
+    this.fisStream('Chat', 'clear', {}).pipe(takeUntil(this.destroy$)).subscribe();
+    this.sessionId.set(crypto.randomUUID());
+    this.messages.set([makeWelcome()]);
+    this.inputControl.reset();
+  }
+
+  send(event: Event): void {
+    event.preventDefault();
+    const text = this.inputControl.value.trim();
+    if (!text || this.loading()) return;
+
+    this.inputControl.reset();
+    this.messages.update(m => [
+      ...m,
+      {
+        id: crypto.randomUUID(),
+        sender: 'user',
+        content: text,
+        timestamp: Date.now(),
+      },
+    ]);
+    this.loading.set(true);
+    this.pendingScroll = true;
+
+    this.fisStream('Chat', 'send', { message: text, sessionId: this.sessionId() })
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (res: any) => {
+          const content: string =
+            typeof res === 'string'
+              ? res
+              : Array.isArray(res)
+              ? (res[0]?.output ?? res[0]?.text ?? res[0]?.message ?? JSON.stringify(res))
+              : (res?.output ?? res?.text ?? res?.message ?? JSON.stringify(res));
+
+          this.messages.update(m => [
+            ...m,
+            {
+              id: crypto.randomUUID(),
+              sender: 'assistant',
+              content,
+              timestamp: Date.now(),
+            },
+          ]);
+          this.loading.set(false);
+          this.pendingScroll = true;
+        },
+        error: () => this.appendError(),
+      });
+  }
+
+  private fisStream(serviceId: string, operation: string, payload: unknown) {
+    const messageID = crypto.randomUUID();
+    const message: FisAppMessage = {
+      header: {
+        messageID,
+        serviceId,
+        messageName: operation,
+        messageType: AppMessageType.Command,
+      } as unknown as MessageHeader,
+      data: payload,
+    };
+    return this.dpService.stream(message);
+  }
+
+  private appendError(): void {
+    this.messages.update(m => [
+      ...m,
+      {
+        id: crypto.randomUUID(),
+        sender: 'assistant',
+        content:
+          'Unable to reach the intelligence layer. Verify the WebSocket connection is active and the backend is running.',
+        timestamp: Date.now(),
+      },
+    ]);
+    this.loading.set(false);
+    this.pendingScroll = true;
+  }
+}

+ 168 - 0
src/src.palm.vision/chatbot/components/abw-chart.component.ts

@@ -0,0 +1,168 @@
+import {
+  AfterViewInit,
+  Component,
+  effect,
+  ElementRef,
+  input,
+  ViewChild,
+} from '@angular/core';
+import { AbwChartPayload } from '../models/intelligence.model';
+
+@Component({
+  selector: 'app-abw-chart',
+  standalone: true,
+  template: `
+    <div class="abw-host">
+      <h3 class="abw-title">{{ payload()?.title || 'Average Bunch Weight (ABW) Curve' }}</h3>
+      <canvas #chartCanvas class="abw-canvas"></canvas>
+      <div class="abw-unit">{{ payload()?.unit }}</div>
+    </div>
+  `,
+  styles: [`
+    .abw-host {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      padding: 20px 24px 16px;
+      box-sizing: border-box;
+    }
+    .abw-title {
+      margin: 0 0 12px;
+      font-size: 13px;
+      font-weight: 600;
+      color: #90caf9;
+      letter-spacing: 0.5px;
+      flex-shrink: 0;
+    }
+    .abw-canvas {
+      flex: 1;
+      width: 100%;
+      min-height: 0;
+      display: block;
+    }
+    .abw-unit {
+      text-align: right;
+      font-size: 11px;
+      color: #546e7a;
+      margin-top: 6px;
+      flex-shrink: 0;
+    }
+  `],
+})
+export class AbwChartComponent implements AfterViewInit {
+  payload = input<AbwChartPayload | null>(null);
+
+  @ViewChild('chartCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
+
+  private viewReady = false;
+
+  constructor() {
+    effect(() => {
+      const p = this.payload();
+      if (this.viewReady && p) this.scheduleRender(p);
+    });
+  }
+
+  ngAfterViewInit(): void {
+    this.viewReady = true;
+    const p = this.payload();
+    if (p) this.scheduleRender(p);
+  }
+
+  // setTimeout(0) defers until after Angular layout pass — matches AnalyzerComponent pattern.
+  private scheduleRender(p: AbwChartPayload): void {
+    setTimeout(() => this.render(p), 0);
+  }
+
+  private render(p: AbwChartPayload): void {
+    const canvas = this.canvasRef?.nativeElement;
+    if (!canvas) return;
+
+    const rect = canvas.getBoundingClientRect();
+    if (rect.width === 0 || rect.height === 0) return;
+
+    const dpr = window.devicePixelRatio || 1;
+    canvas.width = rect.width * dpr;
+    canvas.height = rect.height * dpr;
+
+    const ctx = canvas.getContext('2d')!;
+    ctx.scale(dpr, dpr);
+
+    const W = rect.width;
+    const H = rect.height;
+    const PAD = { top: 20, right: 20, bottom: 44, left: 56 };
+    const cW = W - PAD.left - PAD.right;
+    const cH = H - PAD.top - PAD.bottom;
+
+    ctx.clearRect(0, 0, W, H);
+
+    const series = p.series;
+    if (!series.length) return;
+
+    const values = series.map(d => d.value);
+    const minVal = Math.min(...values);
+    const maxVal = Math.max(...values);
+    const range = maxVal - minVal || 1;
+
+    const toX = (i: number) => PAD.left + (i / Math.max(series.length - 1, 1)) * cW;
+    const toY = (v: number) => PAD.top + cH - ((v - minVal) / range) * cH;
+
+    // Horizontal grid lines + y-axis labels
+    const gridLines = 4;
+    for (let g = 0; g <= gridLines; g++) {
+      const y = PAD.top + (cH / gridLines) * g;
+      ctx.strokeStyle = 'rgba(255,255,255,0.06)';
+      ctx.lineWidth = 1;
+      ctx.beginPath();
+      ctx.moveTo(PAD.left, y);
+      ctx.lineTo(PAD.left + cW, y);
+      ctx.stroke();
+
+      const labelVal = maxVal - (range / gridLines) * g;
+      ctx.fillStyle = '#546e7a';
+      ctx.font = '10px sans-serif';
+      ctx.textAlign = 'right';
+      ctx.fillText(labelVal.toFixed(1), PAD.left - 8, y + 4);
+    }
+
+    // Area fill beneath the line
+    ctx.beginPath();
+    ctx.moveTo(toX(0), toY(values[0]));
+    for (let i = 1; i < series.length; i++) ctx.lineTo(toX(i), toY(values[i]));
+    ctx.lineTo(toX(series.length - 1), PAD.top + cH);
+    ctx.lineTo(toX(0), PAD.top + cH);
+    ctx.closePath();
+    const grad = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + cH);
+    grad.addColorStop(0, 'rgba(76,175,80,0.30)');
+    grad.addColorStop(1, 'rgba(76,175,80,0.02)');
+    ctx.fillStyle = grad;
+    ctx.fill();
+
+    // Line stroke
+    ctx.beginPath();
+    ctx.strokeStyle = '#4caf50';
+    ctx.lineWidth = 2;
+    ctx.lineJoin = 'round';
+    ctx.moveTo(toX(0), toY(values[0]));
+    for (let i = 1; i < series.length; i++) ctx.lineTo(toX(i), toY(values[i]));
+    ctx.stroke();
+
+    // Data point dots
+    for (let i = 0; i < series.length; i++) {
+      ctx.beginPath();
+      ctx.arc(toX(i), toY(values[i]), 3.5, 0, Math.PI * 2);
+      ctx.fillStyle = '#4caf50';
+      ctx.fill();
+    }
+
+    // x-axis labels (auto-thin when many points)
+    ctx.fillStyle = '#546e7a';
+    ctx.font = '10px sans-serif';
+    ctx.textAlign = 'center';
+    const maxLabels = Math.max(2, Math.floor(cW / 56));
+    const step = Math.ceil(series.length / maxLabels);
+    for (let i = 0; i < series.length; i += step) {
+      ctx.fillText(series[i].label, toX(i), H - PAD.bottom + 18);
+    }
+  }
+}

+ 87 - 0
src/src.palm.vision/chatbot/models/intelligence.model.ts

@@ -0,0 +1,87 @@
+export type MpobClass = 'Ripe' | 'Unripe' | 'Underripe' | 'Overripe' | 'Abnormal' | 'Empty_Bunch';
+
+// ── Visualization payload contracts ────────────────────────────────────────
+
+export interface AbwDataPoint {
+  label: string;
+  value: number;
+}
+
+export interface AbwChartPayload {
+  chartType: 'abw-curve';
+  series: AbwDataPoint[];
+  unit: string;
+  title?: string;
+}
+
+// Stub contracts — not yet rendered; adding @case in chatbot.component.ts activates each.
+export interface HeatmapPayload {
+  chartType: 'heatmap';
+  rows: string[];
+  cols: string[];
+  matrix: number[][];
+  unit?: string;
+}
+
+export interface VelocityPayload {
+  chartType: 'velocity-chart';
+  series: { label: string; value: number; delta: number }[];
+  unit: string;
+}
+
+export interface ClusterPayload {
+  chartType: 'cluster-map';
+  points: { x: number; y: number; class: MpobClass }[];
+}
+
+export type VisualizationPayload =
+  | AbwChartPayload
+  | HeatmapPayload
+  | VelocityPayload
+  | ClusterPayload;
+
+// ── Message contracts ───────────────────────────────────────────────────────
+
+export interface TextMessage {
+  type: 'text';
+  sender: 'user' | 'assistant';
+  content: string;
+  id: string;
+  timestamp: number;
+}
+
+export interface DataMessage {
+  type: 'data';
+  sender: 'assistant';
+  content: string;
+  payload: VisualizationPayload;
+  id: string;
+  timestamp: number;
+}
+
+export type IntelligenceMessage = TextMessage | DataMessage;
+
+// ── Response parser ─────────────────────────────────────────────────────────
+// Expects the LLM/n8n to embed structured payloads inside ```json fences.
+// Strips the fence from content so the feed only shows narrative text.
+
+export function parseIntelligenceResponse(
+  raw: string,
+  sessionId: string,
+): IntelligenceMessage {
+  const id = `${sessionId}-${Date.now()}`;
+  const timestamp = Date.now();
+
+  const fenceMatch = raw.match(/```json\s*([\s\S]*?)\s*```/);
+  if (fenceMatch) {
+    try {
+      const payload = JSON.parse(fenceMatch[1]) as VisualizationPayload;
+      if (payload?.chartType) {
+        const content = raw.replace(/```json[\s\S]*?```/, '').trim();
+        return { type: 'data', sender: 'assistant', content, payload, id, timestamp };
+      }
+    } catch { /* fall through to plain text */ }
+  }
+
+  return { type: 'text', sender: 'assistant', content: raw, id, timestamp };
+}

+ 116 - 0
src/src.palm.vision/history/batch-report/batch-report.component.html

@@ -0,0 +1,116 @@
+<div class="batch-report" [class.light]="lightMode">
+
+  @if (loading) {
+    <div class="report-loading">
+      <mat-spinner diameter="40"></mat-spinner>
+      <span>Loading batch details&hellip;</span>
+    </div>
+  }
+
+  @if (error) {
+    <div class="report-error">
+      <mat-icon>error_outline</mat-icon>
+      <span>{{ error }}</span>
+    </div>
+  }
+
+  @if (data && !loading) {
+
+    <!-- Row 1: KPI Scalar Metrics -->
+    <div class="kpi-row">
+      <div class="kpi-card">
+        <span class="kpi-value">{{ data.frameCount }}</span>
+        <span class="kpi-label">Frames</span>
+      </div>
+      <div class="kpi-card">
+        <span class="kpi-value">{{ data.totalDetections }}</span>
+        <span class="kpi-label">Detections</span>
+      </div>
+      <div class="kpi-card">
+        <span class="kpi-value">{{ data.avgInferenceMs | number:'1.0-0' }}&thinsp;ms</span>
+        <span class="kpi-label">Avg Inference</span>
+      </div>
+      <div class="kpi-card">
+        <span class="kpi-value">{{ data.avgProcessingMs | number:'1.0-0' }}&thinsp;ms</span>
+        <span class="kpi-label">Avg Processing</span>
+      </div>
+      <div class="kpi-card">
+        <span class="kpi-value">{{ data.batchStart ? (data.batchStart | date:'HH:mm:ss') : '—' }}</span>
+        <span class="kpi-label">Start</span>
+      </div>
+      <div class="kpi-card">
+        <span class="kpi-value">{{ data.batchEnd ? (data.batchEnd | date:'HH:mm:ss') : '—' }}</span>
+        <span class="kpi-label">End</span>
+      </div>
+    </div>
+
+    <!-- Row 2: Cumulative Quality Distribution Bar -->
+    @if (totalInTally() > 0) {
+      <div class="distribution-section">
+        <span class="section-title">Quality Distribution</span>
+        <div class="distribution-bar">
+          @for (entry of data.classTally | keyvalue; track entry.key) {
+            @if (entry.value > 0) {
+              <div
+                class="bar-segment"
+                [style.width.%]="tallyPercent(entry.value)"
+                [style.background-color]="gradeColor(entry.key)"
+                [title]="entry.key + ': ' + entry.value"
+              ></div>
+            }
+          }
+        </div>
+        <div class="distribution-legend">
+          @for (entry of data.classTally | keyvalue; track entry.key) {
+            @if (entry.value > 0) {
+              <span class="legend-item">
+                <span class="legend-dot" [style.background-color]="gradeColor(entry.key)"></span>
+                {{ entry.key }} ({{ entry.value }})
+              </span>
+            }
+          }
+        </div>
+      </div>
+    }
+
+    <!-- Row 3: Thumbnail Gallery Grid -->
+    @if (data.frames.length > 0) {
+      <div class="frames-section">
+        <button class="gallery-toggle-row" type="button" (click)="toggleGallery()">
+          <mat-icon class="toggle-chevron" [class.rotated]="galleryOpen">expand_more</mat-icon>
+          <span>{{ galleryOpen ? 'Hide' : 'Show' }} Frame Breakdowns ({{ data.frameCount }})</span>
+        </button>
+
+        @if (galleryOpen) {
+          <div class="frame-grid">
+            @for (frame of data.frames; track frame.archive_id) {
+              <div
+                class="frame-card"
+                [class.clickable]="!!frameImages[frame.archive_id]"
+                (click)="openFrameInspector(frame, frameImages[frame.archive_id])"
+              >
+                @if (frameImages[frame.archive_id]; as imgUrl) {
+                  <img class="frame-thumb" [src]="imgUrl" [alt]="frame.archive_id" />
+                } @else if (imageErrors[frame.archive_id]) {
+                  <div class="frame-thumb-placeholder error">
+                    <mat-icon>broken_image</mat-icon>
+                  </div>
+                } @else {
+                  <div class="frame-thumb-placeholder">
+                    <mat-spinner diameter="20"></mat-spinner>
+                  </div>
+                }
+                <div class="frame-meta">
+                  <span>{{ frame.total_count ?? 0 }} det.</span>
+                  <span>{{ frame.inference_ms | number:'1.0-0' }}&thinsp;ms</span>
+                </div>
+              </div>
+            }
+          </div>
+        }
+      </div>
+    }
+
+  }
+
+</div>

+ 273 - 0
src/src.palm.vision/history/batch-report/batch-report.component.scss

@@ -0,0 +1,273 @@
+.batch-report {
+  max-width: 760px;
+  margin: 0 auto;
+  padding: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+
+  @media (max-width: 480px) {
+    padding: 12px;
+    gap: 14px;
+  }
+}
+
+// ── Loading / Error states ─────────────────────────────────────────────────────
+
+.report-loading,
+.report-error {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  padding: 40px 16px;
+  color: rgba(255, 255, 255, 0.5);
+  font-size: 0.875rem;
+}
+
+.report-error {
+  color: #f44336;
+}
+
+// ── Row 1: KPI scalar metrics ──────────────────────────────────────────────────
+
+.kpi-row {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
+  gap: 10px;
+}
+
+.kpi-card {
+  background: rgba(255, 255, 255, 0.05);
+  border: 1px solid rgba(255, 255, 255, 0.10);
+  border-radius: 8px;
+  padding: 14px 8px 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  text-align: center;
+}
+
+.kpi-value {
+  font-size: 1.15rem;
+  font-weight: 600;
+  color: #ffffff;
+  line-height: 1.2;
+  white-space: nowrap;
+}
+
+.kpi-label {
+  font-size: 0.68rem;
+  color: rgba(255, 255, 255, 0.45);
+  text-transform: uppercase;
+  letter-spacing: 0.06em;
+}
+
+// ── Row 2: Quality distribution bar ───────────────────────────────────────────
+
+.distribution-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.section-title {
+  font-size: 0.72rem;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.45);
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+}
+
+.distribution-bar {
+  display: flex;
+  height: 18px;
+  border-radius: 4px;
+  overflow: hidden;
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+}
+
+.bar-segment {
+  height: 100%;
+  min-width: 2px;
+  transition: width 0.35s ease;
+}
+
+.distribution-legend {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px 14px;
+}
+
+.legend-item {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 0.75rem;
+  color: rgba(255, 255, 255, 0.65);
+}
+
+.legend-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+// ── Row 3: Thumbnail gallery grid ─────────────────────────────────────────────
+
+.frames-section {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.gallery-toggle-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 6px;
+  padding: 6px 12px;
+  cursor: pointer;
+  width: 100%;
+  text-align: left;
+  color: rgba(255, 255, 255, 0.55);
+  font-size: 0.75rem;
+  font-weight: 500;
+  letter-spacing: 0.03em;
+  transition: background 0.15s, color 0.15s;
+
+  &:hover {
+    background: rgba(255, 255, 255, 0.08);
+    color: rgba(255, 255, 255, 0.80);
+  }
+
+  .toggle-chevron {
+    font-size: 1rem;
+    width: 1rem;
+    height: 1rem;
+    transition: transform 0.2s;
+
+    &.rotated {
+      transform: rotate(180deg);
+    }
+  }
+}
+
+.frame-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+  gap: 8px;
+}
+
+.frame-card {
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 6px;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  cursor: default;
+  transition: border-color 0.15s, background 0.15s;
+
+  &.clickable {
+    cursor: pointer;
+
+    &:hover {
+      border-color: rgba(129, 199, 132, 0.50);
+      background: rgba(255, 255, 255, 0.07);
+    }
+  }
+}
+
+.frame-thumb {
+  width: 100%;
+  height: 76px;
+  object-fit: cover;
+  display: block;
+}
+
+.frame-thumb-placeholder {
+  width: 100%;
+  height: 76px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(255, 255, 255, 0.03);
+  color: rgba(255, 255, 255, 0.25);
+
+  &.error {
+    color: rgba(244, 67, 54, 0.55);
+  }
+}
+
+.frame-meta {
+  padding: 4px 7px;
+  display: flex;
+  justify-content: space-between;
+  font-size: 0.65rem;
+  color: rgba(255, 255, 255, 0.45);
+  line-height: 1.5;
+}
+
+// ── Light-mode variant (analyzer context only) ─────────────────────────────────
+
+.batch-report.light {
+  .kpi-card {
+    background: #ffffff;
+    border-color: #c8e6c9;
+  }
+
+  .kpi-value {
+    color: #1b5e20;
+  }
+
+  .kpi-label {
+    color: #607d8b;
+  }
+
+  .section-title {
+    color: #607d8b;
+  }
+
+  .legend-item {
+    color: #37474f;
+  }
+
+  .gallery-toggle-row {
+    background: #f1f8e9;
+    border-color: #c8e6c9;
+    color: #2e7d32;
+
+    &:hover {
+      background: #e8f5e9;
+      color: #1b5e20;
+    }
+  }
+
+  .frame-card {
+    background: #ffffff;
+    border-color: #c8e6c9;
+
+    &.clickable:hover {
+      border-color: #81c784;
+      background: #e8f5e9;
+    }
+  }
+
+  .frame-meta {
+    color: #607d8b;
+  }
+
+  .report-loading {
+    color: #546e7a;
+  }
+
+  .report-error {
+    color: #f44336;
+  }
+}

+ 166 - 0
src/src.palm.vision/history/batch-report/batch-report.component.ts

@@ -0,0 +1,166 @@
+import {
+  Component,
+  Input,
+  OnChanges,
+  OnDestroy,
+  OnInit,
+  SimpleChanges,
+} from '@angular/core';
+import { DatePipe, DecimalPipe, KeyValuePipe } from '@angular/common';
+import { Subject, takeUntil } from 'rxjs';
+import { MatButtonModule } from '@angular/material/button';
+import { MatDialog } from '@angular/material/dialog';
+import { MatIconModule } from '@angular/material/icon';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { RemoteInferenceService } from '../../services/remote-inference.service';
+import {
+  FrameInspectorDialogComponent,
+  FrameInspectorData,
+} from './frame-inspector-dialog.component';
+
+export interface BatchDetails {
+  batchId: string;
+  frameCount: number;
+  totalDetections: number;
+  avgInferenceMs: number;
+  avgProcessingMs: number;
+  batchStart: string | null;
+  batchEnd: string | null;
+  classTally: Record<string, number>;
+  frames: any[];
+}
+
+@Component({
+  selector: 'app-batch-report',
+  standalone: true,
+  imports: [
+    DatePipe,
+    DecimalPipe,
+    KeyValuePipe,
+    MatButtonModule,
+    MatIconModule,
+    MatProgressSpinnerModule,
+  ],
+  templateUrl: './batch-report.component.html',
+  styleUrl: './batch-report.component.scss',
+})
+export class BatchReportComponent implements OnInit, OnChanges, OnDestroy {
+  @Input() batchId = '';
+  @Input() startCollapsed = false;
+  @Input() lightMode = false;
+
+  data: BatchDetails | null = null;
+  loading = false;
+  error: string | null = null;
+  frameImages: Record<string, string> = {};
+  imageErrors: Record<string, boolean> = {};
+  galleryOpen = true;
+
+  readonly MPOB_COLORS: Record<string, string> = {
+    Ripe: '#4caf50',
+    Unripe: '#ff9800',
+    Underripe: '#ffeb3b',
+    Overripe: '#9c27b0',
+    Abnormal: '#f44336',
+    Empty_Bunch: '#607d8b',
+  };
+
+  private destroyed$ = new Subject<void>();
+
+  constructor(
+    private remoteInference: RemoteInferenceService,
+    private dialog: MatDialog,
+  ) {}
+
+  ngOnInit(): void {
+    this.galleryOpen = !this.startCollapsed;
+  }
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['batchId'] && this.batchId) {
+      this.fetch();
+    }
+  }
+
+  ngOnDestroy(): void {
+    this.destroyed$.next();
+    this.destroyed$.complete();
+  }
+
+  totalInTally(): number {
+    if (!this.data?.classTally) return 0;
+    return Object.values(this.data.classTally).reduce((s, v) => s + v, 0);
+  }
+
+  tallyPercent(count: number): number {
+    const total = this.totalInTally();
+    return total > 0 ? (count / total) * 100 : 0;
+  }
+
+  gradeColor(cls: string): string {
+    return this.MPOB_COLORS[cls] ?? '#757575';
+  }
+
+  toggleGallery(): void {
+    this.galleryOpen = !this.galleryOpen;
+  }
+
+  openFrameInspector(frame: any, imgUrl: string | undefined): void {
+    if (!imgUrl) return;
+    const data: FrameInspectorData = {
+      frame,
+      imgUrl,
+      mpobColors: this.MPOB_COLORS,
+    };
+    this.dialog.open(FrameInspectorDialogComponent, {
+      data,
+      panelClass: 'frame-inspector-panel',
+      width: '100%',
+      maxWidth: '95vw',
+      autoFocus: false,
+    });
+  }
+
+  private fetch(): void {
+    this.loading = true;
+    this.error = null;
+    this.data = null;
+    this.frameImages = {};
+    this.imageErrors = {};
+    this.galleryOpen = !this.startCollapsed;
+
+    this.remoteInference.getBatchDetails(this.batchId)
+      .pipe(takeUntil(this.destroyed$))
+      .subscribe({
+        next: details => {
+          this.data = details as BatchDetails;
+          this.loading = false;
+          this.loadFrameImages(details.frames ?? []);
+        },
+        error: err => {
+          this.error = err?.message ?? 'Failed to load batch details.';
+          this.loading = false;
+        },
+      });
+  }
+
+  private loadFrameImages(frames: any[]): void {
+    for (const frame of frames) {
+      const id: string = frame.archive_id;
+      this.remoteInference.getImage(id)
+        .pipe(takeUntil(this.destroyed$))
+        .subscribe({
+          next: res => {
+            if (res?.image_data) {
+              this.frameImages = { ...this.frameImages, [id]: res.image_data };
+            } else {
+              this.imageErrors = { ...this.imageErrors, [id]: true };
+            }
+          },
+          error: () => {
+            this.imageErrors = { ...this.imageErrors, [id]: true };
+          },
+        });
+    }
+  }
+}

+ 247 - 0
src/src.palm.vision/history/batch-report/frame-inspector-dialog.component.ts

@@ -0,0 +1,247 @@
+import {
+  AfterViewInit,
+  Component,
+  ElementRef,
+  Inject,
+  ViewChild,
+} from '@angular/core';
+import { DecimalPipe } from '@angular/common';
+import {
+  MAT_DIALOG_DATA,
+  MatDialogModule,
+  MatDialogRef,
+} from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatIconModule } from '@angular/material/icon';
+
+export interface FrameInspectorData {
+  frame: any;
+  imgUrl: string;
+  mpobColors: Record<string, string>;
+}
+
+@Component({
+  selector: 'app-frame-inspector-dialog',
+  standalone: true,
+  imports: [DecimalPipe, MatDialogModule, MatButtonModule, MatIconModule],
+  template: `
+    <div class="inspector-shell">
+      <div class="inspector-title">
+        <mat-icon class="title-icon">image_search</mat-icon>
+        <span>Frame Inspector</span>
+        <button mat-icon-button class="close-btn" (click)="close()">
+          <mat-icon>close</mat-icon>
+        </button>
+      </div>
+
+      <div class="inspector-body">
+        <canvas #inspectorCanvas class="inspector-canvas"></canvas>
+
+        <div class="det-list">
+          @for (det of detections; track det.bunch_id ?? $index) {
+            <div class="det-row" [style.border-left-color]="color(det.class)">
+              <span class="det-class">{{ det.class }}</span>
+              <span class="det-conf">{{ det.confidence * 100 | number:'1.1-1' }}%</span>
+            </div>
+          }
+          @if (detections.length === 0) {
+            <p class="det-empty">No detections.</p>
+          }
+        </div>
+      </div>
+
+      <div class="inspector-footer">
+        <span class="timing-chip">
+          Inference: {{ data.frame.inference_ms | number:'1.0-0' }}&thinsp;ms
+          &nbsp;|&nbsp;
+          Processing: {{ data.frame.processing_ms | number:'1.0-0' }}&thinsp;ms
+        </span>
+        <button mat-stroked-button (click)="close()">Close</button>
+      </div>
+    </div>
+  `,
+  styles: [`
+    :host { display: block; width: 100%; }
+
+    .inspector-shell {
+      display: flex;
+      flex-direction: column;
+      background: #1e272e;
+      border-radius: 8px;
+      overflow: hidden;
+      width: 100%;
+    }
+
+    .inspector-title {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 14px 20px 12px;
+      border-bottom: 1px solid rgba(255,255,255,0.10);
+      color: #fff;
+      .title-icon { color: #81c784; font-size: 1.2rem; width: 1.2rem; height: 1.2rem; }
+      span { flex: 1; font-size: 0.95rem; font-weight: 600; }
+      .close-btn { color: rgba(255,255,255,0.45); width: 32px; height: 32px; }
+    }
+
+    .inspector-body {
+      display: flex;
+      gap: 16px;
+      padding: 16px 20px;
+      max-height: 68vh;
+      overflow: hidden;
+    }
+
+    .inspector-canvas {
+      flex: 1;
+      min-width: 0;
+      max-width: 100%;
+      max-height: 65vh;
+      height: auto;
+      box-sizing: border-box;
+      border-radius: 8px;
+      display: block;
+      background: #111;
+    }
+
+    .det-list {
+      width: 168px;
+      flex-shrink: 0;
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+      overflow-y: auto;
+      max-height: 65vh;
+      padding-right: 2px;
+      scrollbar-width: thin;
+      scrollbar-color: rgba(255,255,255,0.15) transparent;
+    }
+
+    .det-row {
+      border-left: 3px solid;
+      border-radius: 0 4px 4px 0;
+      padding: 5px 8px;
+      background: rgba(255,255,255,0.05);
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      gap: 4px;
+    }
+
+    .det-class { font-size: 0.78rem; color: rgba(255,255,255,0.82); font-weight: 500; }
+    .det-conf  { font-size: 0.72rem; color: rgba(255,255,255,0.45); white-space: nowrap; }
+    .det-empty { font-size: 0.8rem; color: rgba(255,255,255,0.35); font-style: italic; margin: 0; }
+
+    .inspector-footer {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 20px 16px;
+      border-top: 1px solid rgba(255,255,255,0.10);
+      .timing-chip { font-size: 0.72rem; color: rgba(255,255,255,0.40); }
+      button { color: rgba(255,255,255,0.65); border-color: rgba(255,255,255,0.20); }
+    }
+
+    @media (max-width: 599px) {
+      .inspector-body {
+        flex-direction: column;
+        max-height: none;
+        overflow-y: auto;
+        padding: 12px 14px;
+        gap: 12px;
+      }
+
+      .inspector-canvas {
+        width: 100%;
+        max-height: 52vw;
+      }
+
+      .det-list {
+        width: 100%;
+        max-height: 180px;
+      }
+
+      .inspector-footer {
+        flex-direction: column;
+        align-items: flex-start;
+        gap: 8px;
+        padding: 10px 14px 14px;
+      }
+    }
+  `],
+})
+export class FrameInspectorDialogComponent implements AfterViewInit {
+  @ViewChild('inspectorCanvas') canvasRef!: ElementRef<HTMLCanvasElement>;
+
+  detections: any[];
+
+  constructor(
+    public dialogRef: MatDialogRef<FrameInspectorDialogComponent>,
+    @Inject(MAT_DIALOG_DATA) public data: FrameInspectorData,
+  ) {
+    const raw = data.frame?.detections;
+    this.detections = Array.isArray(raw)
+      ? raw
+      : (typeof raw === 'string' ? (() => { try { return JSON.parse(raw); } catch { return []; } })() : []);
+  }
+
+  ngAfterViewInit(): void {
+    this.paint();
+  }
+
+  close(): void {
+    this.dialogRef.close();
+  }
+
+  color(cls: string): string {
+    return this.data.mpobColors[cls] ?? '#757575';
+  }
+
+  private paint(): void {
+    const canvas = this.canvasRef?.nativeElement;
+    if (!canvas || !this.data.imgUrl) return;
+
+    const img = new Image();
+    img.onload = () => {
+      const maxW = Math.min(560, img.naturalWidth);
+      const scale = maxW / img.naturalWidth;
+      canvas.width = img.naturalWidth * scale;
+      canvas.height = img.naturalHeight * scale;
+      const ctx = canvas.getContext('2d')!;
+      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+
+      for (const det of this.detections) {
+        let x: number, y: number, w: number, h: number;
+        if (det.norm_box) {
+          const [nx1, ny1, nx2, ny2] = det.norm_box;
+          x = nx1 * canvas.width;
+          y = ny1 * canvas.height;
+          w = (nx2 - nx1) * canvas.width;
+          h = (ny2 - ny1) * canvas.height;
+        } else if (det.box) {
+          const [x1, y1, x2, y2] = det.box;
+          x = x1 * scale;
+          y = y1 * scale;
+          w = (x2 - x1) * scale;
+          h = (y2 - y1) * scale;
+        } else {
+          continue;
+        }
+
+        const color = this.color(det.class);
+        ctx.strokeStyle = color;
+        ctx.lineWidth = 2;
+        ctx.strokeRect(x, y, w, h);
+
+        const label = `${det.class} ${(det.confidence * 100).toFixed(1)}%`;
+        ctx.font = 'bold 12px sans-serif';
+        const tw = ctx.measureText(label).width;
+        ctx.fillStyle = color;
+        ctx.fillRect(x, y - 20, tw + 8, 20);
+        ctx.fillStyle = '#fff';
+        ctx.fillText(label, x + 4, y - 5);
+      }
+    };
+    img.src = this.data.imgUrl;
+  }
+}

+ 1 - 35
src/src.palm.vision/history/history.component.html

@@ -71,41 +71,7 @@
 
           @if (group.isExpanded) {
             <div class="batch-detail">
-              <div class="thumb-grid">
-                @for (item of group.items; track item.archive_id) {
-                  <div class="thumb-card">
-
-                    @if (item.imageDataUrl) {
-                      <canvas
-                        #thumbCanvas
-                        class="historical-frame-canvas"
-                        [attr.data-archive-id]="item.archive_id"
-                      ></canvas>
-                    } @else {
-                      <div class="thumb-placeholder">
-                        <mat-spinner diameter="24"></mat-spinner>
-                      </div>
-                    }
-
-                    <div class="thumb-metrics">
-                      <span>{{ item.total_count ?? 0 }} detections</span>
-                      <span>{{ item.inference_ms | number:'1.0-0' }}&nbsp;ms</span>
-                    </div>
-
-                    <button
-                      mat-icon-button
-                      class="item-delete-btn"
-                      color="warn"
-                      matTooltip="Delete frame"
-                      [disabled]="loading$ | async"
-                      (click)="onDeleteItem(item.archive_id, $event)"
-                    >
-                      <mat-icon>close</mat-icon>
-                    </button>
-
-                  </div>
-                }
-              </div>
+              <app-batch-report [batchId]="group.batchId"></app-batch-report>
             </div>
           }
 

+ 39 - 49
src/src.palm.vision/history/history.component.scss

@@ -204,67 +204,57 @@
   }
 }
 
-// ── Detail drawer ─────────────────────────────────────────────────────────────
+// ── Detail drawer (hosts BatchReportComponent) ────────────────────────────────
 
 .batch-detail {
-  padding: 1rem;
+  padding: 0 1rem;
   border-top: 1px solid #c8e6c9;
-  background: #fafafa;
+  background: #1e272e;
 }
 
-// ── Thumbnail grid ────────────────────────────────────────────────────────────
+// ── Mobile responsive overrides ───────────────────────────────────────────────
 
-.thumb-grid {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 0.75rem;
-}
-
-.thumb-card {
-  position: relative;
-  width: 160px;
-  border-radius: 8px;
-  overflow: hidden;
-  border: 1px solid #e0e0e0;
-  background: #f5f5f5;
-
-  .historical-frame-canvas {
-    width: 100%;
-    height: 120px;
-    display: block;
-    object-fit: cover;
+@media (max-width: 767px) {
+  .history-root {
+    padding: 1rem 0.75rem;
+    gap: 0.75rem;
   }
 
-  .thumb-placeholder {
-    width: 100%;
-    height: 120px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    background: #eeeeee;
-  }
+  .history-header {
+    flex-wrap: wrap;
+    gap: 0.5rem;
 
-  .thumb-metrics {
-    display: flex;
-    justify-content: space-between;
-    padding: 3px 6px;
-    background: rgba(0, 0, 0, 0.6);
-    font-size: 0.7rem;
-    color: #fff;
+    .clear-btn {
+      margin-left: 0;
+    }
   }
 
-  .item-delete-btn {
-    position: absolute;
-    top: 2px;
-    right: 2px;
-    width: 24px;
-    height: 24px;
-    line-height: 24px;
+  .batch-header {
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    padding: 0.65rem 0.75rem;
 
-    mat-icon {
-      font-size: 1rem;
-      width: 1rem;
-      height: 1rem;
+    .batch-meta {
+      min-width: 0;
+      flex: 1 1 auto;
     }
+
+    .batch-stats {
+      flex-basis: 100%;
+      order: 3;
+    }
+
+    .mode-badge {
+      order: 2;
+    }
+
+    .delete-btn {
+      order: 1;
+      margin-left: auto;
+    }
+  }
+
+  .batch-detail {
+    padding: 0 0.5rem;
   }
 }

+ 6 - 102
src/src.palm.vision/history/history.component.ts

@@ -1,11 +1,7 @@
 import {
-  AfterViewInit,
   Component,
-  ElementRef,
   OnDestroy,
   OnInit,
-  QueryList,
-  ViewChildren,
 } from '@angular/core';
 import { AsyncPipe, DatePipe, DecimalPipe, NgClass, SlicePipe } from '@angular/common';
 import { Select, Store } from '@ngxs/store';
@@ -19,10 +15,10 @@ import { VisionState } from '../store/vision.state';
 import {
   ClearAllHistory,
   DeleteHistoryRecord,
-  LoadGroupImages,
   LoadHistory,
   ToggleBatchGroup,
 } from '../store/vision.actions';
+import { BatchReportComponent } from './batch-report/batch-report.component';
 
 interface BatchGroup {
   batchId: string;
@@ -47,21 +43,18 @@ interface BatchGroup {
     MatIconModule,
     MatProgressSpinnerModule,
     MatTooltipModule,
+    BatchReportComponent,
   ],
   templateUrl: './history.component.html',
   styleUrl: './history.component.scss',
 })
-export class HistoryComponent implements OnInit, AfterViewInit, OnDestroy {
+export class HistoryComponent implements OnInit, OnDestroy {
   @Select(VisionState.items) items$!: Observable<any[]>;
   @Select(VisionState.expandedBatchIds) expandedBatchIds$!: Observable<string[]>;
   @Select(VisionState.loading) loading$!: Observable<boolean>;
 
-  @ViewChildren('thumbCanvas') thumbCanvasRefs!: QueryList<ElementRef<HTMLCanvasElement>>;
-
   groups$!: Observable<BatchGroup[]>;
-  private currentGroups: BatchGroup[] = [];
   private groupsSub!: Subscription;
-  private canvasSub!: Subscription;
 
   constructor(private store: Store) {}
 
@@ -69,24 +62,16 @@ export class HistoryComponent implements OnInit, AfterViewInit, OnDestroy {
     this.groups$ = combineLatest([this.items$, this.expandedBatchIds$]).pipe(
       map(([items, expandedIds]) => this.buildGroups(items, expandedIds)),
     );
-    this.groupsSub = this.groups$.subscribe(g => (this.currentGroups = g));
+    this.groupsSub = this.groups$.subscribe();
     this.store.dispatch(new LoadHistory());
   }
 
-  ngAfterViewInit(): void {
-    this.canvasSub = this.thumbCanvasRefs.changes.subscribe(() =>
-      this.paintAllVisibleCanvases(),
-    );
-  }
-
   ngOnDestroy(): void {
     this.groupsSub?.unsubscribe();
-    this.canvasSub?.unsubscribe();
   }
 
   onToggle(batchId: string): void {
     this.store.dispatch(new ToggleBatchGroup({ batchId }));
-    this.store.dispatch(new LoadGroupImages({ batchId }));
   }
 
   onDeleteBatch(group: BatchGroup, event: MouseEvent): void {
@@ -96,77 +81,10 @@ export class HistoryComponent implements OnInit, AfterViewInit, OnDestroy {
     }
   }
 
-  onDeleteItem(archiveId: string, event: MouseEvent): void {
-    event.stopPropagation();
-    this.store.dispatch(new DeleteHistoryRecord({ id: archiveId }));
-  }
-
   onClearAll(): void {
     this.store.dispatch(new ClearAllHistory());
   }
 
-  renderThumbnailWithBoxes(canvas: HTMLCanvasElement, item: any): void {
-    if (!canvas || !item?.imageDataUrl) return;
-    const img = new Image();
-    img.onload = () => {
-      canvas.width = canvas.offsetWidth || 148;
-      canvas.height = 120;
-      const ctx = canvas.getContext('2d')!;
-      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
-
-      for (const det of (item.detections ?? [])) {
-        let x: number, y: number, w: number, h: number;
-        if (det.norm_box) {
-          const [nx1, ny1, nx2, ny2] = det.norm_box;
-          x = nx1 * canvas.width;
-          y = ny1 * canvas.height;
-          w = (nx2 - nx1) * canvas.width;
-          h = (ny2 - ny1) * canvas.height;
-        } else if (det.box) {
-          const scaleX = canvas.width / img.naturalWidth;
-          const scaleY = canvas.height / img.naturalHeight;
-          const [x1, y1, x2, y2] = det.box;
-          x = x1 * scaleX;
-          y = y1 * scaleY;
-          w = (x2 - x1) * scaleX;
-          h = (y2 - y1) * scaleY;
-        } else {
-          continue;
-        }
-        const color = this.gradeColor(det.class);
-
-        ctx.strokeStyle = color;
-        ctx.lineWidth = 1.5;
-        ctx.strokeRect(x, y, w, h);
-
-        ctx.font = 'bold 9px sans-serif';
-        const label = det.class;
-        const tw = ctx.measureText(label).width;
-        ctx.fillStyle = color;
-        ctx.fillRect(x, y - 14, tw + 4, 14);
-        ctx.fillStyle = '#fff';
-        ctx.fillText(label, x + 2, y - 3);
-      }
-    };
-    img.src = item.imageDataUrl;
-  }
-
-  confidencePercent(value: number): string {
-    return (value * 100).toFixed(1) + '%';
-  }
-
-  gradeColor(cls: string): string {
-    const palette: Record<string, string> = {
-      Ripe: '#4caf50',
-      Unripe: '#ff9800',
-      Underripe: '#ffeb3b',
-      Overripe: '#9c27b0',
-      Abnormal: '#f44336',
-      Empty_Bunch: '#607d8b',
-    };
-    return palette[cls] ?? '#757575';
-  }
-
   private buildGroups(items: any[], expandedIds: string[]): BatchGroup[] {
     if (!items || !Array.isArray(items)) {
       return [];
@@ -174,7 +92,8 @@ export class HistoryComponent implements OnInit, AfterViewInit, OnDestroy {
 
     const groupMap = new Map<string, any[]>();
     for (const item of (items ?? [])) {
-      const bid = item.batch_id ?? 'unknown';
+      const bid = item.batch_id;
+      if (!bid) continue;
       if (!groupMap.has(bid)) groupMap.set(bid, []);
       groupMap.get(bid)!.push(item);
     }
@@ -197,19 +116,4 @@ export class HistoryComponent implements OnInit, AfterViewInit, OnDestroy {
       };
     });
   }
-
-  private paintAllVisibleCanvases(): void {
-    setTimeout(() => {
-      this.thumbCanvasRefs.forEach(ref => {
-        const canvas = ref.nativeElement;
-        const archiveId = canvas.getAttribute('data-archive-id');
-        const item = this.currentGroups
-          .flatMap(g => g.items)
-          .find(i => i.archive_id === archiveId);
-        if (item?.imageDataUrl) {
-          this.renderThumbnailWithBoxes(canvas, item);
-        }
-      });
-    }, 0);
-  }
 }

+ 4 - 0
src/src.palm.vision/services/remote-inference.service.ts

@@ -92,6 +92,10 @@ export class RemoteInferenceService implements OnDestroy {
     return this.send('PalmHistory', 'GetImage', { archiveId });
   }
 
+  getBatchDetails(batchId: string): Observable<any> {
+    return this.send<any>('History', 'getBatchDetails', { batchId });
+  }
+
   saveExternalResult(payload: EdgeResultPayload): Observable<any> {
     const adjustedPayload: any = {
       ...payload,

+ 14 - 5
src/src.palm.vision/store/vision.state.ts

@@ -159,6 +159,15 @@ export class VisionState implements NgxsOnInit {
               }),
             });
           }),
+          catchError(() => {
+            const current = ctx.getState().items;
+            ctx.patchState({
+              items: current.map((i: any) =>
+                i.archive_id === item.archive_id ? { ...i, imageDataUrl: 'error' } : i,
+              ),
+            });
+            return of(void 0);
+          }),
         ),
       ),
     ).pipe(map(() => void 0));
@@ -184,12 +193,12 @@ export class VisionState implements NgxsOnInit {
     ctx: StateContext<VisionStateModel>,
     { payload }: DeleteHistoryRecord,
   ): Observable<void> {
+    const { items } = ctx.getState();
+    ctx.patchState({ items: items.filter((i: any) => i.archive_id !== payload.id) });
     return this.remoteInferenceService.deleteRecord(payload.id).pipe(
-      tap(() => {
-        const { items } = ctx.getState();
-        ctx.patchState({
-          items: items.filter((i: any) => i.archive_id !== payload.id),
-        });
+      catchError(err => {
+        console.warn('[Vault State] Delete confirmation failed — record already removed from view:', err?.message || err);
+        return of(void 0);
       }),
       map(() => void 0),
     );