Files
Artemis-Eapil/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts
T

1206 lines
39 KiB
TypeScript

/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult } from '../../core/models/stream.model';
export const PLANNING_LOADER_PHRASES: string[] = [
'Planning next step...',
'Analyzing screen coordinates...',
'Consulting neural network...',
'Formulating tactical action plan...',
'Deciphering UI state & elements...',
'Synthesizing decision pathways...',
'Calibrating next move...',
'Aligning logical vectors...',
'Evaluating optimal sub-goals...',
'Gathering sensory inputs...',
'Computing next interaction...',
'Optimizing execution strategy...',
'Simulating probable outcomes...',
'Summoning AI intuition...',
'Brewing the next command...',
'Strategizing tactical moves...'
];
import {
parseNote,
parseNoteLines,
extractCheckerResult,
renderMarkdownToHtml
} from '../../utils/markdown-parser.util';
import {
getActionObject,
isAndroidAction,
getActionIcon,
getActionTitle,
getActionTargetText,
getActionInputLabel,
getActionInputText,
getActionCoords,
getActionBounds,
getActionResourceId,
getActionClass,
isActionFailed,
getActionErrorMessage,
extractActionExtraParams,
getStepPreImageUrl,
getStepPostImageUrl,
isReportStatusAction,
getReportStatusValue,
getReportStatusExplanation
} from '../../utils/action-formatter.util';
import {
getToolArgs,
isNoteTool,
isVideoTool,
getVideoToolTarget,
isDeviceActionTool,
isFailureAnalyzerActionTool,
getToolAgentName,
shouldShowTool,
getUniqueGenericTools,
getToolKey,
getToolDisplayLabel,
getToolIcon,
getToolTitle,
getToolTargetText,
getToolInputLabel,
getToolInputText,
getToolCoords,
getToolAnalysisText,
getToolGenericDetails,
isAdbCommandTool,
getAdbCommandLine,
getAdbCwd,
getAdbTerminalId,
isToolFailed,
getToolErrorMessage,
extractToolExtraParams,
isHumanThinking,
cleanErrorMessage
} from '../../utils/tool-formatter.util';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
import {
consolidateLogsToBlocks,
groupBlocksToPhases,
extractBlockTokens,
formatTokenCount,
getSortedStepEvents,
compileSessionSummary,
computeSessionDuration,
checkPlanningLoader
} from '../../utils/stream-aggregator.util';
export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss'
})
export class AgentStreamComponent implements AfterViewInit {
public agentService = inject(AgentService);
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
// Auto-scroll state tracking
public isUserAtBottom = true;
private resizeObserver: ResizeObserver | null = null;
// Dynamic Planning Loader Game-style Phrases
public currentPlanningText = signal<string>(PLANNING_LOADER_PHRASES[0]);
private planningIntervalId: any = null;
private currentPhraseIndex = 0;
// Top Nav Dropdown States
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
// Zoom Modal State
public selectedZoomImage = signal<string | null>(null);
public selectedZoomAction = signal<any | null>(null);
// Signals and properties for managing delayed collapsing of thought streams
public collapsedStreams = signal<Set<string>>(new Set<string>());
private scheduledCollapses = new Set<string>();
// Signals and maps for typewriter effect simulation
public typedTextsSignal = signal<Record<string, string>>({});
private typingTimers = new Map<string, any>();
// Set to track expanded and collapsed state of action cards
public expandedActionCards = signal<Set<string>>(new Set<string>());
public collapsedActionCards = signal<Set<string>>(new Set<string>());
public retryClock = signal<number>(Date.now());
// Performance caches for parameter and event extractions
private actionParamsCache = new WeakMap<any, ActionParam[]>();
private toolParamsCache = new WeakMap<any, ActionParam[]>();
private sortedEventsCache = new WeakMap<any, { length: number; actionTs: any; events: StepEvent[] }>();
// Top Nav Task Queue Computed Properties
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'paused' || status === 'pending';
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running' || statusA === 'paused') return -1;
if (statusB === 'running' || statusB === 'paused') return 1;
return a.start_time - b.start_time;
});
});
public historyTasks = computed(() => {
return this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'completed' || status === 'failed' || status === 'cancelled';
});
});
// Top Nav Notes Computed Properties
public currentNoteContent = computed(() => {
const notes = this.agentService.currentNotes();
const key = this.agentService.selectedNoteKey();
return notes[key] || '';
});
public noteKeys = computed(() => {
return Object.keys(this.agentService.currentNotes()).filter(key => key.toLowerCase().endsWith('.md'));
});
public outputterReport = computed(() => {
const notes = this.agentService.currentNotes();
return notes['output.md'] || null;
});
public parsedNote = computed<ParsedNote>(() => {
return this.getParsedNote(this.currentNoteContent());
});
// Stream Log Filtering & Aggregation Computed Properties
public filteredLogs = computed(() => {
const logs = this.agentService.sessionLogs();
const currentSessionId = this.agentService.currentSessionId();
if (!currentSessionId) return logs;
return logs.filter((log: any) => {
const logSessionId = log.session_id || log.data?.session_id;
return !logSessionId || logSessionId === currentSessionId;
});
});
public sessionSummary = computed(() => {
return compileSessionSummary(this.filteredLogs());
});
public showPlanningLoader = computed(() => {
const isRunning = this.agentService.agentStatus() === 'running';
const currentSessionId = this.agentService.currentSessionId();
const runningSessionId = this.agentService.runningSessionId();
const isCurrentRunningSession = !currentSessionId || currentSessionId === runningSessionId;
return checkPlanningLoader(this.filteredLogs(), isRunning, isCurrentRunningSession);
});
public isViewingPausedTask = computed(() => {
if (!this.agentService.isPaused()) return false;
const currentSessionId = this.agentService.currentSessionId();
const pausedSessionId = this.agentService.runningSessionId();
return !currentSessionId || currentSessionId === pausedSessionId;
});
public sessionDuration = computed(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return computeSessionDuration(currentSession?.start_time || 0, this.filteredLogs());
});
public consolidatedBlocks = computed<StepBlock[]>(() => {
return consolidateLogsToBlocks(this.filteredLogs());
});
public phases = computed<PhaseBlock[]>(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
});
private retryablePauseTrace = computed<any | null>(() => {
if (!this.agentService.isPaused()) return null;
const blocks = this.consolidatedBlocks();
for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) {
const tools = blocks[blockIndex]?.data?.generic_tools;
if (!Array.isArray(tools)) continue;
for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) {
const tool = tools[toolIndex];
if (
tool?.type === 'llm_call'
&& tool?.status === 'failed'
&& tool?.payload?.pause === true
) {
return tool;
}
}
}
return null;
});
public hasVisibleBlocks(phase: any): boolean {
if (!phase || !phase.blocks) return false;
return phase.blocks.some((b: any) => this.hasVisibleContent(b));
}
constructor() {
const retryClockInterval = setInterval(() => this.retryClock.set(Date.now()), 1000);
this.destroyRef.onDestroy(() => clearInterval(retryClockInterval));
// Auto scroll stream box during active text streaming
effect(() => {
const logs = this.filteredLogs();
const activeStream = logs.find(log => log.type === 'llm_stream' && !log.data?.isCompleted);
if (activeStream) {
setTimeout(() => {
const elements = document.getElementsByClassName('stream-box');
for (let i = 0; i < elements.length; i++) {
const el = elements[i] as HTMLElement;
el.scrollTop = el.scrollHeight;
}
}, 30);
}
});
// Auto scroll main log list when new logs are added or typewriter updates
effect(() => {
this.consolidatedBlocks();
this.typedTextsSignal();
setTimeout(() => {
const container = document.querySelector('.stream-logs-content');
if (container && this.isUserAtBottom) {
container.scrollTop = container.scrollHeight;
}
}, 50);
});
// Typewriter drive logic: triggers typing when block appears or expands
effect(() => {
const blocks = this.consolidatedBlocks();
blocks.forEach(block => {
const blockId = block.id;
const rawText = this.getRawThinking(block) || '';
const nativeText = this.getNativeThinking(block) || '';
untracked(() => {
// Handle Raw Thinking (Work)
const currentRecord = this.typedTextsSignal();
if (rawText) {
if (!(blockId in currentRecord)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText }));
} else {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: '' }));
this.startTyping(blockId, rawText, false);
}
} else {
const currentVal = currentRecord[blockId] || '';
if (currentVal.length < rawText.length && !this.typingTimers.has(blockId)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText }));
} else {
this.startTyping(blockId, rawText, false);
}
}
}
}
// Handle Native Thinking (Thought)
const nativeKey = blockId + '-native';
if (nativeText) {
const execId = block.data?.execution_id || block.data?.step_id || blockId;
if (!(nativeKey in currentRecord)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText }));
this.collapsedStreams.update(set => {
const newSet = new Set(set);
newSet.add(execId);
return newSet;
});
} else {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: '' }));
this.startTyping(nativeKey, nativeText, true, execId);
}
} else {
const currentVal = currentRecord[nativeKey] || '';
if (currentVal.length < nativeText.length && !this.typingTimers.has(nativeKey)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText }));
this.collapsedStreams.update(set => {
const newSet = new Set(set);
newSet.add(execId);
return newSet;
});
} else {
this.startTyping(nativeKey, nativeText, true, execId);
}
}
}
}
});
});
});
// Reset typewriter and collapse states when session changes
effect(() => {
this.agentService.currentSessionId();
untracked(() => {
this.typingTimers.forEach((timer) => clearInterval(timer));
this.typingTimers.clear();
this.typedTextsSignal.set({});
this.scheduledCollapses.clear();
this.collapsedStreams.set(new Set<string>());
this.isUserAtBottom = true;
setTimeout(() => {
const container = document.querySelector('.stream-logs-content');
if (container) {
container.scrollTop = container.scrollHeight;
}
}, 100);
});
});
// Periodically rotate loading text like game loading screen hints
effect(() => {
const isLoading = this.showPlanningLoader();
if (isLoading) {
if (!this.planningIntervalId) {
this.currentPhraseIndex = Math.floor(Math.random() * PLANNING_LOADER_PHRASES.length);
this.currentPlanningText.set(PLANNING_LOADER_PHRASES[this.currentPhraseIndex]);
this.planningIntervalId = setInterval(() => {
this.currentPhraseIndex = (this.currentPhraseIndex + 1) % PLANNING_LOADER_PHRASES.length;
this.currentPlanningText.set(PLANNING_LOADER_PHRASES[this.currentPhraseIndex]);
}, 2800);
}
} else {
if (this.planningIntervalId) {
clearInterval(this.planningIntervalId);
this.planningIntervalId = null;
}
}
});
this.destroyRef.onDestroy(() => {
if (this.planningIntervalId) {
clearInterval(this.planningIntervalId);
this.planningIntervalId = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
});
}
public ngAfterViewInit(): void {
const container = document.querySelector('.stream-logs-content');
if (container && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
if (this.isUserAtBottom) {
container.scrollTop = container.scrollHeight;
}
});
this.resizeObserver.observe(container);
}
}
public onStreamScroll(event: Event): void {
const container = event.target as HTMLElement;
if (!container) return;
const threshold = 150;
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
this.isUserAtBottom = distanceToBottom <= threshold;
}
// Top Nav Action Methods
public toggleTaskDropdown(event: Event): void {
event.stopPropagation();
this.isTaskDropdownOpen.update(v => !v);
if (this.isTaskDropdownOpen()) {
this.isNotesDropdownOpen.set(false);
}
}
public toggleNotesDropdown(event: Event): void {
event.stopPropagation();
this.isNotesDropdownOpen.update((v) => !v);
if (this.isNotesDropdownOpen()) {
this.isTaskDropdownOpen.set(false);
}
}
public openOutputterNote(event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectedNoteKey.set('output.md');
this.isNotesDropdownOpen.set(true);
this.isTaskDropdownOpen.set(false);
}
public closeAllDropdowns(): void {
this.isTaskDropdownOpen.set(false);
this.isNotesDropdownOpen.set(false);
}
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
return this.agentService.agentStatus() as 'running' | 'paused';
}
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'running' || s === 'paused' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
return s as any;
}
}
return 'completed';
}
public selectTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectSession(sessionId, true);
this.isTaskDropdownOpen.set(false);
}
public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation();
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
return;
}
this.agentService.deleteSession(sessionId).subscribe({
error: (err) => {
console.error(`Failed to delete session ${sessionId}:`, err);
}
});
}
public selectNote(key: string, event?: Event): void {
if (event) event.stopPropagation();
this.agentService.selectedNoteKey.set(key);
}
public getParsedNote(content: string): ParsedNote {
return parseNote(content);
}
public getParsedNoteLines(content: string): MarkdownLine[] {
return parseNoteLines(content);
}
// Typewriter & Delayed Collapse
private startTyping(key: string, targetText: string, isThinking: boolean = false, execId?: string) {
if (this.typingTimers.has(key)) {
clearInterval(this.typingTimers.get(key));
}
const interval = 12;
const timer = setInterval(() => {
const currentRecord = this.typedTextsSignal();
const current = currentRecord[key] || '';
const blocks = this.consolidatedBlocks();
const blockId = key.endsWith('-native') ? key.replace('-native', '') : key;
const block = blocks.find(b => b.id === blockId);
let target = targetText;
if (block) {
target = isThinking ? (this.getNativeThinking(block) || targetText) : (this.getRawThinking(block) || targetText);
}
if (current.length < target.length) {
const nextLength = Math.min(current.length + 8, target.length);
const newText = target.slice(0, nextLength);
this.typedTextsSignal.update(r => ({ ...r, [key]: newText }));
} else {
if (block?.data?.isCompleted) {
clearInterval(timer);
this.typingTimers.delete(key);
if (isThinking && execId) {
this.triggerDelayedCollapse(execId);
}
}
}
}, interval);
this.typingTimers.set(key, timer);
}
private triggerDelayedCollapse(execId: string) {
if (!this.scheduledCollapses.has(execId)) {
this.scheduledCollapses.add(execId);
setTimeout(() => {
this.collapsedStreams.update((set: Set<string>) => {
const newSet = new Set(set);
newSet.add(execId);
return newSet;
});
}, 1000);
}
}
public onDetailsToggle(event: Event, execId: string): void {
const details = event.target as HTMLDetailsElement;
const isOpen = details.open;
this.collapsedStreams.update((set: Set<string>) => {
const newSet = new Set(set);
if (isOpen) {
newSet.delete(execId);
} else {
newSet.add(execId);
}
return newSet;
});
}
public toggleCollapse(execId: string): void {
this.collapsedStreams.update((set: Set<string>) => {
const newSet = new Set(set);
if (newSet.has(execId)) {
newSet.delete(execId);
} else {
newSet.add(execId);
}
return newSet;
});
}
// Android Action Delegation Methods
public getActionObject(action: any): any {
return getActionObject(action);
}
public isAndroidAction(action: any): boolean {
return isAndroidAction(action);
}
public getActionIcon(action: any): string {
return getActionIcon(action);
}
public getActionTitle(action: any): string {
return getActionTitle(action);
}
public getActionTargetText(action: any): string {
return getActionTargetText(action);
}
public getActionInputLabel(action: any): string {
return getActionInputLabel(action);
}
public getActionInputText(action: any): string {
return getActionInputText(action);
}
public getActionCoords(action: any): string {
return getActionCoords(action);
}
public isActionFailed(action: any, stepData?: any): boolean {
return isActionFailed(action, stepData);
}
public getActionErrorMessage(action: any, stepData?: any): string {
return getActionErrorMessage(action, stepData);
}
public isActionExpanded(cardId: string, defaultExpanded: boolean = false): boolean {
if (this.collapsedActionCards().has(cardId)) return false;
if (this.expandedActionCards().has(cardId)) return true;
return defaultExpanded;
}
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
if (event) {
event.stopPropagation();
}
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
if (isCurrentlyExpanded) {
this.expandedActionCards.update(set => {
const newSet = new Set(set);
newSet.delete(cardId);
return newSet;
});
this.collapsedActionCards.update(set => {
const newSet = new Set(set);
newSet.add(cardId);
return newSet;
});
} else {
this.collapsedActionCards.update(set => {
const newSet = new Set(set);
newSet.delete(cardId);
return newSet;
});
this.expandedActionCards.update(set => {
const newSet = new Set(set);
newSet.add(cardId);
return newSet;
});
}
}
public getActionBounds(action: any): string {
return getActionBounds(action);
}
public getActionResourceId(action: any): string {
return getActionResourceId(action);
}
public getActionClass(action: any): string {
return getActionClass(action);
}
public getActionExtraParams(action: any): ActionParam[] {
return extractActionExtraParams(action, this.actionParamsCache);
}
public getToolExtraParams(toolData: any): ActionParam[] {
return extractToolExtraParams(toolData, this.toolParamsCache);
}
public getStepPreImageUrl(stepData: any, actionData?: any): string | null {
return getStepPreImageUrl(stepData, actionData);
}
public getStepPostImageUrl(stepData: any, actionData?: any): string | null {
return getStepPostImageUrl(stepData, actionData);
}
// Zoom Modal & Overlay Methods
public openImageModal(url: string, actionObj?: any, event?: Event): void {
if (event) {
event.stopPropagation();
}
this.selectedZoomImage.set(url);
this.selectedZoomAction.set(actionObj || null);
}
public closeImageModal(event?: Event): void {
if (event) {
event.stopPropagation();
}
this.selectedZoomImage.set(null);
this.selectedZoomAction.set(null);
}
public onImageLoad(img: HTMLImageElement, overlay: HTMLElement, actionObj: any): void {
if (!img || !overlay) return;
try {
this.drawActionCoordinatesOnOverlay(img, overlay, actionObj);
} catch (e) {
console.warn('Failed to draw action overlay:', e);
}
}
public drawActionCoordinatesOnOverlay(img: HTMLImageElement, overlay: HTMLElement, actionObj: any): void {
drawActionCoordinatesOnOverlay(img, overlay, actionObj);
}
// Generic Tool Delegation Methods
public isFirstSaveNoteForKey(tool: any): boolean {
if (!tool || !tool.name || tool.name.toLowerCase() !== 'save_note') {
return false;
}
const key = this.getToolKey(tool);
if (!key) return true;
const blocks = this.consolidatedBlocks();
const saveNoteCalls: any[] = [];
for (const block of blocks) {
if (block.type === 'step' && block.data.generic_tools) {
const uniqTools = this.getUniqueGenericTools(block.data.generic_tools);
for (const t of uniqTools) {
if (t.name && t.name.toLowerCase() === 'save_note' && this.getToolKey(t) === key) {
saveNoteCalls.push(t);
}
}
}
}
if (saveNoteCalls.length > 0) {
const firstCall = saveNoteCalls[0];
return (tool.trace_id && firstCall.trace_id)
? tool.trace_id === firstCall.trace_id
: tool === firstCall;
}
return true;
}
public getToolDisplayLabel(tool: any): string {
return getToolDisplayLabel(tool, this.isFirstSaveNoteForKey(tool));
}
public isNoteTool(tool: any): boolean {
return isNoteTool(tool);
}
public isVideoTool(tool: any): boolean {
return isVideoTool(tool);
}
public getVideoToolTarget(tool: any): string | null {
return getVideoToolTarget(tool);
}
public onVideoToolClick(toolData: any): void {
const curSessionId = this.agentService.currentSessionId();
if (curSessionId) {
this.agentService.openVideoPlayer(curSessionId);
}
}
public isDeviceActionTool(tool: any): boolean {
return isDeviceActionTool(tool);
}
public isFailureAnalyzerActionTool(tool: any): boolean {
return isDeviceActionTool(tool);
}
public getToolAgentName(tool: any): string | null {
return getToolAgentName(tool);
}
public shouldShowTool(tool: any, stepData?: any): boolean {
return shouldShowTool(tool, stepData);
}
public getUniqueGenericTools(tools: any[] | undefined): any[] {
return getUniqueGenericTools(tools);
}
public getSortedStepEvents(stepData: any): StepEvent[] {
return getSortedStepEvents(stepData, this.sortedEventsCache);
}
public getNativeThinking(block: any): string | null {
if (!block || !block.data) return null;
const text = block.data.operator_native_thinking || (block.data.stream_type === 'thinking' ? block.data.text : null);
return text && text.trim() ? text : null;
}
public getRawThinking(block: any): string | null {
if (!block || !block.data) return null;
const text = block.data.operator_raw_thinking || (block.data.stream_type === 'text' ? block.data.text : null);
if (!text || !text.trim() || !this.isHumanThinking(text)) return null;
return text;
}
public hasVisibleContent(block: any): boolean {
if (!block) return false;
const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block));
const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) =>
this.shouldShowTool(t, block.data)
|| this.isDisplayableLLMFailure(t)
|| this.isLLMRetry(t)
|| this.isReportStatusAction(t)
);
const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken));
return hasNative || hasRaw || hasVisibleTools || hasAndroidActions;
}
public isReportStatusAction(action: any): boolean {
return isReportStatusAction(action);
}
public getReportStatusValue(action: any): string {
return getReportStatusValue(action);
}
public getReportStatusExplanation(action: any): string {
return getReportStatusExplanation(action);
}
public isHumanThinking(text: string | null): boolean {
return isHumanThinking(text);
}
public isTerminalLLMFailure(tool: any): boolean {
return !!tool && this.retryablePauseTrace() === tool;
}
public isDisplayableLLMFailure(tool: any): boolean {
return tool?.type === 'llm_call'
&& tool?.status === 'failed'
&& (tool?.name === 'llm_pause' || tool?.payload?.pause === true);
}
public resumePausedTask(event: Event): void {
event.stopPropagation();
this.agentService.resumeTask();
}
public getLLMErrorText(tool: any): string {
if (!tool) return 'Unknown error';
const rawError = tool.payload?.error || tool.error;
if (!rawError) return 'Unknown error';
return this.cleanErrorMessage(rawError);
}
public isLLMRetry(tool: any): boolean {
return tool?.type === 'llm_call'
&& tool?.status === 'retrying'
&& tool?.payload?.source === 'provider_sdk'
&& ['google', 'gemini'].includes(String(tool?.payload?.provider || '').toLowerCase());
}
public getLLMRetryEntries(tool: any): any[] {
const retries = tool?.payload?.retries;
return Array.isArray(retries) && retries.length > 0 ? retries : [tool?.payload || tool];
}
public getLLMFailureRetryEntries(tool: any): any[] {
const retries = tool?.payload?.retries;
if (!Array.isArray(retries)) return [];
return retries.filter((retry: any) =>
retry?.source === 'provider_sdk'
&& ['google', 'gemini'].includes(String(retry?.provider || '').toLowerCase())
);
}
public hasLLMFailureRetryEntries(tool: any): boolean {
return this.getLLMFailureRetryEntries(tool).length > 0;
}
public getLLMRetryEntryDelay(entry: any): string | null {
const delay = Number(entry?.delay);
if (!Number.isFinite(delay) || delay <= 0) return null;
return `${delay.toFixed(2).replace(/\.00$/, '')}s`;
}
public getLLMRetryEntryWaited(entry: any): string {
const delay = Number(entry?.delay);
if (!Number.isFinite(delay) || delay <= 0) return '0s';
const rawStartedAt = Number(entry?.scheduled_at ?? entry?.timestamp);
if (!Number.isFinite(rawStartedAt) || rawStartedAt <= 0) {
return this.formatLLMDuration(delay);
}
const startedAtMs = rawStartedAt < 1e11 ? rawStartedAt * 1000 : rawStartedAt;
const elapsed = Math.min(delay, Math.max(0, (this.retryClock() - startedAtMs) / 1000));
return this.formatLLMDuration(elapsed);
}
public isLLMRetryEntryWaiting(entry: any): boolean {
const delay = Number(entry?.delay);
const rawStartedAt = Number(entry?.scheduled_at ?? entry?.timestamp);
if (!Number.isFinite(delay) || delay <= 0 || !Number.isFinite(rawStartedAt)) return false;
const startedAtMs = rawStartedAt < 1e11 ? rawStartedAt * 1000 : rawStartedAt;
return this.retryClock() < startedAtMs + delay * 1000;
}
public getLLMFailureWaited(tool: any): string | null {
const waited = Number(tool?.payload?.waited_seconds);
if (!Number.isFinite(waited) || waited < 0) return null;
return this.formatLLMDuration(waited);
}
public formatLLMDuration(seconds: number): string {
if (seconds < 0.05) return '0s';
if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`;
return `${Math.round(seconds)}s`;
}
public cleanErrorMessage(rawError: any): string {
return cleanErrorMessage(rawError);
}
public getToolKey(tool: any): string | null {
return getToolKey(tool);
}
public getToolCallName(action: any): string {
const act = this.getActionObject(action);
if (!act) return 'Tool';
const label = this.getToolDisplayLabel(act);
const key = this.getToolKey(act);
return key ? `${label}: ${key}` : label;
}
public getToolCallLabel(action: any): string {
return this.getToolCallName(action);
}
public isToolFailed(tool: any): boolean {
return isToolFailed(tool);
}
public getToolErrorMessage(tool: any): string {
return getToolErrorMessage(tool);
}
public getToolArgs(tool: any): any {
return getToolArgs(tool);
}
public getToolIcon(tool: any): string {
return getToolIcon(tool);
}
public getToolTitle(tool: any): string {
return getToolTitle(tool);
}
public getToolTargetText(tool: any): string {
return getToolTargetText(tool);
}
public getToolInputLabel(tool: any): string {
return getToolInputLabel(tool);
}
public getToolInputText(tool: any): string {
return getToolInputText(tool);
}
public getToolCoords(tool: any): string {
return getToolCoords(tool);
}
public getToolAnalysisText(tool: any): string {
return getToolAnalysisText(tool);
}
public getToolGenericDetails(tool: any): string {
return getToolGenericDetails(tool);
}
public isAdbCommandTool(tool: any): boolean {
return isAdbCommandTool(tool);
}
public getAdbCommandLine(tool: any): string {
return getAdbCommandLine(tool);
}
public getAdbCwd(tool: any): string {
return getAdbCwd(tool);
}
public getAdbTerminalId(tool: any): string {
return getAdbTerminalId(tool);
}
public onSessionChange(event: Event): void {
const selectElement = event.target as HTMLSelectElement;
const sessionId = selectElement.value;
if (sessionId) {
this.agentService.selectSession(sessionId, true);
}
}
public isStepEvent(eventType: string): boolean {
return eventType === 'step_recorded' || eventType === 'step_updated';
}
public getCheckerResult(text: string): CheckerResult | null {
return extractCheckerResult(text);
}
public isShortContent(data: any): boolean {
if (!data) return true;
const str = typeof data === 'string' ? data : JSON.stringify(data);
return str.length < 200;
}
public formatJson(data: any): string {
if (typeof data === 'string') {
try {
return JSON.stringify(JSON.parse(data), null, 2);
} catch {
return data;
}
}
return JSON.stringify(data, null, 2);
}
public getStatusLabel(status: string): string {
switch (status) {
case 'idle':
return 'Idle';
case 'running':
return 'Running';
case 'completed':
return 'Completed';
case 'offline':
return 'Offline';
default:
return status.toUpperCase();
}
}
public renderMarkdown(text: string): string {
return renderMarkdownToHtml(text);
}
public onToolKeyClick(key: string): void {
this.agentService.selectedNoteKey.set(key);
this.agentService.activeTab.set('notes');
const sessionId = this.agentService.currentSessionId();
if (sessionId) {
this.agentService.fetchNotes(sessionId);
}
}
public trackSession(index: number, session: Session): string {
return session.session_id;
}
public trackNoteKey(index: number, key: string): string {
return key;
}
public trackMilestone(index: number, milestone: NoteMilestone): number {
return milestone.index;
}
public trackMarkdownLine(index: number, line: MarkdownLine): number {
return index;
}
public trackPhase(index: number, phase: any): string {
return phase.id;
}
public trackBlock(index: number, block: any): string {
return block.id;
}
public trackTool(index: number, tool: any): string {
return tool.trace_id || index.toString();
}
public trackStepEvent(index: number, item: { type: string; data: any }): string {
if (!item || !item.data) return index.toString();
return item.type + '-' + (item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id || item.data.action || item.data.name || index);
}
public trackParam(index: number, param: { key: string; value: string }): string {
return param.key;
}
public getModelDisplayName(name: string | undefined | null): string {
if (!name) return 'Flash';
const lower = name.toLowerCase();
if (lower.includes('pro')) return 'Pro';
if (lower.includes('flash')) return 'Flash';
return name.replace(/^artemis\s+/i, '');
}
public getModelIcon(name: string | undefined | null): string {
if (!name) return 'bolt';
const lower = name.toLowerCase();
if (lower.includes('pro')) return 'diamond';
if (lower.includes('flash')) return 'bolt';
return 'smart_toy';
}
public getModelClass(name: string | undefined | null): string {
if (!name) return 'is-flash';
const lower = name.toLowerCase();
if (lower.includes('pro')) return 'is-pro';
if (lower.includes('flash')) return 'is-flash';
return '';
}
public formatTokenCount(tokens?: number): string {
return formatTokenCount(tokens);
}
public getTokenTooltip(phase: PhaseBlock): string {
if (!phase.tokens) return '';
if (phase.promptTokens && phase.completionTokens) {
return `Consumed: ${phase.tokens.toLocaleString()} tokens (${phase.promptTokens.toLocaleString()} in / ${phase.completionTokens.toLocaleString()} out)`;
}
return `Consumed: ${phase.tokens.toLocaleString()} tokens`;
}
public isCurrentSessionActive(): boolean {
const status = this.agentService.agentStatus();
if (status !== 'running') return false;
const currentId = this.agentService.currentSessionId();
const runningId = this.agentService.runningSessionId();
if (currentId && runningId && currentId !== runningId) {
return false;
}
const currentSession = this.agentService.sessions().find(s => s.session_id === currentId);
if (currentSession && (currentSession.status === 'completed' || currentSession.status === 'failed' || currentSession.status === 'cancelled')) {
return false;
}
return true;
}
public isItemRunning(item: any, block: any): boolean {
if (!item || !item.data) return false;
if (!this.isCurrentSessionActive()) return false;
if (item.data.status === 'success' || item.data.status === 'failed' || item.data.status === 'cancelled') return false;
if (this.isReportStatusAction(item.data) || this.isReportStatusAction(item.data?.action_taken)) return false;
if (item.data.status === 'running') return true;
// Check if session is currently running and this is the active trailing item
const blocks = this.consolidatedBlocks();
const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
if (lastBlock && (lastBlock.id === block?.id || !block?.data?.isCompleted)) {
const events = this.getSortedStepEvents(block.data);
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
if (lastEvent && (lastEvent.data?.trace_id === item.data?.trace_id || lastEvent === item)) {
return true;
}
}
return false;
}
public isBlockRunning(block: any): boolean {
if (!block || !block.data) return false;
if (!this.isCurrentSessionActive()) return false;
if (this.isReportStatusAction(block.data.action_taken) || block.data?.status === 'completed' || block.data?.status === 'failed' || block.data?.status === 'cancelled') return false;
if (block.data.isCompleted === false) return true;
const blocks = this.consolidatedBlocks();
const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null;
return lastBlock?.id === block.id;
}
}