From 07682eca37febe49d7816f172c601f3298b798d2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 13 Apr 2026 18:36:44 +0300 Subject: [PATCH] feat(graph-controls): add team page and task creation buttons, improve toolbar button styles --- packages/agent-graph/src/ui/GraphControls.tsx | 87 +++++--- packages/agent-graph/src/ui/GraphView.tsx | 29 ++- .../services/infrastructure/FileWatcher.ts | 65 +----- src/main/utils/jsonl.ts | 153 +++++++++++-- src/renderer/App.tsx | 2 +- .../components/dashboard/TmuxStatusBanner.tsx | 140 ++++++++++-- .../components/layout/CustomTitleBar.tsx | 73 ++++--- src/renderer/components/layout/MoreMenu.tsx | 57 +++-- .../components/layout/SortableTab.tsx | 30 ++- src/renderer/components/layout/TabBar.tsx | 32 +-- .../components/layout/TabBarActions.tsx | 170 +++++++++------ src/renderer/components/layout/TabBarRow.tsx | 38 ++-- .../components/team/TeamDetailView.tsx | 98 +++++---- .../team/taskLogs/TaskActivitySection.tsx | 47 +++- .../agent-graph/ui/GraphActivityHud.tsx | 205 ++++++++++++------ .../agent-graph/ui/TeamGraphOverlay.tsx | 12 + .../features/agent-graph/ui/TeamGraphTab.tsx | 14 ++ .../infrastructure/FileWatcher.test.ts | 54 +++++ test/main/utils/jsonl.test.ts | 53 ++++- .../team/taskLogs/TaskActivitySection.test.ts | 155 +++++++++++++ 20 files changed, 1111 insertions(+), 403 deletions(-) create mode 100644 test/renderer/components/team/taskLogs/TaskActivitySection.test.ts diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 15859be8..3b5914cc 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -14,7 +14,9 @@ import { Pause, Pin, Play, + Plus, Server, + Users, X, ZoomIn, ZoomOut, @@ -36,10 +38,11 @@ export interface GraphControlsProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + onOpenTeamPage?: () => void; + onCreateTask?: () => void; teamName: string; teamColor?: string; isAlive?: boolean; - showBlockingHint?: boolean; } export function GraphControls({ @@ -51,10 +54,11 @@ export function GraphControls({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + onOpenTeamPage, + onCreateTask, teamName, teamColor, isAlive, - showBlockingHint = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -93,7 +97,21 @@ export function GraphControls({ return ( <> -
+
+ {onOpenTeamPage ? ( +
+ } mini title="Team page" /> + {onCreateTask ? ( + } mini title="Create task" /> + ) : null} +
+ ) : null}
-
+
toggle('paused')} - icon={filters.paused ? : } + icon={filters.paused ? : } + mini />
setIsSettingsOpen((value) => !value)} - icon={} - label="View" + icon={} active={isSettingsOpen} + mini />
@@ -174,52 +193,39 @@ export function GraphControls({
- {onRequestPinAsTab && } />} + {onRequestPinAsTab && ( + } mini /> + )} {onRequestFullscreen && ( } - label="Fullscreen" + icon={} + mini /> )} - {onRequestClose && } />} + {onRequestClose && } mini />}
- } /> - } label="Fit" /> - } /> + } compact /> + } label="Fit" compact /> + } compact />
- - {showBlockingHint && ( -
-
- Red lines - blockers, click to inspect -
-
- )} ); } @@ -231,16 +237,29 @@ function ToolbarButton({ icon, label, active = false, + compact = false, + mini = false, + title, }: { onClick?: () => void; icon: React.ReactNode; label?: string; active?: boolean; + compact?: boolean; + mini?: boolean; + title?: string; }): React.JSX.Element { return (
diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index dab24266..14b888e0 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -11,7 +11,7 @@ */ import { type FileChangeEvent, type ParsedMessage } from '@main/types'; -import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl'; +import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl'; import { getProjectsBasePath, getTasksBasePath, @@ -765,12 +765,12 @@ export class FileWatcher extends EventEmitter { const currentSize = fileStats.size; // Fast path: no size change means no new data - if (currentSize === lastSize && lastLineCount > 0) { + if (currentSize === lastSize && lastSize > 0) { return; } const isFirstRead = lastLineCount === 0 && lastSize === 0; - const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize; + const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize; let newMessages: ParsedMessage[] = []; let currentLineCount: number; let processedSize: number; @@ -782,12 +782,10 @@ export class FileWatcher extends EventEmitter { processedSize = lastSize + appended.consumedBytes; } else { // Fallback for first-read, truncation, or rewrite scenarios - const messages = await parseJsonlFile(filePath); - currentLineCount = messages.length; - newMessages = messages.slice(lastLineCount); - // Re-stat after full parse to capture bytes written during the parse - const postParseStats = await this.fsProvider.stat(filePath); - processedSize = postParseStats.size; + const parsedFile = await parseJsonlFileWithStats(filePath, this.fsProvider); + currentLineCount = parsedFile.parsedLineCount; + newMessages = parsedFile.messages.slice(lastLineCount); + processedSize = parsedFile.consumedBytes; } // If no new lines, skip processing @@ -895,56 +893,15 @@ export class FileWatcher extends EventEmitter { filePath: string, startOffset: number ): Promise { - const parsedMessages: ParsedMessage[] = []; const stream = this.fsProvider.createReadStream(filePath, { start: startOffset, - encoding: 'utf8', }); - - let buffer = ''; - let consumedBytes = 0; - let parsedLineCount = 0; - for await (const chunk of stream) { - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop() ?? ''; - - for (const rawLine of lines) { - consumedBytes += Buffer.byteLength(`${rawLine}\n`, 'utf8'); - const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; - if (!line.trim()) { - continue; - } - try { - const parsed = parseJsonlLine(line); - if (parsed) { - parsedMessages.push(parsed); - parsedLineCount++; - } - } catch { - // Ignore malformed appended lines; full parse path will recover on next rewrite. - } - } - } - - // Handle final line without trailing newline - if (buffer.trim()) { - try { - const parsed = parseJsonlLine(buffer); - if (parsed) { - parsedMessages.push(parsed); - parsedLineCount++; - consumedBytes += Buffer.byteLength(buffer, 'utf8'); - } - } catch { - // Keep offset pinned until this trailing partial becomes a complete line. - } - } + const parsed = await parseJsonlStream(stream); return { - messages: parsedMessages, - parsedLineCount, - consumedBytes, + messages: parsed.messages, + parsedLineCount: parsed.parsedLineCount, + consumedBytes: parsed.consumedBytes, }; } diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 59fe25d5..a1d42fba 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -34,6 +34,7 @@ import { extractToolCalls, extractToolResults } from './toolExtraction'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; import type { PhaseTokenBreakdown } from '../types/domain'; +import type { Readable } from 'stream'; const logger = createLogger('Util:jsonl'); @@ -47,6 +48,12 @@ export { checkMessagesOngoing } from './sessionStateDetection'; // Core Parsing Functions // ============================================================================= +export interface JsonlParseResult { + messages: ParsedMessage[]; + parsedLineCount: number; + consumedBytes: number; +} + /** * Parse a JSONL file line by line using streaming. * This avoids loading the entire file into memory. @@ -55,38 +62,130 @@ export async function parseJsonlFile( filePath: string, fsProvider: FileSystemProvider = defaultProvider ): Promise { - const messages: ParsedMessage[] = []; - if (!(await fsProvider.exists(filePath))) { - return messages; + return []; } - const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); + const result = await parseJsonlStream(fsProvider.createReadStream(filePath), filePath); + return result.messages; +} - let lineCount = 0; - for await (const line of rl) { - if (!line.trim()) continue; +/** + * Parse a JSONL file and return byte accounting details for incremental readers. + */ +export async function parseJsonlFileWithStats( + filePath: string, + fsProvider: FileSystemProvider = defaultProvider +): Promise { + if (!(await fsProvider.exists(filePath))) { + return { messages: [], parsedLineCount: 0, consumedBytes: 0 }; + } + + return parseJsonlStream(fsProvider.createReadStream(filePath), filePath); +} + +/** + * Parse JSONL data from a readable stream while tracking how many bytes were + * safely consumed as complete lines. + */ +export async function parseJsonlStream( + stream: Readable, + filePath?: string +): Promise { + const messages: ParsedMessage[] = []; + let pending = Buffer.alloc(0); + let parsedLineCount = 0; + let consumedBytes = 0; + let completeLineCount = 0; + let malformedLineCount = 0; + let skippedNonJsonCount = 0; + + const processLine = (lineBuffer: Buffer): void => { + let effectiveBuffer = lineBuffer; + if (effectiveBuffer.length > 0 && effectiveBuffer[effectiveBuffer.length - 1] === 0x0d) { + effectiveBuffer = effectiveBuffer.subarray(0, -1); + } + + const line = effectiveBuffer.toString('utf8'); + if (!line.trim()) { + return; + } + + const normalized = normalizeJsonlLine(line); + if (!looksLikeJsonObjectLine(normalized)) { + skippedNonJsonCount += 1; + return; + } try { - const parsed = parseJsonlLine(line); + const parsed = parseJsonlLine(normalized); if (parsed) { messages.push(parsed); + parsedLineCount += 1; } - } catch (error) { - logger.error(`Error parsing line in ${filePath}:`, error); + } catch { + malformedLineCount += 1; } + }; - lineCount++; - if (lineCount % 250 === 0) { - await yieldToEventLoop(); + for await (const chunk of stream) { + const chunkBuffer = + typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk as Uint8Array); + pending = + pending.length === 0 + ? chunkBuffer + : Buffer.concat([pending, chunkBuffer], pending.length + chunkBuffer.length); + + while (true) { + const newlineIndex = pending.indexOf(0x0a); + if (newlineIndex === -1) { + break; + } + + const lineBuffer = pending.subarray(0, newlineIndex); + pending = pending.subarray(newlineIndex + 1); + consumedBytes += lineBuffer.length + 1; + completeLineCount += 1; + processLine(lineBuffer); + + if (completeLineCount % 250 === 0) { + await yieldToEventLoop(); + } } } - return messages; + if (pending.length > 0) { + try { + const trailingLine = pending.toString('utf8'); + const normalized = normalizeJsonlLine(trailingLine); + if (looksLikeJsonObjectLine(normalized)) { + const parsed = parseJsonlLine(normalized); + if (parsed) { + messages.push(parsed); + parsedLineCount += 1; + consumedBytes += pending.length; + } + } else if (normalized.length > 0) { + // Treat non-JSON tail text as a complete malformed line and advance. + consumedBytes += pending.length; + } + } catch { + // Ignore trailing partial JSON. Callers should keep their offset pinned + // until the line is completed by a future append. + } + } + + if (filePath && (malformedLineCount > 0 || skippedNonJsonCount > 0)) { + logger.debug( + `Skipped invalid JSONL lines in ${filePath} malformed=${malformedLineCount} nonJson=${skippedNonJsonCount}` + ); + } + + return { + messages, + parsedLineCount, + consumedBytes, + }; } /** @@ -94,14 +193,28 @@ export async function parseJsonlFile( * Returns null for invalid/unsupported lines. */ export function parseJsonlLine(line: string): ParsedMessage | null { - if (!line.trim()) { + const normalized = normalizeJsonlLine(line); + if (!normalized) { return null; } - const entry = JSON.parse(line) as ChatHistoryEntry; + if (!looksLikeJsonObjectLine(normalized)) { + return null; + } + + const entry = JSON.parse(normalized) as ChatHistoryEntry; return parseChatHistoryEntry(entry); } +function normalizeJsonlLine(line: string): string { + const trimmed = line.trim(); + return trimmed.charCodeAt(0) === 0xfeff ? trimmed.slice(1) : trimmed; +} + +function looksLikeJsonObjectLine(line: string): boolean { + return line.startsWith('{'); +} + // ============================================================================= // Entry Parsing // ============================================================================= diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9469af07..fc9771c4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -37,7 +37,7 @@ export const App = (): React.JSX.Element => { return ( - + diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx index 86784408..c36be67b 100644 --- a/src/renderer/components/dashboard/TmuxStatusBanner.tsx +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -6,6 +6,15 @@ import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react'; import type { TmuxStatus } from '@shared/types'; const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; +const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README'; +const HOMEBREW_TMUX_URL = 'https://formulae.brew.sh/formula/tmux'; +const MACPORTS_TMUX_URL = 'https://ports.macports.org/port/tmux/'; +const MICROSOFT_WSL_INSTALL_URL = 'https://learn.microsoft.com/en-us/windows/wsl/install'; + +interface SourceLink { + label: string; + url: string; +} type BannerState = | { loading: true; status: null; error: null } @@ -14,6 +23,33 @@ type BannerState = const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; +const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => { + return ( +
+
+ Sources +
+
+ {links.map((link) => ( + + ))} +
+
+ ); +}; + const PlatformInstallMatrix = (): React.JSX.Element => { return (
@@ -28,10 +64,19 @@ const PlatformInstallMatrix = (): React.JSX.Element => { macOS
-
Homebrew
+
Recommended: Homebrew
brew install tmux -
MacPorts
- port install tmux +
Alternative: MacPorts
+ + sudo port install tmux + +
@@ -46,11 +91,21 @@ const PlatformInstallMatrix = (): React.JSX.Element => { Linux
- apt install tmux - dnf install tmux - yum install tmux - zypper install tmux - pacman -S tmux +
Use your distro package manager:
+ + sudo apt install tmux + + + sudo dnf install tmux + + + sudo yum install tmux + + + sudo zypper install tmux + + sudo pacman -S tmux +
@@ -65,11 +120,20 @@ const PlatformInstallMatrix = (): React.JSX.Element => { Windows
-

В official tmux wiki нет native Windows install command.

-

- Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux - команд выше, например apt install tmux. -

+

The tmux docs do not provide an official native Windows install command.

+
1. Install WSL
+ wsl --install +
2. Inside Ubuntu or another distro
+ + sudo apt install tmux + +
@@ -78,21 +142,25 @@ const PlatformInstallMatrix = (): React.JSX.Element => { function getPrimaryDetail(status: TmuxStatus): string { if (status.platform === 'darwin') { - return 'На macOS проще всего поставить tmux через Homebrew или MacPorts.'; + return 'On macOS, the simplest options are Homebrew or MacPorts.'; } if (status.platform === 'linux') { - return 'На Linux команда зависит от дистрибутива: apt, dnf, yum, zypper или pacman.'; + return 'On Linux, install tmux with your distro package manager.'; } if (status.platform === 'win32') { - return 'На Windows у official tmux wiki нет native installer; safest путь — WSL и установка tmux внутри Linux-дистрибутива.'; + return 'On Windows, the clearest path is WSL, then installing tmux inside your Linux distro.'; } - return 'Поставь tmux через пакетный менеджер своей ОС.'; + return 'Install tmux with your operating system package manager.'; } export const TmuxStatusBanner = (): React.JSX.Element | null => { const isElectron = useMemo(() => isElectronMode(), []); const [state, setState] = useState(INITIAL_STATE); + const loadStatus = useCallback(async () => { + return api.tmux.getStatus(); + }, []); + const fetchStatus = useCallback(async () => { setState( (prev) => @@ -104,7 +172,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { ); try { - const status = await api.tmux.getStatus(); + const status = await loadStatus(); setState({ loading: false, status, error: null }); } catch (error) { setState({ @@ -113,14 +181,38 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { error: error instanceof Error ? error.message : 'Failed to check tmux status', }); } - }, []); + }, [loadStatus]); useEffect(() => { if (!isElectron) { return; } - void fetchStatus(); - }, [fetchStatus, isElectron]); + + let cancelled = false; + + const loadInitialStatus = async (): Promise => { + try { + const status = await loadStatus(); + if (!cancelled) { + setState({ loading: false, status, error: null }); + } + } catch (error) { + if (!cancelled) { + setState({ + loading: false, + status: null, + error: error instanceof Error ? error.message : 'Failed to check tmux status', + }); + } + } + }; + + void loadInitialStatus(); + + return () => { + cancelled = true; + }; + }, [isElectron, loadStatus]); if (!isElectron) return null; if (state.loading && !state.status) return null; @@ -182,8 +274,8 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }} > - Persistent team agents работают стабильнее в process/tmux path. Без tmux app остаётся - на более тяжёлом in-process пути. {getPrimaryDetail(state.status)} + Persistent team agents are more reliable on the process/tmux path. Without tmux, the + app falls back to the heavier in-process path. {getPrimaryDetail(state.status)}

{state.status.error && (

@@ -208,7 +300,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => { style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }} > - Open guide + Official guide diff --git a/src/renderer/components/layout/CustomTitleBar.tsx b/src/renderer/components/layout/CustomTitleBar.tsx index f8b38a04..7a1593dc 100644 --- a/src/renderer/components/layout/CustomTitleBar.tsx +++ b/src/renderer/components/layout/CustomTitleBar.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import faviconUrl from '@renderer/favicon.png'; import { useStore } from '@renderer/store'; import { Minus, Square, X } from 'lucide-react'; @@ -68,36 +69,48 @@ export const CustomTitleBar = (): React.JSX.Element | null => { {/* Window controls — no-drag so they receive clicks */}

- - - + + + + + Minimize + + + + + + {isMaximized ? 'Restore' : 'Maximize'} + + + + + + Close +
); diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx index 2e0910df..cba228ba 100644 --- a/src/renderer/components/layout/MoreMenu.tsx +++ b/src/renderer/components/layout/MoreMenu.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { triggerDownload } from '@renderer/utils/sessionExporter'; import { formatShortcut } from '@renderer/utils/stringUtils'; @@ -22,6 +23,7 @@ import { Type, Users, } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import type { SessionDetail } from '@renderer/types/data'; import type { Tab } from '@renderer/types/tabs'; @@ -51,12 +53,23 @@ export const MoreMenu = ({ const [hoveredId, setHoveredId] = useState(null); const containerRef = useRef(null); - const openCommandPalette = useStore((s) => s.openCommandPalette); - const openExtensionsTab = useStore((s) => s.openExtensionsTab); - const openSessionReport = useStore((s) => s.openSessionReport); - const openSchedulesTab = useStore((s) => s.openSchedulesTab); - const openSettingsTab = useStore((s) => s.openSettingsTab); - const openTeamsTab = useStore((s) => s.openTeamsTab); + const { + openCommandPalette, + openExtensionsTab, + openSessionReport, + openSchedulesTab, + openSettingsTab, + openTeamsTab, + } = useStore( + useShallow((s) => ({ + openCommandPalette: () => s.openCommandPalette(), + openExtensionsTab: () => s.openExtensionsTab(), + openSessionReport: (tabId: string) => s.openSessionReport(tabId), + openSchedulesTab: () => s.openSchedulesTab(), + openSettingsTab: () => s.openSettingsTab(), + openTeamsTab: () => s.openTeamsTab(), + })) + ); // Close on outside click useEffect(() => { @@ -212,19 +225,25 @@ export const MoreMenu = ({ return (
{/* Trigger button */} - + + + + + More actions + {/* Dropdown menu */} {isOpen && ( diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 56f99fe9..6fbba201 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -7,6 +7,7 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet, getThemedBadge, @@ -211,18 +212,23 @@ export const SortableTab = ({ }} /> )} - + + + + + Close tab +
); }; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 9e079e03..2b7586be 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; import { RefreshCw } from 'lucide-react'; @@ -293,19 +294,24 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { {/* Refresh button - show only for session tabs */} {activeTab?.type === 'session' && ( - + + + + + {`Refresh Session (${formatShortcut('R')})`} + )} diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx index b60ab422..b0f6f75e 100644 --- a/src/renderer/components/layout/TabBarActions.tsx +++ b/src/renderer/components/layout/TabBarActions.tsx @@ -86,66 +86,93 @@ export const TabBarActions = (): React.JSX.Element => { )} {/* Notifications bell icon */} - + + + + + Notifications + {/* GitHub link */} - + + + + + GitHub + {/* Discord link */} - + + + + + Discord + {/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */} { {/* Expand sidebar — rightmost, only when collapsed */} {sidebarCollapsed && ( - + + + + + Expand sidebar + )} ); diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx index 77f4d90c..7f1ef8a8 100644 --- a/src/renderer/components/layout/TabBarRow.tsx +++ b/src/renderer/components/layout/TabBarRow.tsx @@ -7,6 +7,7 @@ import { Fragment, useState } from 'react'; import { isElectronMode } from '@renderer/api'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { Plus } from 'lucide-react'; @@ -70,22 +71,27 @@ export const TabBarRow = (): React.JSX.Element => { ))} {/* New tab button — right after last tab */} - + + + + + New tab (Dashboard) + {/* Action buttons — right side */} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 573ea3cf..fe8eed43 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -74,7 +74,7 @@ import { type MemberActivityFilter, type MemberDetailTab } from './members/membe import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; -import type { ComponentProps } from 'react'; +import type { ComponentProps, CSSProperties } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) @@ -822,6 +822,34 @@ export const TeamDetailView = ({ const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); const pendingReplyRefreshTimerRef = useRef(null); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, + teamName, + }); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); // Set inert on background content when editor/graph overlay is open (a11y focus trap) useEffect(() => { @@ -839,18 +867,12 @@ export const TeamDetailView = ({ const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail?.teamName === teamName) { - const state = useStore.getState(); - const displayName = state.teamByName[teamName]?.displayName ?? teamName; - useStore.getState().openTab({ - type: 'graph', - label: `${displayName} Graph`, - teamName, - }); + handleOpenGraphTab(); } }; window.addEventListener('toggle-team-graph', handler); return () => window.removeEventListener('toggle-team-graph', handler); - }, [teamName]); + }, [handleOpenGraphTab, teamName]); // Listen for graph tab actions (open task, send message) useEffect(() => { @@ -2059,13 +2081,13 @@ export const TeamDetailView = ({ {data.config.description}

)} - {(data.config.projectPath || leadBranch) && ( -
+
+
{data.config.projectPath && ( @@ -2108,7 +2130,28 @@ export const TeamDetailView = ({ )}
- )} + + + + + Open team graph + +
{(() => { const currentPath = data.config.projectPath; const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); @@ -2159,27 +2202,6 @@ export const TeamDetailView = ({ defaultOpen action={
-
diff --git a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx b/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx index 377cb1c4..49e4d9c0 100644 --- a/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx +++ b/src/renderer/features/agent-graph/ui/GraphActivityHud.tsx @@ -33,6 +33,7 @@ interface GraphActivityHudProps { getActivityAnchorScreenPlacement: ( ownerNodeId: string ) => { x: number; y: number; scale: number; visible: boolean } | null; + getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; enabled?: boolean; onOpenTaskDetail?: (taskId: string) => void; @@ -49,12 +50,15 @@ export function GraphActivityHud({ teamName, nodes, getActivityAnchorScreenPlacement, + getNodeScreenPosition = () => null, focusNodeIds, enabled = true, onOpenTaskDetail, onOpenMemberProfile, }: GraphActivityHudProps): React.JSX.Element | null { const shellRefs = useRef(new Map()); + const connectorRefs = useRef(new Map()); + const connectorPathRefs = useRef(new Map()); const [expandedItem, setExpandedItem] = useState(null); const { teamData, teams } = useStore( useShallow((state) => ({ @@ -126,6 +130,11 @@ export function GraphActivityHud({ shell.style.opacity = '0'; } } + for (const connector of connectorRefs.current.values()) { + if (connector) { + connector.style.opacity = '0'; + } + } return; } @@ -136,16 +145,57 @@ export function GraphActivityHud({ if (!shell) { continue; } + const connector = connectorRefs.current.get(lane.node.id); + const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; const placement = getActivityAnchorScreenPlacement(lane.node.id); - if (!placement || !placement.visible) { + const nodeScreen = getNodeScreenPosition(lane.node.id); + if (!placement || !placement.visible || !nodeScreen || !nodeScreen.visible) { shell.style.opacity = '0'; + if (connector) { + connector.style.opacity = '0'; + } continue; } const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; shell.style.opacity = String(baseOpacity); shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; + + if (connector && connectorPath) { + const scaledWidth = (shell.offsetWidth || 296) * placement.scale; + const laneCenterX = placement.x + scaledWidth / 2; + const laneIsLeft = laneCenterX < nodeScreen.x; + const endX = laneIsLeft ? placement.x + scaledWidth - 8 : placement.x + 8; + const endY = placement.y + 10 * placement.scale; + const startX = nodeScreen.x; + const startY = nodeScreen.y - 10; + const minX = Math.min(startX, endX); + const minY = Math.min(startY, endY); + const width = Math.max(1, Math.abs(endX - startX)); + const height = Math.max(1, Math.abs(endY - startY)); + const localStartX = startX - minX; + const localStartY = startY - minY; + const localEndX = endX - minX; + const localEndY = endY - minY; + const dx = localEndX - localStartX; + const curve = Math.max(28, Math.abs(dx) * 0.35); + const c1x = localStartX + Math.sign(dx || 1) * curve; + const c1y = localStartY; + const c2x = localEndX - Math.sign(dx || 1) * curve; + const c2y = localEndY; + + connector.style.opacity = String(baseOpacity); + connector.style.left = `${Math.round(minX)}px`; + connector.style.top = `${Math.round(minY)}px`; + connector.setAttribute('width', String(Math.ceil(width))); + connector.setAttribute('height', String(Math.ceil(height))); + connector.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`); + connectorPath.setAttribute( + 'd', + `M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}` + ); + } } frameId = window.requestAnimationFrame(updatePositions); @@ -155,7 +205,13 @@ export function GraphActivityHud({ return () => { window.cancelAnimationFrame(frameId); }; - }, [enabled, focusNodeIds, getActivityAnchorScreenPlacement, visibleLanes]); + }, [ + enabled, + focusNodeIds, + getActivityAnchorScreenPlacement, + getNodeScreenPosition, + visibleLanes, + ]); const expandedItemsByKey = useMemo(() => { const items = new Map(); @@ -216,71 +272,90 @@ export function GraphActivityHud({ return ( <> {visibleLanes.map((lane) => ( -
{ - shellRefs.current.set(lane.node.id, element); - }} - className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0" - > -
- Activity -
-
- {lane.entries.map((entry, index) => { - const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps(entry.message, messageContext); - const timelineItem: TimelineItem = { type: 'message', message: entry.message }; - const isUnread = !entry.message.read && !readSet.has(messageKey); +
+ { + connectorRefs.current.set(lane.node.id, element); + }} + className="pointer-events-none absolute z-[9] overflow-visible opacity-0" + > + { + connectorPathRefs.current.set(lane.node.id, element); + }} + d="" + fill="none" + stroke="rgba(148, 163, 184, 0.3)" + strokeWidth="1.25" + strokeLinecap="round" + strokeDasharray="3 4" + /> + +
{ + shellRefs.current.set(lane.node.id, element); + }} + className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0" + > +
+ Activity +
+
+ {lane.entries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps(entry.message, messageContext); + const timelineItem: TimelineItem = { type: 'message', message: entry.message }; + const isUnread = !entry.message.read && !readSet.has(messageKey); - return ( -
handleMessageClick(timelineItem)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleMessageClick(timelineItem); - } - }} + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( +
- ); - })} - - {lane.overflowCount > 0 ? ( - - ) : null} + +{lane.overflowCount} more + + ) : null} +
))} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 580de796..b4d2c165 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -7,6 +7,7 @@ import { useCallback, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; +import { useStore } from '@renderer/store'; import type { MemberActivityFilter, MemberDetailTab, @@ -69,6 +70,13 @@ export const TeamGraphOverlay = ({ }), [dispatchTaskAction] ); + const openTeamPage = useCallback(() => { + useStore.getState().openTeamTab(teamName); + onClose(); + }, [onClose, teamName]); + const openCreateTask = useCallback(() => { + window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner: '' } })); + }, [teamName]); const events: GraphEventPort = { onNodeDoubleClick: useCallback( @@ -100,10 +108,13 @@ export const TeamGraphOverlay = ({ events={events} onRequestClose={onClose} onRequestPinAsTab={onPinAsTab} + onOpenTeamPage={openTeamPage} + onCreateTask={openCreateTask} className="team-graph-view min-w-0 flex-1" renderHud={({ getLaunchAnchorScreenPlacement, getActivityAnchorScreenPlacement, + getNodeScreenPosition, focusNodeIds, }) => ( <> @@ -111,6 +122,7 @@ export const TeamGraphOverlay = ({ teamName={teamName} nodes={graphData.nodes} getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement} + getNodeScreenPosition={getNodeScreenPosition} focusNodeIds={focusNodeIds} onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 89683dcb..676c7af4 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -7,6 +7,7 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; +import { useStore } from '@renderer/store'; import type { MemberActivityFilter, MemberDetailTab, @@ -75,6 +76,15 @@ export const TeamGraphTab = ({ window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })), [teamName] ); + const openTeamPage = useCallback(() => { + useStore.getState().openTeamTab(teamName); + }, [teamName]); + const openCreateTask = useCallback(() => { + useStore.getState().openTeamTab(teamName); + window.setTimeout(() => { + dispatchCreateTask(''); + }, 0); + }, [dispatchCreateTask, teamName]); // Task action dispatchers const dispatchTaskAction = useCallback( @@ -139,9 +149,12 @@ export const TeamGraphTab = ({ className="team-graph-view size-full" suspendAnimation={!isActive} onRequestFullscreen={() => setFullscreen(true)} + onOpenTeamPage={openTeamPage} + onCreateTask={openCreateTask} renderHud={({ getLaunchAnchorScreenPlacement, getActivityAnchorScreenPlacement, + getNodeScreenPosition, focusNodeIds, }) => ( <> @@ -149,6 +162,7 @@ export const TeamGraphTab = ({ teamName={teamName} nodes={graphData.nodes} getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement} + getNodeScreenPosition={getNodeScreenPosition} focusNodeIds={focusNodeIds} enabled={isActive} onOpenTaskDetail={dispatchOpenTask} diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index bb1a537f..ef0dd4c4 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -221,6 +221,60 @@ describe('FileWatcher', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + it('pins fallback processed size to the last complete line until a trailing JSON object is completed', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-fallback-partial-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + const firstLine = jsonlLine('u1', 'hello'); + const partialSuffix = + '{"type":"assistant","uuid":"u2","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"'; + fs.writeFileSync(filePath, firstLine + partialSuffix, 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + vi.mocked(errorDetector.detectErrors).mockClear(); + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedLineCount: Map; + lastProcessedSize: Map; + instanceCreatedAt: number; + }; + watcherAny.instanceCreatedAt = 0; + + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1); + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(1); + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(Buffer.byteLength(firstLine, 'utf8')); + + fs.appendFileSync(filePath, '}]}}\n', 'utf8'); + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + expect(errorDetector.detectErrors).toHaveBeenCalledTimes(2); + const secondCallArgs = vi.mocked(errorDetector.detectErrors).mock.calls[1]; + expect(secondCallArgs?.[0]).toHaveLength(1); + expect(secondCallArgs?.[0][0]?.uuid).toBe('u2'); + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + // =========================================================================== // Catch-Up Scan Tests // =========================================================================== diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index c7a1cf05..561de38d 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -3,7 +3,12 @@ import * as os from 'os'; import * as path from 'path'; import { describe, expect, it } from 'vitest'; -import { analyzeSessionFileMetadata, calculateMetrics } from '../../../src/main/utils/jsonl'; +import { + analyzeSessionFileMetadata, + calculateMetrics, + parseJsonlFile, + parseJsonlLine, +} from '../../../src/main/utils/jsonl'; import type { ParsedMessage } from '../../../src/main/types'; // Helper to create a minimal ParsedMessage @@ -183,4 +188,50 @@ describe('jsonl', () => { } }); }); + + describe('tolerant parsing', () => { + it('skips non-JSON garbage and ignores a partial trailing object', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const validLine = JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2026-01-01T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'hello' }], + }, + }); + const nonJsonGarbage = '╨╕┬аAI-╤А╨░╨╖╤А╨░╨▒'; + const partialJson = + '{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"'; + + fs.writeFileSync(filePath, `${validLine}\n${nonJsonGarbage}\n${partialJson}`, 'utf8'); + + const result = await parseJsonlFile(filePath); + + expect(result).toHaveLength(1); + expect(result[0]?.uuid).toBe('a1'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('strips a UTF-8 BOM before parsing an object line', () => { + const parsed = parseJsonlLine( + `\ufeff${JSON.stringify({ + type: 'assistant', + uuid: 'bom-1', + timestamp: '2026-01-01T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'bom' }], + }, + })}` + ); + + expect(parsed?.uuid).toBe('bom-1'); + }); + }); }); diff --git a/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts new file mode 100644 index 00000000..7daebb66 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskActivitySection.test.ts @@ -0,0 +1,155 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BoardTaskActivityEntry } from '../../../../../src/shared/types'; + +const apiState = { + getTaskActivity: vi.fn<(teamName: string, taskId: string) => Promise>(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskActivity: (...args: Parameters) => + apiState.getTaskActivity(...args), + }, + }, +})); + +import { TaskActivitySection } from '@renderer/components/team/taskLogs/TaskActivitySection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function makeEntry( + overrides: Partial & Pick +): BoardTaskActivityEntry { + const { id, linkKind, ...rest } = overrides; + + return { + id, + timestamp: '2026-04-13T10:33:00.000Z', + task: { + locator: { + ref: 'abc12345', + refKind: 'display', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-1', + displayId: 'abc12345', + teamName: 'demo', + }, + }, + linkKind, + targetRole: 'subject', + actor: { + memberName: 'bob', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { + relation: 'same_task', + }, + source: { + messageUuid: `${overrides.id}-message`, + filePath: '/tmp/transcript.jsonl', + sourceOrder: 1, + ...(rest.source?.toolUseId ? { toolUseId: rest.source.toolUseId } : {}), + }, + ...rest, + }; +} + +describe('TaskActivitySection', () => { + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskActivity.mockReset(); + vi.unstubAllGlobals(); + }); + + it('hides low-signal execution rows while keeping key task activity in descending time order', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'viewed', + timestamp: '2026-04-13T10:33:00.000Z', + linkKind: 'board_action', + action: { + canonicalToolName: 'task_get', + category: 'read', + }, + }), + makeEntry({ + id: 'started', + timestamp: '2026-04-13T10:34:00.000Z', + linkKind: 'lifecycle', + action: { + canonicalToolName: 'task_start', + category: 'status', + }, + }), + makeEntry({ + id: 'worked-1', + linkKind: 'execution', + }), + makeEntry({ + id: 'worked-2', + linkKind: 'execution', + }), + ]); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskActivitySection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Viewed task'); + expect(host.textContent).toContain('Started work'); + expect(host.textContent).not.toContain('Worked on task'); + expect(host.textContent?.indexOf('Started work')).toBeLessThan( + host.textContent?.indexOf('Viewed task') ?? Number.POSITIVE_INFINITY + ); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('shows a task-log-stream hint when only low-signal execution rows exist', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskActivity.mockResolvedValue([ + makeEntry({ + id: 'worked-1', + linkKind: 'execution', + }), + ]); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskActivitySection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('No key task activity was found yet'); + expect(host.textContent).toContain('Task Log Stream'); + expect(host.textContent).not.toContain('Worked on task'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +});