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);
+
+
+
{
+ 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();
+ });
+ });
+});