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:
Louis Knight-Webb
2025-08-11 23:52:32 +01:00
committed by GitHub
parent 59c977e235
commit 69cda33532
9 changed files with 453 additions and 42 deletions

View File

@@ -1,15 +1,25 @@
{
"root": true,
"env": { "browser": true, "es2020": true },
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"ignorePatterns": ["dist", ".eslintrc.json"],
"ignorePatterns": [
"dist",
".eslintrc.json"
],
"parser": "@typescript-eslint/parser",
"plugins": ["react-refresh", "@typescript-eslint"],
"plugins": [
"react-refresh",
"@typescript-eslint",
"unused-imports"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
@@ -17,9 +27,19 @@
"rules": {
"react-refresh/only-export-components": [
"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"
}
}

View File

@@ -40,7 +40,8 @@
"react-window": "^1.8.11",
"rfc6902": "^5.1.2",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"use-debounce": "^10.0.5"
},
"devDependencies": {
"@types/react": "^18.2.43",
@@ -54,6 +55,7 @@
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.4.32",
"prettier": "^3.6.1",
"tailwindcss": "^3.4.0",
@@ -1122,6 +1124,15 @@
"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": {
"version": "0.0.30",
"resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.30.tgz",
@@ -3805,15 +3816,6 @@
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"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": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz",
@@ -4081,6 +4083,22 @@
"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": {
"version": "7.2.2",
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
@@ -7778,4 +7808,4 @@
}
}
}
}
}

View File

@@ -60,6 +60,7 @@
"eslint-plugin-prettier": "^5.5.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"eslint-plugin-unused-imports": "^4.1.4",
"postcss": "^8.4.32",
"prettier": "^3.6.1",
"tailwindcss": "^3.4.0",

View File

@@ -11,9 +11,18 @@ interface LogEntryRowProps {
index: number;
style?: React.CSSProperties;
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);
useEffect(() => {
@@ -42,6 +51,8 @@ function LogEntryRow({ entry, index, style, setRowHeight }: LogEntryRowProps) {
return (
<ProcessStartCard
payload={entry.payload as ProcessStartPayload}
isCollapsed={isCollapsed || false}
onToggle={onToggleCollapse || (() => {})}
/>
);
default:

View File

@@ -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';
interface ProcessStartCardProps {
payload: ProcessStartPayload;
isCollapsed: boolean;
onToggle: (processId: string) => void;
}
function ProcessStartCard({ payload }: ProcessStartCardProps) {
function ProcessStartCard({
payload,
isCollapsed,
onToggle,
}: ProcessStartCardProps) {
const getProcessIcon = (runReason: string) => {
switch (runReason) {
case 'setupscript':
@@ -40,9 +47,26 @@ function ProcessStartCard({ payload }: ProcessStartCardProps) {
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 (
<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-foreground">
{getProcessIcon(payload.runReason)}
@@ -67,6 +91,12 @@ function ProcessStartCard({ payload }: ProcessStartCardProps) {
>
{payload.status}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isCollapsed && '-rotate-90'
)}
/>
</div>
</div>
</div>

View File

@@ -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 { 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 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() {
const { attemptData } = useContext(TaskAttemptDataContext);
const [isAtBottom, setIsAtBottom] = useState(true);
const { selectedAttempt } = useContext(TaskSelectedAttemptContext);
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
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
const handleAtBottomStateChange = useCallback((atBottom: boolean) => {
setIsAtBottom(atBottom);
}, []);
if (!attemptData.processes || attemptData.processes.length === 0) {
if (!filteredProcesses || filteredProcesses.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
@@ -39,10 +285,9 @@ function LogsTab() {
<Virtuoso
ref={virtuosoRef}
style={{ height: '100%' }}
data={entries}
data={visibleEntries}
itemContent={itemContent}
followOutput={isAtBottom ? 'smooth' : false}
atBottomStateChange={handleAtBottomStateChange}
followOutput={true}
increaseViewportBy={200}
overscan={5}
components={{

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useRef } from 'react';
import {
Play,
Square,
@@ -40,12 +40,6 @@ function ProcessCard({ process }: ProcessCardProps) {
const isConnected = isCodingAgent ? normalizedConnected : rawConnected;
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) => {
switch (status) {
case 'running':

View 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;
};