feat: unify history tools and improve execution reliability

This commit is contained in:
somew1nd committed 2026-09-03 16:05:47 -07:00
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' {
@@ -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(() => {});