Agent logs should be collapsable (vibe-kanban) (#451)
* Add collapsible agent log headers - Make process headers (Setup Script, Coding Agent, Cleanup Script) clickable to hide/show logs - Add chevron icon with rotation animation to indicate collapsed state - Filter log entries to hide those from collapsed processes while preserving virtualization - Maintain scroll position and follow-output behavior when toggling collapse - Support keyboard navigation (Enter/Space) for accessibility Amp-Thread: https://ampcode.com/threads/T-f44b0256-de19-45e1-96cb-df755553716d Co-authored-by: Amp <amp@ampcode.com> * Cleanup script changes for task attempt 990e23da-53ff-4203-ac58-f5b28653bc1f * Commit changes from coding agent for task attempt 990e23da-53ff-4203-ac58-f5b28653bc1f * Cleanup (and adjust lint) * Enhance agent log collapsibility with auto-hide and dev server filtering - Filter dev server processes from logs tab to reduce noise - Auto-collapse completed setup/cleanup scripts on initial load for cleaner UX - Auto-expand scripts that restart after completion - Add process constants for type safety and consistency - Separate auto-collapsed and user-collapsed state management - Maintain scroll position and user preferences throughout Amp-Thread: https://ampcode.com/threads/T-f44b0256-de19-45e1-96cb-df755553716d Co-authored-by: Amp <amp@ampcode.com> * Cleanup script changes for task attempt 990e23da-53ff-4203-ac58-f5b28653bc1f * Add coding agent auto-collapse for cleaner log focus - Auto-collapse all non-latest coding agents on task load for focused viewing - Detect new coding agent starts (follow-ups) and collapse previous agents - Latest coding agent always remains expanded for active monitoring - Robust latest agent detection with timestamp tie-breaking - One-shot initial collapse prevents duplicate processing - Smart follow-up detection tracks new running agents - User manual toggles permanently override auto-collapse behavior - Comprehensive state reset on attempt changes Amp-Thread: https://ampcode.com/threads/T-f44b0256-de19-45e1-96cb-df755553716d Co-authored-by: Amp <amp@ampcode.com> * Cleanup script changes for task attempt 990e23da-53ff-4203-ac58-f5b28653bc1f * Fix timing issue with coding agent auto-collapse - Only mark initial collapse as complete when coding agents are actually processed - Prevents race condition where flag was set before real data arrived - Ensures auto-collapse logic runs after data loads, not during empty state - Fixes issue where all coding agents remained expanded instead of latest-only Amp-Thread: https://ampcode.com/threads/T-f44b0256-de19-45e1-96cb-df755553716d Co-authored-by: Amp <amp@ampcode.com> * Cleanup script changes for task attempt 990e23da-53ff-4203-ac58-f5b28653bc1f * refactor * lints * prettier --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
committed by
GitHub
parent
59c977e235
commit
69cda33532
@@ -1,15 +1,25 @@
|
|||||||
{
|
{
|
||||||
"root": true,
|
"root": true,
|
||||||
"env": { "browser": true, "es2020": true },
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2020": true
|
||||||
|
},
|
||||||
"extends": [
|
"extends": [
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"prettier"
|
"prettier"
|
||||||
],
|
],
|
||||||
"ignorePatterns": ["dist", ".eslintrc.json"],
|
"ignorePatterns": [
|
||||||
|
"dist",
|
||||||
|
".eslintrc.json"
|
||||||
|
],
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": ["react-refresh", "@typescript-eslint"],
|
"plugins": [
|
||||||
|
"react-refresh",
|
||||||
|
"@typescript-eslint",
|
||||||
|
"unused-imports"
|
||||||
|
],
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"ecmaVersion": "latest",
|
"ecmaVersion": "latest",
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
@@ -17,9 +27,19 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"react-refresh/only-export-components": [
|
"react-refresh/only-export-components": [
|
||||||
"warn",
|
"warn",
|
||||||
{ "allowConstantExport": true }
|
{
|
||||||
|
"allowConstantExport": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"vars": "all",
|
||||||
|
"args": "after-used",
|
||||||
|
"ignoreRestSiblings": false
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"@typescript-eslint/no-unused-vars": "error",
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn"
|
"@typescript-eslint/no-explicit-any": "warn"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
52
frontend/package-lock.json
generated
52
frontend/package-lock.json
generated
@@ -40,7 +40,8 @@
|
|||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"rfc6902": "^5.1.2",
|
"rfc6902": "^5.1.2",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-debounce": "^10.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.0",
|
"eslint-plugin-prettier": "^5.5.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"prettier": "^3.6.1",
|
"prettier": "^3.6.1",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
@@ -1122,6 +1124,15 @@
|
|||||||
"lowlight": "^3.3.0"
|
"lowlight": "^3.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@git-diff-view/file/node_modules/diff": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@git-diff-view/lowlight": {
|
"node_modules/@git-diff-view/lowlight": {
|
||||||
"version": "0.0.30",
|
"version": "0.0.30",
|
||||||
"resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.30.tgz",
|
"resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.30.tgz",
|
||||||
@@ -3805,15 +3816,6 @@
|
|||||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
|
||||||
@@ -4081,6 +4083,22 @@
|
|||||||
"eslint": ">=8.40"
|
"eslint": ">=8.40"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-unused-imports": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
|
||||||
|
"eslint": "^9.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@typescript-eslint/eslint-plugin": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-scope": {
|
"node_modules/eslint-scope": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||||
@@ -7441,6 +7459,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-debounce": {
|
||||||
|
"version": "10.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz",
|
||||||
|
"integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-isomorphic-layout-effect": {
|
"node_modules/use-isomorphic-layout-effect": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||||
@@ -7778,4 +7808,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.5.0",
|
"eslint-plugin-prettier": "^5.5.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"prettier": "^3.6.1",
|
"prettier": "^3.6.1",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
|
|||||||
@@ -11,9 +11,18 @@ interface LogEntryRowProps {
|
|||||||
index: number;
|
index: number;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
setRowHeight?: (index: number, height: number) => void;
|
setRowHeight?: (index: number, height: number) => void;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onToggleCollapse?: (processId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LogEntryRow({ entry, index, style, setRowHeight }: LogEntryRowProps) {
|
function LogEntryRow({
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
style,
|
||||||
|
setRowHeight,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
}: LogEntryRowProps) {
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,6 +51,8 @@ function LogEntryRow({ entry, index, style, setRowHeight }: LogEntryRowProps) {
|
|||||||
return (
|
return (
|
||||||
<ProcessStartCard
|
<ProcessStartCard
|
||||||
payload={entry.payload as ProcessStartPayload}
|
payload={entry.payload as ProcessStartPayload}
|
||||||
|
isCollapsed={isCollapsed || false}
|
||||||
|
onToggle={onToggleCollapse || (() => {})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { Clock, Cog, Play, Terminal, Code } from 'lucide-react';
|
import { Clock, Cog, Play, Terminal, Code, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import type { ProcessStartPayload } from '@/types/logs';
|
import type { ProcessStartPayload } from '@/types/logs';
|
||||||
|
|
||||||
interface ProcessStartCardProps {
|
interface ProcessStartCardProps {
|
||||||
payload: ProcessStartPayload;
|
payload: ProcessStartPayload;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggle: (processId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProcessStartCard({ payload }: ProcessStartCardProps) {
|
function ProcessStartCard({
|
||||||
|
payload,
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
|
}: ProcessStartCardProps) {
|
||||||
const getProcessIcon = (runReason: string) => {
|
const getProcessIcon = (runReason: string) => {
|
||||||
switch (runReason) {
|
switch (runReason) {
|
||||||
case 'setupscript':
|
case 'setupscript':
|
||||||
@@ -40,9 +47,26 @@ function ProcessStartCard({ payload }: ProcessStartCardProps) {
|
|||||||
return new Date(dateString).toLocaleTimeString();
|
return new Date(dateString).toLocaleTimeString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
onToggle(payload.processId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggle(payload.processId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pt-4 pb-2">
|
<div className="px-4 pt-4 pb-2">
|
||||||
<div className="bg-muted/50 border border-border rounded-lg p-2">
|
<div
|
||||||
|
className="bg-muted/50 border border-border rounded-lg p-2 cursor-pointer select-none hover:bg-muted/70 transition-colors"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<div className="flex items-center gap-2 text-foreground">
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
{getProcessIcon(payload.runReason)}
|
{getProcessIcon(payload.runReason)}
|
||||||
@@ -67,6 +91,12 @@ function ProcessStartCard({ payload }: ProcessStartCardProps) {
|
|||||||
>
|
>
|
||||||
{payload.status}
|
{payload.status}
|
||||||
</div>
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isCollapsed && '-rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,275 @@
|
|||||||
import { useContext, useState, useRef, useCallback } from 'react';
|
import {
|
||||||
|
useContext,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
useReducer,
|
||||||
|
} from 'react';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { Cog } from 'lucide-react';
|
import { Cog } from 'lucide-react';
|
||||||
import { TaskAttemptDataContext } from '@/components/context/taskDetailsContext.ts';
|
import {
|
||||||
|
TaskAttemptDataContext,
|
||||||
|
TaskSelectedAttemptContext,
|
||||||
|
} from '@/components/context/taskDetailsContext.ts';
|
||||||
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
|
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
|
||||||
import LogEntryRow from '@/components/logs/LogEntryRow';
|
import LogEntryRow from '@/components/logs/LogEntryRow';
|
||||||
|
import {
|
||||||
|
shouldShowInLogs,
|
||||||
|
isAutoCollapsibleProcess,
|
||||||
|
isProcessCompleted,
|
||||||
|
isCodingAgent,
|
||||||
|
getLatestCodingAgent,
|
||||||
|
PROCESS_STATUSES,
|
||||||
|
} from '@/constants/processes';
|
||||||
|
import type { ExecutionProcessStatus } from 'shared/types';
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function addAll<T>(set: Set<T>, items: T[]): Set<T> {
|
||||||
|
items.forEach((i: T) => set.add(i));
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State management types
|
||||||
|
type LogsState = {
|
||||||
|
userCollapsed: Set<string>;
|
||||||
|
autoCollapsed: Set<string>;
|
||||||
|
prevStatus: Map<string, ExecutionProcessStatus>;
|
||||||
|
prevLatestAgent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogsAction =
|
||||||
|
| { type: 'RESET_ATTEMPT' }
|
||||||
|
| { type: 'TOGGLE_USER'; id: string }
|
||||||
|
| { type: 'AUTO_COLLAPSE'; ids: string[] }
|
||||||
|
| { type: 'AUTO_EXPAND'; ids: string[] }
|
||||||
|
| { type: 'UPDATE_STATUS'; id: string; status: ExecutionProcessStatus }
|
||||||
|
| { type: 'NEW_RUNNING_AGENT'; id: string };
|
||||||
|
|
||||||
|
const initialState: LogsState = {
|
||||||
|
userCollapsed: new Set(),
|
||||||
|
autoCollapsed: new Set(),
|
||||||
|
prevStatus: new Map(),
|
||||||
|
prevLatestAgent: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: LogsState, action: LogsAction): LogsState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'RESET_ATTEMPT':
|
||||||
|
return { ...initialState };
|
||||||
|
|
||||||
|
case 'TOGGLE_USER': {
|
||||||
|
const newUserCollapsed = new Set(state.userCollapsed);
|
||||||
|
const newAutoCollapsed = new Set(state.autoCollapsed);
|
||||||
|
|
||||||
|
const isCurrentlyCollapsed =
|
||||||
|
newUserCollapsed.has(action.id) || newAutoCollapsed.has(action.id);
|
||||||
|
|
||||||
|
if (isCurrentlyCollapsed) {
|
||||||
|
// we want to EXPAND
|
||||||
|
newUserCollapsed.delete(action.id);
|
||||||
|
newAutoCollapsed.delete(action.id);
|
||||||
|
} else {
|
||||||
|
// we want to COLLAPSE
|
||||||
|
newUserCollapsed.add(action.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
userCollapsed: newUserCollapsed,
|
||||||
|
autoCollapsed: newAutoCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'AUTO_COLLAPSE': {
|
||||||
|
const newAutoCollapsed = new Set(state.autoCollapsed);
|
||||||
|
addAll(newAutoCollapsed, action.ids);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
autoCollapsed: newAutoCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'AUTO_EXPAND': {
|
||||||
|
const newAutoCollapsed = new Set(state.autoCollapsed);
|
||||||
|
action.ids.forEach((id) => newAutoCollapsed.delete(id));
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
autoCollapsed: newAutoCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UPDATE_STATUS': {
|
||||||
|
const newPrevStatus = new Map(state.prevStatus);
|
||||||
|
newPrevStatus.set(action.id, action.status);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
prevStatus: newPrevStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'NEW_RUNNING_AGENT':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
prevLatestAgent: action.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function LogsTab() {
|
function LogsTab() {
|
||||||
const { attemptData } = useContext(TaskAttemptDataContext);
|
const { attemptData } = useContext(TaskAttemptDataContext);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
|
||||||
const virtuosoRef = useRef<any>(null);
|
const virtuosoRef = useRef<any>(null);
|
||||||
|
|
||||||
const { entries } = useProcessesLogs(attemptData.processes || [], true);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
// Filter out dev server processes before passing to useProcessesLogs
|
||||||
|
const filteredProcesses = useMemo(
|
||||||
|
() =>
|
||||||
|
(attemptData.processes || []).filter((process) =>
|
||||||
|
shouldShowInLogs(process.run_reason)
|
||||||
|
),
|
||||||
|
[attemptData.processes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { entries } = useProcessesLogs(filteredProcesses, true);
|
||||||
|
|
||||||
|
// Combined collapsed processes (auto + user)
|
||||||
|
const allCollapsedProcesses = useMemo(() => {
|
||||||
|
const combined = new Set(state.autoCollapsed);
|
||||||
|
state.userCollapsed.forEach((id: string) => combined.add(id));
|
||||||
|
return combined;
|
||||||
|
}, [state.autoCollapsed, state.userCollapsed]);
|
||||||
|
|
||||||
|
// Toggle collapsed state for a process (user action)
|
||||||
|
const toggleProcessCollapse = useCallback((processId: string) => {
|
||||||
|
dispatch({ type: 'TOGGLE_USER', id: processId });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Effect #1: Reset state when attempt changes
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: 'RESET_ATTEMPT' });
|
||||||
|
}, [selectedAttempt?.id]);
|
||||||
|
|
||||||
|
// Effect #2: Handle setup/cleanup script auto-collapse and auto-expand
|
||||||
|
useEffect(() => {
|
||||||
|
const toCollapse: string[] = [];
|
||||||
|
const toExpand: string[] = [];
|
||||||
|
|
||||||
|
filteredProcesses.forEach((process) => {
|
||||||
|
if (isAutoCollapsibleProcess(process.run_reason)) {
|
||||||
|
const prevStatus = state.prevStatus.get(process.id);
|
||||||
|
const currentStatus = process.status;
|
||||||
|
|
||||||
|
// Auto-collapse completed setup/cleanup scripts
|
||||||
|
const shouldAutoCollapse =
|
||||||
|
(prevStatus === PROCESS_STATUSES.RUNNING ||
|
||||||
|
prevStatus === undefined) &&
|
||||||
|
isProcessCompleted(currentStatus) &&
|
||||||
|
!state.userCollapsed.has(process.id) &&
|
||||||
|
!state.autoCollapsed.has(process.id);
|
||||||
|
|
||||||
|
if (shouldAutoCollapse) {
|
||||||
|
toCollapse.push(process.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-expand scripts that restart after completion
|
||||||
|
const becameRunningAgain =
|
||||||
|
prevStatus &&
|
||||||
|
isProcessCompleted(prevStatus) &&
|
||||||
|
currentStatus === PROCESS_STATUSES.RUNNING &&
|
||||||
|
state.autoCollapsed.has(process.id);
|
||||||
|
|
||||||
|
if (becameRunningAgain) {
|
||||||
|
toExpand.push(process.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status tracking
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_STATUS',
|
||||||
|
id: process.id,
|
||||||
|
status: currentStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toCollapse.length > 0) {
|
||||||
|
dispatch({ type: 'AUTO_COLLAPSE', ids: toCollapse });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toExpand.length > 0) {
|
||||||
|
dispatch({ type: 'AUTO_EXPAND', ids: toExpand });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
filteredProcesses,
|
||||||
|
state.userCollapsed,
|
||||||
|
state.autoCollapsed,
|
||||||
|
state.prevStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Effect #3: Handle coding agent succession logic
|
||||||
|
useEffect(() => {
|
||||||
|
const latestCodingAgentId = getLatestCodingAgent(filteredProcesses);
|
||||||
|
if (!latestCodingAgentId) return;
|
||||||
|
|
||||||
|
// Collapse previous agents when a new latest agent appears
|
||||||
|
if (latestCodingAgentId !== state.prevLatestAgent) {
|
||||||
|
// Collapse all other coding agents that aren't user-collapsed
|
||||||
|
const toCollapse = filteredProcesses
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
isCodingAgent(p.run_reason) &&
|
||||||
|
p.id !== latestCodingAgentId &&
|
||||||
|
!state.userCollapsed.has(p.id) &&
|
||||||
|
!state.autoCollapsed.has(p.id)
|
||||||
|
)
|
||||||
|
.map((p) => p.id);
|
||||||
|
|
||||||
|
if (toCollapse.length > 0) {
|
||||||
|
dispatch({ type: 'AUTO_COLLAPSE', ids: toCollapse });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ type: 'NEW_RUNNING_AGENT', id: latestCodingAgentId });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
filteredProcesses,
|
||||||
|
state.prevLatestAgent,
|
||||||
|
state.userCollapsed,
|
||||||
|
state.autoCollapsed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Filter entries to hide logs from collapsed processes
|
||||||
|
const visibleEntries = useMemo(() => {
|
||||||
|
return entries.filter((entry) =>
|
||||||
|
entry.channel === 'process_start'
|
||||||
|
? true
|
||||||
|
: !allCollapsedProcesses.has(entry.processId)
|
||||||
|
);
|
||||||
|
}, [entries, allCollapsedProcesses]);
|
||||||
|
|
||||||
// Memoized item content to prevent flickering
|
// Memoized item content to prevent flickering
|
||||||
const itemContent = useCallback(
|
const itemContent = useCallback(
|
||||||
(index: number, entry: any) => <LogEntryRow entry={entry} index={index} />,
|
(index: number, entry: any) => (
|
||||||
[]
|
<LogEntryRow
|
||||||
|
entry={entry}
|
||||||
|
index={index}
|
||||||
|
isCollapsed={
|
||||||
|
entry.channel === 'process_start'
|
||||||
|
? allCollapsedProcesses.has(entry.payload.processId)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onToggleCollapse={
|
||||||
|
entry.channel === 'process_start' ? toggleProcessCollapse : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[allCollapsedProcesses, toggleProcessCollapse]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle when user manually scrolls away from bottom
|
if (!filteredProcesses || filteredProcesses.length === 0) {
|
||||||
const handleAtBottomStateChange = useCallback((atBottom: boolean) => {
|
|
||||||
setIsAtBottom(atBottom);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!attemptData.processes || attemptData.processes.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -39,10 +285,9 @@ function LogsTab() {
|
|||||||
<Virtuoso
|
<Virtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
data={entries}
|
data={visibleEntries}
|
||||||
itemContent={itemContent}
|
itemContent={itemContent}
|
||||||
followOutput={isAtBottom ? 'smooth' : false}
|
followOutput={true}
|
||||||
atBottomStateChange={handleAtBottomStateChange}
|
|
||||||
increaseViewportBy={200}
|
increaseViewportBy={200}
|
||||||
overscan={5}
|
overscan={5}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Square,
|
Square,
|
||||||
@@ -40,12 +40,6 @@ function ProcessCard({ process }: ProcessCardProps) {
|
|||||||
const isConnected = isCodingAgent ? normalizedConnected : rawConnected;
|
const isConnected = isCodingAgent ? normalizedConnected : rawConnected;
|
||||||
const error = isCodingAgent ? normalizedError : rawError;
|
const error = isCodingAgent ? normalizedError : rawError;
|
||||||
|
|
||||||
// Auto-scroll to bottom when new logs/entries arrive
|
|
||||||
useEffect(() => {
|
|
||||||
if (logEndRef.current) {
|
|
||||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, [logs, entries]);
|
|
||||||
const getStatusIcon = (status: ExecutionProcessStatus) => {
|
const getStatusIcon = (status: ExecutionProcessStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
|
|||||||
62
frontend/src/constants/processes.ts
Normal file
62
frontend/src/constants/processes.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
ExecutionProcessRunReason,
|
||||||
|
ExecutionProcessStatus,
|
||||||
|
ExecutionProcessSummary,
|
||||||
|
} from 'shared/types';
|
||||||
|
|
||||||
|
// Process run reasons
|
||||||
|
export const PROCESS_RUN_REASONS = {
|
||||||
|
SETUP_SCRIPT: 'setupscript' as ExecutionProcessRunReason,
|
||||||
|
CLEANUP_SCRIPT: 'cleanupscript' as ExecutionProcessRunReason,
|
||||||
|
CODING_AGENT: 'codingagent' as ExecutionProcessRunReason,
|
||||||
|
DEV_SERVER: 'devserver' as ExecutionProcessRunReason,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Process statuses
|
||||||
|
export const PROCESS_STATUSES = {
|
||||||
|
RUNNING: 'running' as ExecutionProcessStatus,
|
||||||
|
COMPLETED: 'completed' as ExecutionProcessStatus,
|
||||||
|
FAILED: 'failed' as ExecutionProcessStatus,
|
||||||
|
KILLED: 'killed' as ExecutionProcessStatus,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
export const isAutoCollapsibleProcess = (
|
||||||
|
runReason: ExecutionProcessRunReason
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
runReason === PROCESS_RUN_REASONS.SETUP_SCRIPT ||
|
||||||
|
runReason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCodingAgent = (
|
||||||
|
runReason: ExecutionProcessRunReason
|
||||||
|
): boolean => {
|
||||||
|
return runReason === PROCESS_RUN_REASONS.CODING_AGENT;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isProcessCompleted = (status: ExecutionProcessStatus): boolean => {
|
||||||
|
return (
|
||||||
|
status === PROCESS_STATUSES.COMPLETED || status === PROCESS_STATUSES.FAILED
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldShowInLogs = (
|
||||||
|
runReason: ExecutionProcessRunReason
|
||||||
|
): boolean => {
|
||||||
|
return runReason !== PROCESS_RUN_REASONS.DEV_SERVER;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLatestCodingAgent = (
|
||||||
|
processes: ExecutionProcessSummary[]
|
||||||
|
): string | null => {
|
||||||
|
const codingAgents = processes.filter((p) => isCodingAgent(p.run_reason));
|
||||||
|
if (codingAgents.length === 0) return null;
|
||||||
|
|
||||||
|
return codingAgents.sort((a, b) =>
|
||||||
|
a.started_at === b.started_at
|
||||||
|
? a.id.localeCompare(b.id) // tie-break for same timestamp
|
||||||
|
: new Date(b.started_at).getTime() - new Date(a.started_at).getTime()
|
||||||
|
)[0].id;
|
||||||
|
};
|
||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -150,6 +150,9 @@ importers:
|
|||||||
eslint-plugin-react-refresh:
|
eslint-plugin-react-refresh:
|
||||||
specifier: ^0.4.5
|
specifier: ^0.4.5
|
||||||
version: 0.4.20(eslint@8.57.1)
|
version: 0.4.20(eslint@8.57.1)
|
||||||
|
eslint-plugin-unused-imports:
|
||||||
|
specifier: ^4.1.4
|
||||||
|
version: 4.1.4(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.4.32
|
specifier: ^8.4.32
|
||||||
version: 8.5.6
|
version: 8.5.6
|
||||||
@@ -1719,6 +1722,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: '>=8.40'
|
eslint: '>=8.40'
|
||||||
|
|
||||||
|
eslint-plugin-unused-imports@4.1.4:
|
||||||
|
resolution: {integrity: sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
|
||||||
|
eslint: ^9.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@typescript-eslint/eslint-plugin':
|
||||||
|
optional: true
|
||||||
|
|
||||||
eslint-scope@7.2.2:
|
eslint-scope@7.2.2:
|
||||||
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
|
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
@@ -4419,6 +4431,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
|
|
||||||
|
eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1):
|
||||||
|
dependencies:
|
||||||
|
eslint: 8.57.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)
|
||||||
|
|
||||||
eslint-scope@7.2.2:
|
eslint-scope@7.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
esrecurse: 4.3.0
|
esrecurse: 4.3.0
|
||||||
|
|||||||
Reference in New Issue
Block a user