feat: unify history tools and improve execution reliability
This commit is contained in:
1 parent
d23a4f6472
commit
2fea9865ee
116 files changed
+8291
-2503
No files matched your search
@@ -387,17 +387,95 @@ limitations under the License.
|
||||
<!-- Floating Top-Right Badges & Toolbar (Pinned overlay, hidden in empty/initial state) -->
|
||||
@if (consolidatedBlocks().length > 0 || showPlanningLoader()) {
|
||||
<div class="stream-floating-toolbar">
|
||||
<!-- Active Architecture Info Chip (Informational Metadata, Not a Button) -->
|
||||
@if (agentService.activeModel(); as model) {
|
||||
<div
|
||||
<!-- Active Architecture Chip: click opens the run info popover -->
|
||||
@if (agentService.viewedModel(); as model) {
|
||||
<button
|
||||
type="button"
|
||||
#runInfoOrigin="cdkOverlayOrigin"
|
||||
cdkOverlayOrigin
|
||||
class="model-info-chip"
|
||||
[ngClass]="getModelClass(model.name)"
|
||||
[class.active]="isRunInfoOpen()"
|
||||
[title]="getArchitectureTooltip(model)"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="isRunInfoOpen()"
|
||||
(click)="toggleRunInfo($event)"
|
||||
>
|
||||
<span class="material-symbols-outlined model-icon">{{ getModelIcon(model.name) }}</span>
|
||||
<span class="model-label">Architecture:</span>
|
||||
<span class="model-name">{{ getModelDisplayName(model.name) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<ng-template
|
||||
cdkConnectedOverlay
|
||||
[cdkConnectedOverlayOrigin]="runInfoOrigin"
|
||||
[cdkConnectedOverlayOpen]="isRunInfoOpen()"
|
||||
[cdkConnectedOverlayHasBackdrop]="true"
|
||||
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
|
||||
(backdropClick)="isRunInfoOpen.set(false)"
|
||||
[cdkConnectedOverlayPositions]="dropdownPositions"
|
||||
>
|
||||
<div class="vscode-dropdown-menu run-info-dropdown" role="dialog" aria-label="Run info">
|
||||
<div class="run-info-header">
|
||||
<span class="material-symbols-outlined" [class.is-pro]="runInfoIsPro()">{{ getModelIcon(model.name) }}</span>
|
||||
<span class="run-info-title">{{ getModelDisplayName(model.name) }}</span>
|
||||
@if (model.id) {
|
||||
<span class="run-info-model" [title]="model.id">{{ model.id }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Elapsed</span>
|
||||
<span class="run-info-value">{{ runInfoElapsed() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Tokens</span>
|
||||
@if (runUsage(); as usage) {
|
||||
<span class="run-info-value">
|
||||
{{ formatRunTokens(usage.total_tokens) }}
|
||||
<span class="run-info-sub">{{ formatRunTokens(usage.prompt_tokens) }} in · {{ formatRunTokens(usage.completion_tokens) }} out</span>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="run-info-value is-muted">—</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="run-info-row is-stacked">
|
||||
<div class="run-info-line">
|
||||
<span class="run-info-label">Context</span>
|
||||
@if (runInfoContextPercent() !== null) {
|
||||
<span class="run-info-value">
|
||||
{{ runInfoContextPercent() }}%
|
||||
<span class="run-info-sub-inline">of {{ formatRunTokens(runUsage()?.operator_context_window_tokens) }}</span>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="run-info-value is-muted">—</span>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="run-info-meter"
|
||||
[class.is-high]="(runInfoContextPercent() ?? 0) >= 70"
|
||||
[class.is-critical]="(runInfoContextPercent() ?? 0) >= 90"
|
||||
>
|
||||
<div class="run-info-meter-fill" [style.width.%]="runInfoContextPercent() ?? 0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (runInfoIsPro()) {
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Result check</span>
|
||||
<span class="run-info-value">{{ runTuningLabel('verify', runInfoTuning()?.verification_level) }}</span>
|
||||
</div>
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Screen reading</span>
|
||||
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="run-info-note">No extra settings for Flash.</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
<!-- Screen Recording Action Button (Interactive Button) -->
|
||||
<button
|
||||
|
||||
@@ -224,21 +224,44 @@ $stream-row-height: 25px;
|
||||
top: 72px;
|
||||
}
|
||||
|
||||
/* Model Info Chip: Informational Tag / Metadata (Not clickable) */
|
||||
/* Model Info Chip: click opens the run info popover */
|
||||
.model-info-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||
border-radius: 15px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(203, 213, 225, 0.95);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
|
||||
.model-icon,
|
||||
.model-name {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #93c5fd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -3044,6 +3067,137 @@ $stream-row-height: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Run info popover: elapsed, tokens, context share, Pro tuning */
|
||||
&.run-info-dropdown {
|
||||
width: 288px;
|
||||
gap: 0;
|
||||
padding: 12px 14px 10px;
|
||||
|
||||
.run-info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
color: #64748b;
|
||||
|
||||
&.is-pro {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.run-info-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.run-info-model {
|
||||
margin-left: auto;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.run-info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.is-stacked {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.run-info-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.run-info-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-info-value {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-muted {
|
||||
color: #cbd5e1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.run-info-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.run-info-sub-inline {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.run-info-meter {
|
||||
height: 4px;
|
||||
margin-top: 7px;
|
||||
border-radius: 2px;
|
||||
background: #e2e8f0;
|
||||
overflow: hidden;
|
||||
|
||||
.run-info-meter-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #2563eb;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&.is-high .run-info-meter-fill {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
&.is-critical .run-info-meter-fill {
|
||||
background: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.run-info-note {
|
||||
padding-top: 9px;
|
||||
font-size: 11.5px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
&.notes-dropdown {
|
||||
width: 380px;
|
||||
max-height: 520px;
|
||||
|
||||
@@ -20,10 +20,18 @@ import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { AgentService, StartupProgressEvent } from '../../services/agent.service';
|
||||
import { Session, ModelInfo } from '../../core/models/session.model';
|
||||
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
|
||||
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
|
||||
import {
|
||||
contextPercent,
|
||||
formatCompactTokens,
|
||||
formatElapsed,
|
||||
parseRunTuning,
|
||||
sessionElapsedSeconds,
|
||||
tuningLabel
|
||||
} from '../../utils/run-info.util';
|
||||
|
||||
export const PLANNING_LOADER_PHRASES: string[] = [
|
||||
'Planning next step...',
|
||||
@@ -235,6 +243,34 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public isTaskDropdownOpen = signal<boolean>(false);
|
||||
public isNotesDropdownOpen = signal<boolean>(false);
|
||||
|
||||
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
|
||||
// usage, executor context share and the Pro tuning the run started with.
|
||||
public isRunInfoOpen = signal<boolean>(false);
|
||||
public runUsage = signal<SessionUsage | null>(null);
|
||||
private runInfoClock = signal<number>(Date.now());
|
||||
|
||||
public runInfoIsPro = computed(() => this.getModelClass(this.agentService.viewedModel()?.name) === 'is-pro');
|
||||
|
||||
public runInfoElapsed = computed(() => {
|
||||
const session = this.agentService.currentSession();
|
||||
const running = this.agentService.isCurrentSessionRunning();
|
||||
return formatElapsed(sessionElapsedSeconds(session, running, this.runInfoClock()));
|
||||
});
|
||||
|
||||
public runInfoTuning = computed(() => {
|
||||
const usage = this.runUsage();
|
||||
if (usage?.run_tuning && (usage.run_tuning.verification_level || usage.run_tuning.explorer_mode)) {
|
||||
return usage.run_tuning;
|
||||
}
|
||||
return parseRunTuning(this.agentService.currentSession()?.device_info);
|
||||
});
|
||||
|
||||
public runInfoContextPercent = computed(() => {
|
||||
const usage = this.runUsage();
|
||||
if (!usage) return null;
|
||||
return contextPercent(usage.operator_context_tokens, usage.operator_context_window_tokens);
|
||||
});
|
||||
|
||||
// CDK Connected Overlay Positioning Strategy
|
||||
public readonly dropdownPositions: ConnectedPosition[] = [
|
||||
{
|
||||
@@ -476,6 +512,28 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Run info popover: fetch usage when opened or when the viewed session
|
||||
// changes, and keep refreshing while that session is still running.
|
||||
effect((onCleanup) => {
|
||||
const open = this.isRunInfoOpen();
|
||||
const sessionId = this.agentService.currentSessionId();
|
||||
const running = this.agentService.isCurrentSessionRunning();
|
||||
if (!open || !sessionId) return;
|
||||
if (untracked(() => this.runUsage())?.session_id !== sessionId) {
|
||||
this.runUsage.set(null);
|
||||
}
|
||||
this.loadRunUsage(sessionId);
|
||||
if (!running) return;
|
||||
const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000);
|
||||
onCleanup(() => clearInterval(usageInterval));
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return;
|
||||
this.runInfoClock.set(Date.now());
|
||||
const clockInterval = setInterval(() => this.runInfoClock.set(Date.now()), 1000);
|
||||
onCleanup(() => clearInterval(clockInterval));
|
||||
});
|
||||
|
||||
// The retry clock only needs to tick while something on screen is timing
|
||||
// (startup progress, LLM retry countdowns). An idle page stays quiet.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
@@ -758,6 +816,38 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public closeAllDropdowns(): void {
|
||||
this.isTaskDropdownOpen.set(false);
|
||||
this.isNotesDropdownOpen.set(false);
|
||||
this.isRunInfoOpen.set(false);
|
||||
}
|
||||
|
||||
public toggleRunInfo(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.isRunInfoOpen.update((v) => !v);
|
||||
if (this.isRunInfoOpen()) {
|
||||
this.isTaskDropdownOpen.set(false);
|
||||
this.isNotesDropdownOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private loadRunUsage(sessionId: string): void {
|
||||
this.agentService.getSessionUsage(sessionId).subscribe({
|
||||
next: (usage) => {
|
||||
if (this.agentService.currentSessionId() !== sessionId) return;
|
||||
this.runUsage.set(usage);
|
||||
},
|
||||
error: (err) => console.error('Failed to load session usage:', err)
|
||||
});
|
||||
}
|
||||
|
||||
public formatRunElapsed(seconds: number): string {
|
||||
return formatElapsed(seconds);
|
||||
}
|
||||
|
||||
public formatRunTokens(tokens: number | null | undefined): string {
|
||||
return formatCompactTokens(tokens);
|
||||
}
|
||||
|
||||
public runTuningLabel(kind: 'verify' | 'explore', id: string | null | undefined): string {
|
||||
return tuningLabel(kind, id);
|
||||
}
|
||||
|
||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||
|
||||
+18
-6
@@ -33,6 +33,7 @@ import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
|
||||
import { getActionIcon } from '../../utils/action-formatter.util';
|
||||
import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
@@ -179,7 +180,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
if (video && video.readyState >= 1) {
|
||||
const target = this.pendingAbsoluteSeek;
|
||||
this.pendingAbsoluteSeek = null;
|
||||
this.seek(target, true);
|
||||
this.seekToSessionTime(target, true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -302,7 +303,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
if (this.pendingAbsoluteSeek !== null) {
|
||||
const target = this.pendingAbsoluteSeek;
|
||||
this.pendingAbsoluteSeek = null;
|
||||
this.seek(target, true);
|
||||
this.seekToSessionTime(target, true);
|
||||
// A seek into another finalized segment changes the video source. Let
|
||||
// that segment's metadata event apply pendingLocalTime.
|
||||
if (this.pendingLocalTime !== null) return;
|
||||
@@ -382,19 +383,30 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.seek(0, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a session-relative time (seconds since the task session started,
|
||||
* the axis step timestamps and analyzer ranges use). Manifest v2 segments
|
||||
* carry real session offsets, so the time lands in the right segment even
|
||||
* across scrcpy restart gaps; legacy playlists fall back to the timeline axis.
|
||||
*/
|
||||
public seekToSessionTime(sessionSeconds: number, autoplay = false): void {
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay);
|
||||
}
|
||||
|
||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||
public seek(seconds: number, autoplay = false): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v) return;
|
||||
const target = Math.max(0, Math.min(this.duration(), seconds));
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
if (!segments.length) {
|
||||
const location = locateTimelineTime(segments, target);
|
||||
if (!location) {
|
||||
v.currentTime = target;
|
||||
if (autoplay) v.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
let index = segments.findIndex(segment => target < segment.start + segment.duration);
|
||||
if (index < 0) index = segments.length - 1;
|
||||
const localTime = Math.max(0, target - segments[index].start);
|
||||
const { index, localTime } = location;
|
||||
if (index === this.activeSegmentIndex()) {
|
||||
v.currentTime = localTime;
|
||||
if (autoplay) v.play().catch(() => {});
|
||||
|
||||
Reference in new issue
Block a user