feat(graph-controls): add team page and task creation buttons, improve toolbar button styles

This commit is contained in:
777genius 2026-04-13 18:36:44 +03:00
parent 6fbf8518dc
commit 07682eca37
20 changed files with 1111 additions and 403 deletions

View file

@ -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<HTMLDivElement>(null);
@ -93,7 +97,21 @@ export function GraphControls({
return (
<>
<div className="absolute left-20 top-3 z-20 flex items-center gap-3 pointer-events-none">
<div className="absolute left-20 top-3 z-20 flex items-center gap-1.5 pointer-events-none">
{onOpenTeamPage ? (
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
}}
>
<ToolbarButton onClick={onOpenTeamPage} icon={<Users size={9} />} mini title="Team page" />
{onCreateTask ? (
<ToolbarButton onClick={onCreateTask} icon={<Plus size={9} />} mini title="Create task" />
) : null}
</div>
) : null}
<div
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
style={{
@ -110,9 +128,9 @@ export function GraphControls({
</div>
</div>
<div className="absolute right-3 top-3 z-20 flex items-center gap-2 pointer-events-none">
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
<div
className="pointer-events-auto flex items-center rounded-lg px-1 py-0.5 backdrop-blur-sm"
className="pointer-events-auto flex items-center rounded-md px-px py-px backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
@ -120,13 +138,14 @@ export function GraphControls({
>
<ToolbarButton
onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={14} /> : <Pause size={14} />}
icon={filters.paused ? <Play size={9} /> : <Pause size={9} />}
mini
/>
</div>
<div ref={settingsRef} className="relative pointer-events-auto">
<div
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
className="flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
@ -134,9 +153,9 @@ export function GraphControls({
>
<ToolbarButton
onClick={() => setIsSettingsOpen((value) => !value)}
icon={<Settings2 size={14} />}
label="View"
icon={<Settings2 size={9} />}
active={isSettingsOpen}
mini
/>
</div>
@ -174,52 +193,39 @@ export function GraphControls({
</div>
<div
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
className="pointer-events-auto flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
}}
>
{onRequestPinAsTab && <ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={13} />} />}
{onRequestPinAsTab && (
<ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={9} />} mini />
)}
{onRequestFullscreen && (
<ToolbarButton
onClick={onRequestFullscreen}
icon={<Expand size={13} />}
label="Fullscreen"
icon={<Expand size={9} />}
mini
/>
)}
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />}
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={9} />} mini />}
</div>
</div>
<div className="absolute bottom-3 right-3 z-20 pointer-events-none">
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-0.5 py-[2px] backdrop-blur-sm"
style={{
background: 'rgba(8, 12, 24, 0.86)',
border: '1px solid rgba(100, 200, 255, 0.1)',
}}
>
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={14} />} />
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={14} />} label="Fit" />
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} />
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={11} />} compact />
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={11} />} label="Fit" compact />
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={11} />} compact />
</div>
</div>
{showBlockingHint && (
<div className="absolute bottom-3 left-3 z-20 pointer-events-none">
<div
className="pointer-events-auto rounded-lg px-2.5 py-1 text-[10px] font-mono backdrop-blur-sm"
style={{
background: 'rgba(40, 10, 18, 0.78)',
border: '1px solid rgba(239, 68, 68, 0.18)',
color: 'rgba(254, 202, 202, 0.95)',
}}
>
Red lines - blockers, click to inspect
</div>
</div>
)}
</>
);
}
@ -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 (
<button
onClick={onClick}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors cursor-pointer ${
title={title}
className={`flex items-center rounded-md font-mono transition-colors cursor-pointer ${
mini
? 'size-5 justify-center p-0 text-[0]'
: compact
? 'gap-0.5 px-1 py-0.5 text-[9px]'
: 'gap-1 px-2 py-1 text-[11px]'
} ${
active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'

View file

@ -43,6 +43,8 @@ export interface GraphViewProps {
onRequestClose?: () => void;
onRequestPinAsTab?: () => void;
onRequestFullscreen?: () => void;
onOpenTeamPage?: () => void;
onCreateTask?: () => void;
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
renderOverlay?: (props: {
node: GraphNode;
@ -63,6 +65,9 @@ export interface GraphViewProps {
getActivityAnchorScreenPlacement: (
ownerNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null;
getNodeScreenPosition: (
nodeId: string,
) => { x: number; y: number; visible: boolean } | null;
focusNodeIds: ReadonlySet<string> | null;
}) => React.ReactNode;
}
@ -76,6 +81,8 @@ export function GraphView({
onRequestClose,
onRequestPinAsTab,
onRequestFullscreen,
onOpenTeamPage,
onCreateTask,
renderOverlay,
renderEdgeOverlay,
renderHud,
@ -222,6 +229,24 @@ export function GraphView({
viewportHeight: viewport.height,
});
}, [getViewportSize]);
const getNodeScreenPosition = useCallback((nodeId: string) => {
const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) {
return null;
}
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
if (!node || node.x == null || node.y == null) {
return null;
}
const transform = cameraRef.current.transformRef.current;
const x = node.x * transform.zoom + transform.x;
const y = node.y * transform.zoom + transform.y;
return {
x,
y,
visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
};
}, [getViewportSize]);
const animate = useCallback(() => {
if (!runningRef.current) return;
@ -673,10 +698,11 @@ export function GraphView({
onRequestClose={onRequestClose}
onRequestPinAsTab={onRequestPinAsTab}
onRequestFullscreen={onRequestFullscreen}
onOpenTeamPage={onOpenTeamPage}
onCreateTask={onCreateTask}
teamName={data.teamName}
teamColor={data.teamColor}
isAlive={data.isAlive}
showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge}
/>
{renderHud ? (
@ -684,6 +710,7 @@ export function GraphView({
{renderHud({
getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement,
getNodeScreenPosition,
focusNodeIds: focusState.focusNodeIds,
})}
</div>

View file

@ -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<AppendedParseResult> {
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,
};
}

View file

@ -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<ParsedMessage[]> {
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<JsonlParseResult> {
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<JsonlParseResult> {
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
// =============================================================================

View file

@ -37,7 +37,7 @@ export const App = (): React.JSX.Element => {
return (
<ErrorBoundary>
<TooltipProvider delayDuration={300}>
<TooltipProvider delayDuration={150} skipDelayDuration={1500}>
<ContextSwitchOverlay />
<TabbedLayout />
<ConfirmDialog />

View file

@ -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 (
<div className="pt-1">
<div
className="text-[10px] uppercase tracking-wide"
style={{ color: 'var(--color-text-muted)' }}
>
Sources
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{links.map((link) => (
<button
key={link.url}
type="button"
onClick={() => void api.openExternal(link.url)}
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
{link.label}
<ExternalLink className="size-3" />
</button>
))}
</div>
</div>
);
};
const PlatformInstallMatrix = (): React.JSX.Element => {
return (
<div className="mt-3 grid gap-2 lg:grid-cols-3">
@ -28,10 +64,19 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
macOS
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<div>Homebrew</div>
<div>Recommended: Homebrew</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">brew install tmux</code>
<div>MacPorts</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">port install tmux</code>
<div>Alternative: MacPorts</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo port install tmux
</code>
<SourceLinks
links={[
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
{ label: 'Homebrew', url: HOMEBREW_TMUX_URL },
{ label: 'MacPorts', url: MACPORTS_TMUX_URL },
]}
/>
</div>
</div>
@ -46,11 +91,21 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
Linux
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">apt install tmux</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">dnf install tmux</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">yum install tmux</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">zypper install tmux</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">pacman -S tmux</code>
<div>Use your distro package manager:</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo apt install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo dnf install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo yum install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo zypper install tmux
</code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">sudo pacman -S tmux</code>
<SourceLinks links={[{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }]} />
</div>
</div>
@ -65,11 +120,20 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
Windows
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<p>В official tmux wiki нет native Windows install command.</p>
<p>
Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux
команд выше, например <code className="font-mono">apt install tmux</code>.
</p>
<p>The tmux docs do not provide an official native Windows install command.</p>
<div>1. Install WSL</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">wsl --install</code>
<div>2. Inside Ubuntu or another distro</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
sudo apt install tmux
</code>
<SourceLinks
links={[
{ label: 'tmux README', url: TMUX_README_URL },
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
{ label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL },
]}
/>
</div>
</div>
</div>
@ -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<BannerState>(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<void> => {
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)}
</p>
{state.status.error && (
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}>
@ -208,7 +300,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<ExternalLink className="size-3.5" />
Open guide
Official guide
</button>
</div>
</div>

View file

@ -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 */}
<div className="flex shrink-0" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button
type="button"
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void minimize()}
title="Minimize"
aria-label="Minimize"
>
<Minus className="size-4" strokeWidth={2.5} />
</button>
<button
type="button"
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void handleMaximize()}
title={isMaximized ? 'Restore' : 'Maximize'}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
<Square className="size-3.5" strokeWidth={2.5} />
</button>
<button
type="button"
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void close()}
title="Close"
aria-label="Close"
>
<X className="size-4" strokeWidth={2.5} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void minimize()}
aria-label="Minimize"
>
<Minus className="size-4" strokeWidth={2.5} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Minimize</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void handleMaximize()}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
<Square className="size-3.5" strokeWidth={2.5} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{isMaximized ? 'Restore' : 'Maximize'}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void close()}
aria-label="Close"
>
<X className="size-4" strokeWidth={2.5} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
);

View file

@ -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<string | null>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative">
{/* Trigger button */}
<button
onClick={() => setIsOpen(!isOpen)}
onMouseEnter={() => setButtonHover(true)}
onMouseLeave={() => setButtonHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
title="More actions"
>
<MoreHorizontal className="size-4" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setIsOpen(!isOpen)}
onMouseEnter={() => setButtonHover(true)}
onMouseLeave={() => setButtonHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor:
buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
aria-label="More actions"
>
<MoreHorizontal className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">More actions</TooltipContent>
</Tooltip>
{/* Dropdown menu */}
{isOpen && (

View file

@ -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 = ({
}}
/>
)}
<button
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
title="Close tab"
>
<X className="size-3" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: 'transparent' }}
onClick={(e) => {
e.stopPropagation();
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
aria-label="Close tab"
>
<X className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Close tab</TooltipContent>
</Tooltip>
</div>
);
};

View file

@ -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' && (
<button
className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
style={{
color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',
}}
onMouseEnter={() => setRefreshHover(true)}
onMouseLeave={() => setRefreshHover(false)}
onClick={handleRefresh}
title={`Refresh Session (${formatShortcut('R')})`}
>
<RefreshCw className="size-4" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
style={{
color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',
}}
onMouseEnter={() => setRefreshHover(true)}
onMouseLeave={() => setRefreshHover(false)}
onClick={handleRefresh}
aria-label="Refresh session"
>
<RefreshCw className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{`Refresh Session (${formatShortcut('R')})`}</TooltipContent>
</Tooltip>
)}
</div>

View file

@ -86,66 +86,93 @@ export const TabBarActions = (): React.JSX.Element => {
)}
{/* Notifications bell icon */}
<button
onClick={openNotificationsTab}
onMouseEnter={() => setNotificationsHover(true)}
onMouseLeave={() => setNotificationsHover(false)}
className="relative rounded-md p-2 transition-colors"
style={{
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Notifications"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={openNotificationsTab}
onMouseEnter={() => setNotificationsHover(true)}
onMouseLeave={() => setNotificationsHover(false)}
className="relative rounded-md p-2 transition-colors"
style={{
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
aria-label="Notifications"
>
<Bell className="size-4" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Notifications</TooltipContent>
</Tooltip>
{/* GitHub link */}
<button
onClick={() =>
void (isElectronMode()
? window.electronAPI.openExternal('https://github.com/777genius/claude_agent_teams_ui')
: window.open('https://github.com/777genius/claude_agent_teams_ui', '_blank'))
}
onMouseEnter={() => setGithubHover(true)}
onMouseLeave={() => setGithubHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="GitHub"
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
</svg>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={async () => {
if (isElectronMode()) {
await window.electronAPI.openExternal(
'https://github.com/777genius/claude_agent_teams_ui'
);
return;
}
window.open(
'https://github.com/777genius/claude_agent_teams_ui',
'_blank',
'noopener,noreferrer'
);
}}
onMouseEnter={() => setGithubHover(true)}
onMouseLeave={() => setGithubHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
}}
aria-label="GitHub"
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
</svg>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">GitHub</TooltipContent>
</Tooltip>
{/* Discord link */}
<button
onClick={() =>
void (isElectronMode()
? window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc')
: window.open('https://discord.gg/qtqSZSyuEc', '_blank'))
}
onMouseEnter={() => setDiscordHover(true)}
onMouseLeave={() => setDiscordHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Discord"
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.3698A19.791 19.791 0 0 0 15.4319 3.0a13.873 13.873 0 0 0-.6242 1.2757 18.27 18.27 0 0 0-5.6154 0A13.872 13.872 0 0 0 8.5681 3 19.736 19.736 0 0 0 3.683 4.3698C.5334 9.1048-.319 13.7216.099 18.272a19.9 19.9 0 0 0 6.0892 3.1157 14.96 14.96 0 0 0 1.303-2.1356 12.46 12.46 0 0 1-1.9352-.9351c.1624-.1218.3217-.2462.4763-.3736 3.7294 1.7014 7.772 1.7014 11.4572 0 .1546.1274.3139.2518.4763.3736-.6163.3622-1.2638.6754-1.9352.9351.3654.7439.8041 1.4554 1.303 2.1356A19.9 19.9 0 0 0 23.901 18.272c.5003-5.2737-.8381-9.8482-3.584-13.9022ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Zm7.96 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Z" />
</svg>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={async () => {
if (isElectronMode()) {
await window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc');
return;
}
window.open('https://discord.gg/qtqSZSyuEc', '_blank', 'noopener,noreferrer');
}}
onMouseEnter={() => setDiscordHover(true)}
onMouseLeave={() => setDiscordHover(false)}
className="rounded-md p-2 transition-colors"
style={{
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
}}
aria-label="Discord"
>
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.3698A19.791 19.791 0 0 0 15.4319 3.0a13.873 13.873 0 0 0-.6242 1.2757 18.27 18.27 0 0 0-5.6154 0A13.872 13.872 0 0 0 8.5681 3 19.736 19.736 0 0 0 3.683 4.3698C.5334 9.1048-.319 13.7216.099 18.272a19.9 19.9 0 0 0 6.0892 3.1157 14.96 14.96 0 0 0 1.303-2.1356 12.46 12.46 0 0 1-1.9352-.9351c.1624-.1218.3217-.2462.4763-.3736 3.7294 1.7014 7.772 1.7014 11.4572 0 .1546.1274.3139.2518.4763.3736-.6163.3622-1.2638.6754-1.9352.9351.3654.7439.8041 1.4554 1.303 2.1356A19.9 19.9 0 0 0 23.901 18.272c.5003-5.2737-.8381-9.8482-3.584-13.9022ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Zm7.96 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Z" />
</svg>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Discord</TooltipContent>
</Tooltip>
{/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */}
<MoreMenu
@ -156,19 +183,24 @@ export const TabBarActions = (): React.JSX.Element => {
{/* Expand sidebar — rightmost, only when collapsed */}
{sidebarCollapsed && (
<button
onClick={toggleSidebar}
onMouseEnter={() => setExpandHover(true)}
onMouseLeave={() => setExpandHover(false)}
className="mr-1 rounded-md p-2 transition-colors"
style={{
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
}}
title="Expand sidebar"
>
<PanelRight className="size-4" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleSidebar}
onMouseEnter={() => setExpandHover(true)}
onMouseLeave={() => setExpandHover(false)}
className="mr-1 rounded-md p-2 transition-colors"
style={{
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
}}
aria-label="Expand sidebar"
>
<PanelRight className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Expand sidebar</TooltipContent>
</Tooltip>
)}
</div>
);

View file

@ -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 */}
<button
onClick={openDashboard}
onMouseEnter={() => setNewTabHover(true)}
onMouseLeave={() => setNewTabHover(false)}
className="shrink-0 self-stretch px-2 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
title="New tab (Dashboard)"
>
<Plus className="size-4" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={openDashboard}
onMouseEnter={() => setNewTabHover(true)}
onMouseLeave={() => setNewTabHover(false)}
className="shrink-0 self-stretch px-2 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
aria-label="New tab"
>
<Plus className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">New tab (Dashboard)</TooltipContent>
</Tooltip>
</div>
{/* Action buttons — right side */}

View file

@ -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<HTMLDivElement>(null);
const wasProvisioningRef = useRef(false);
const pendingReplyRefreshTimerRef = useRef<number | null>(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<CSSProperties>(
() =>
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}
</p>
)}
{(data.config.projectPath || leadBranch) && (
<div
className={cn(
'mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5',
headerColorSet && 'relative z-10'
)}
>
<div
className={cn(
'mt-1 flex items-start justify-between gap-3',
headerColorSet && 'relative z-10'
)}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5">
{data.config.projectPath && (
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]">
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
@ -2108,7 +2130,28 @@ export const TeamDetailView = ({
</span>
)}
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'-mt-2 h-8 shrink-0 self-start rounded-full border px-3.5 text-xs font-semibold tracking-[0.02em] transition-all',
'hover:-translate-y-0.5 hover:brightness-[1.03] active:translate-y-0 active:brightness-[0.98]',
isLight
? 'hover:border-sky-400/50'
: 'hover:border-cyan-300/50 hover:shadow-[0_14px_32px_rgba(8,145,178,0.28)]'
)}
style={visualizeButtonStyle}
onClick={handleOpenGraphTab}
>
<Network size={13} className="shrink-0" />
Visualize
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Open team graph</TooltipContent>
</Tooltip>
</div>
{(() => {
const currentPath = data.config.projectPath;
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
@ -2159,27 +2202,6 @@ export const TeamDetailView = ({
defaultOpen
action={
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
useStore.getState().openTab({
type: 'graph',
label: `${data.config.name} Graph`,
teamName,
});
}}
>
<span className="relative">
<Network size={12} />
<span className="absolute -right-3.5 -top-2.5 rounded-sm bg-emerald-500/20 px-0.5 py-px text-[7px] font-bold leading-none text-emerald-400">
NEW
</span>
</span>
Graph
</Button>
<Button
variant="ghost"
size="sm"

View file

@ -13,6 +13,31 @@ interface TaskActivitySectionProps {
taskId: string;
}
function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean {
return entry.linkKind !== 'execution';
}
function compareTaskActivityEntriesDesc(
left: BoardTaskActivityEntry,
right: BoardTaskActivityEntry
): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return rightTs - leftTs;
}
if (left.source.filePath !== right.source.filePath) {
return right.source.filePath.localeCompare(left.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return right.source.sourceOrder - left.source.sourceOrder;
}
return right.id.localeCompare(left.id);
}
function formatEntryTime(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
@ -158,6 +183,15 @@ export function TaskActivitySection({
};
}, [entries.length, teamName, taskId]);
const visibleEntries = useMemo(
() =>
entries
.filter((entry) => isHighSignalTaskActivityEntry(entry))
.sort(compareTaskActivityEntriesDesc),
[entries]
);
const hasOnlyLowSignalExecution = entries.length > 0 && visibleEntries.length === 0;
const content = useMemo(() => {
if (loading) {
return (
@ -177,23 +211,24 @@ export function TaskActivitySection({
);
}
if (entries.length === 0) {
if (visibleEntries.length === 0) {
return (
<p className="text-xs text-[var(--color-text-muted)]">
No explicit task activity was found in the available transcripts yet. Older or heuristic
session logs may still be available below in Execution Sessions.
{hasOnlyLowSignalExecution
? 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.'
: 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.'}
</p>
);
}
return (
<div className="space-y-2">
{entries.map((entry) => (
{visibleEntries.map((entry) => (
<Row key={entry.id} entry={entry} />
))}
</div>
);
}, [entries, error, loading]);
}, [error, hasOnlyLowSignalExecution, loading, visibleEntries]);
return (
<div className="space-y-2">
@ -203,7 +238,7 @@ export function TaskActivitySection({
</h4>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Explicit runtime activity linked to this task from transcript metadata.
Key explicit runtime activity linked to this task from transcript metadata.
</p>
{content}
</div>

View file

@ -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<string> | 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<string, HTMLDivElement | null>());
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(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<string, TimelineItem>();
@ -216,71 +272,90 @@ export function GraphActivityHud({
return (
<>
{visibleLanes.map((lane) => (
<div
key={lane.node.id}
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0"
>
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity
</div>
<div className="space-y-2">
{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);
<div key={lane.node.id}>
<svg
ref={(element) => {
connectorRefs.current.set(lane.node.id, element);
}}
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
>
<path
ref={(element) => {
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"
/>
</svg>
<div
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0"
>
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity
</div>
<div className="space-y-2">
{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 (
<div
key={entry.graphItem.id}
className="cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(timelineItem);
}
}}
return (
<div
key={entry.graphItem.id}
className="cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(timelineItem);
}
}}
>
<ActivityItem
message={entry.message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
expandItemKey={messageKey}
onExpand={handleExpandItem}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
onMemberNameClick={handleMemberNameClick}
onTaskIdClick={onOpenTaskDetail}
zebraShade={index % 2 === 1}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</div>
);
})}
{lane.overflowCount > 0 ? (
<button
type="button"
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => handleOpenOwnerActivity(lane.node)}
>
<ActivityItem
message={entry.message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
expandItemKey={messageKey}
onExpand={handleExpandItem}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
onMemberNameClick={handleMemberNameClick}
onTaskIdClick={onOpenTaskDetail}
zebraShade={index % 2 === 1}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</div>
);
})}
{lane.overflowCount > 0 ? (
<button
type="button"
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => handleOpenOwnerActivity(lane.node)}
>
+{lane.overflowCount} more
</button>
) : null}
+{lane.overflowCount} more
</button>
) : null}
</div>
</div>
</div>
))}

View file

@ -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}

View file

@ -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}

View file

@ -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<void>;
lastProcessedLineCount: Map<string, number>;
lastProcessedSize: Map<string, number>;
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
// ===========================================================================

View file

@ -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');
});
});
});

View file

@ -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<BoardTaskActivityEntry[]>>(),
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
getTaskActivity: (...args: Parameters<typeof apiState.getTaskActivity>) =>
apiState.getTaskActivity(...args),
},
},
}));
import { TaskActivitySection } from '@renderer/components/team/taskLogs/TaskActivitySection';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function makeEntry(
overrides: Partial<BoardTaskActivityEntry> & Pick<BoardTaskActivityEntry, 'id' | 'linkKind'>
): 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();
});
});
});