feat(workbench): group live presentation steps into expandable categories
Group adjacent live display steps into category menus with merged snapshots, original-step submenus and scoped playback. The submenu exposes the latest raw step, so explicit selections must remain pinned. Locate failed and cancelled steps using the full group sequence, and prevent an exiting menu from dismissing a trigger-owned reopen. Regression tests reproduce these paths before the fixes. Validation: lint, typecheck, 1178 unit tests and 10 focused browser cases passed; update the presentation history guide.
This commit is contained in:
@@ -28,6 +28,11 @@ export type PresentationHistoryEntry = {
|
||||
id: number;
|
||||
key?: string;
|
||||
taskId?: string;
|
||||
executionGeneration?: number;
|
||||
mergedStepIds?: number[];
|
||||
mergedSummary?: string;
|
||||
category?: PresentationHistoryCategory;
|
||||
chartId?: string;
|
||||
label: string;
|
||||
source: InvestigationPresentationActivity["source"];
|
||||
taskTitle: string | null;
|
||||
@@ -36,12 +41,16 @@ export type PresentationHistoryEntry = {
|
||||
message?: string;
|
||||
snapshot: PresentationView;
|
||||
};
|
||||
export type PresentationHistoryCategory = "charts" | "scada" | "map" | "overlay" | "results";
|
||||
export type PresentationHistoryControls = {
|
||||
currentEntryId: number | null;
|
||||
entries: PresentationHistoryEntry[];
|
||||
selectedId: number | null;
|
||||
mergedEntry?: PresentationHistoryEntry | null;
|
||||
mergedEntries?: PresentationHistoryEntry[];
|
||||
onSelect: (id: number | null) => void;
|
||||
playback?: {
|
||||
entries?: PresentationHistoryEntry[];
|
||||
playing: boolean;
|
||||
speed: number;
|
||||
onToggle: () => void;
|
||||
@@ -66,8 +75,13 @@ export class PresentationViewHistory {
|
||||
entries: PresentationHistoryEntry[] = [];
|
||||
selectedId: number | null = null;
|
||||
playing = false;
|
||||
playbackIds: number[] = [];
|
||||
playbackEntries: PresentationHistoryEntry[] = [];
|
||||
private sequence = 0;
|
||||
private selectedMerged: PresentationHistoryEntry | null = null;
|
||||
|
||||
get playbackIds() {
|
||||
return this.playbackEntries.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
get currentEntryId() {
|
||||
if (this.entries.at(-1)?.status === "running") return null;
|
||||
@@ -86,7 +100,9 @@ export class PresentationViewHistory {
|
||||
);
|
||||
}
|
||||
get selected() {
|
||||
return this.entries.find((entry) => entry.id === this.selectedId) ?? null;
|
||||
if (this.selectedId === this.selectedMerged?.id) return this.selectedMerged;
|
||||
return this.playbackEntries.find((entry) => entry.id === this.selectedId)
|
||||
?? this.entries.find((entry) => entry.id === this.selectedId) ?? null;
|
||||
}
|
||||
get visible() {
|
||||
return this.selected?.snapshot ?? this.current;
|
||||
@@ -98,7 +114,7 @@ export class PresentationViewHistory {
|
||||
label: string,
|
||||
source: PresentationHistoryEntry["source"],
|
||||
taskTitle: string | null,
|
||||
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
|
||||
metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {}
|
||||
) {
|
||||
const existing = metadata.key ? this.entries.find((entry) => entry.key === metadata.key) : null;
|
||||
const id = existing?.id ?? ++this.sequence;
|
||||
@@ -133,7 +149,7 @@ export class PresentationViewHistory {
|
||||
label: string,
|
||||
source: PresentationHistoryEntry["source"],
|
||||
taskTitle: string | null,
|
||||
metadata: { key?: string; taskId?: string; createdAt?: number } = {}
|
||||
metadata: { key?: string; taskId?: string; executionGeneration?: number; createdAt?: number; category?: PresentationHistoryCategory; chartId?: string } = {}
|
||||
) {
|
||||
const id = this.begin(label, source, taskTitle, metadata);
|
||||
this.finish(id, "completed");
|
||||
@@ -144,32 +160,54 @@ export class PresentationViewHistory {
|
||||
if (id !== null && !this.entries.some((entry) => entry.id === id && entry.status !== "running"))
|
||||
return false;
|
||||
this.selectedId = id;
|
||||
this.selectedMerged = null;
|
||||
this.playbackEntries = [];
|
||||
return true;
|
||||
}
|
||||
selectMerged(entry: PresentationHistoryEntry) {
|
||||
this.pausePlayback();
|
||||
this.selectedMerged = entry;
|
||||
this.selectedId = entry.id;
|
||||
this.playbackEntries = [];
|
||||
}
|
||||
startPlayback(entries: PresentationHistoryEntry[]) {
|
||||
this.playbackIds = entries.filter((entry) => entry.status === "completed").map((entry) => entry.id);
|
||||
if (!this.playbackIds.length) return false;
|
||||
const index = this.playbackIds.indexOf(this.selectedId ?? -1);
|
||||
if (index < 0 || index === this.playbackIds.length - 1) this.selectedId = this.playbackIds[0];
|
||||
const completed = entries.filter((entry) => entry.status === "completed");
|
||||
if (!completed.length) return false;
|
||||
const index = completed.findIndex((entry) => entry.id === this.selectedId ||
|
||||
(this.selectedId !== null && entry.mergedStepIds?.includes(this.selectedId)));
|
||||
this.seekPlayback(completed[index < 0 || index === completed.length - 1 ? 0 : index].id, completed);
|
||||
this.playing = true;
|
||||
return true;
|
||||
}
|
||||
seekPlayback(id: number, entries = this.playbackEntries) {
|
||||
const completed = entries.filter((entry) => entry.status === "completed");
|
||||
const entry = completed.find((entry) => entry.id === id);
|
||||
if (!entry) return false;
|
||||
this.pausePlayback();
|
||||
this.playbackEntries = completed;
|
||||
this.selectedMerged = entry.mergedStepIds ? entry : null;
|
||||
this.selectedId = entry.id;
|
||||
return true;
|
||||
}
|
||||
advancePlayback() {
|
||||
if (!this.playing) return false;
|
||||
const next = this.playbackIds[this.playbackIds.indexOf(this.selectedId ?? -1) + 1];
|
||||
const index = this.playbackEntries.findIndex((entry) => entry.id === this.selectedId);
|
||||
const next = this.playbackEntries[index + 1];
|
||||
if (next === undefined) {
|
||||
this.pausePlayback();
|
||||
return false;
|
||||
}
|
||||
this.selectedId = next;
|
||||
this.selectedMerged = next.mergedStepIds ? next : null;
|
||||
this.selectedId = next.id;
|
||||
return true;
|
||||
}
|
||||
pausePlayback() { this.playing = false; }
|
||||
reset() {
|
||||
this.pausePlayback();
|
||||
this.playbackIds = [];
|
||||
this.playbackEntries = [];
|
||||
this.current = emptyPresentationView();
|
||||
this.entries = [];
|
||||
this.selectedId = null;
|
||||
this.selectedMerged = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user