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, Pause,
Pin, Pin,
Play, Play,
Plus,
Server, Server,
Users,
X, X,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
@ -36,10 +38,11 @@ export interface GraphControlsProps {
onRequestClose?: () => void; onRequestClose?: () => void;
onRequestPinAsTab?: () => void; onRequestPinAsTab?: () => void;
onRequestFullscreen?: () => void; onRequestFullscreen?: () => void;
onOpenTeamPage?: () => void;
onCreateTask?: () => void;
teamName: string; teamName: string;
teamColor?: string; teamColor?: string;
isAlive?: boolean; isAlive?: boolean;
showBlockingHint?: boolean;
} }
export function GraphControls({ export function GraphControls({
@ -51,10 +54,11 @@ export function GraphControls({
onRequestClose, onRequestClose,
onRequestPinAsTab, onRequestPinAsTab,
onRequestFullscreen, onRequestFullscreen,
onOpenTeamPage,
onCreateTask,
teamName, teamName,
teamColor, teamColor,
isAlive, isAlive,
showBlockingHint = false,
}: GraphControlsProps): React.JSX.Element { }: GraphControlsProps): React.JSX.Element {
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null); const settingsRef = useRef<HTMLDivElement>(null);
@ -93,7 +97,21 @@ export function GraphControls({
return ( 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 <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-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
style={{ style={{
@ -110,9 +128,9 @@ export function GraphControls({
</div> </div>
</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 <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={{ style={{
background: 'rgba(8, 12, 24, 0.8)', background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)', border: '1px solid rgba(100, 200, 255, 0.08)',
@ -120,13 +138,14 @@ export function GraphControls({
> >
<ToolbarButton <ToolbarButton
onClick={() => toggle('paused')} onClick={() => toggle('paused')}
icon={filters.paused ? <Play size={14} /> : <Pause size={14} />} icon={filters.paused ? <Play size={9} /> : <Pause size={9} />}
mini
/> />
</div> </div>
<div ref={settingsRef} className="relative pointer-events-auto"> <div ref={settingsRef} className="relative pointer-events-auto">
<div <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={{ style={{
background: 'rgba(8, 12, 24, 0.8)', background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)', border: '1px solid rgba(100, 200, 255, 0.08)',
@ -134,9 +153,9 @@ export function GraphControls({
> >
<ToolbarButton <ToolbarButton
onClick={() => setIsSettingsOpen((value) => !value)} onClick={() => setIsSettingsOpen((value) => !value)}
icon={<Settings2 size={14} />} icon={<Settings2 size={9} />}
label="View"
active={isSettingsOpen} active={isSettingsOpen}
mini
/> />
</div> </div>
@ -174,52 +193,39 @@ export function GraphControls({
</div> </div>
<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={{ style={{
background: 'rgba(8, 12, 24, 0.8)', background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)', 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 && ( {onRequestFullscreen && (
<ToolbarButton <ToolbarButton
onClick={onRequestFullscreen} onClick={onRequestFullscreen}
icon={<Expand size={13} />} icon={<Expand size={9} />}
label="Fullscreen" mini
/> />
)} )}
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />} {onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={9} />} mini />}
</div> </div>
</div> </div>
<div className="absolute bottom-3 right-3 z-20 pointer-events-none"> <div className="absolute bottom-3 right-3 z-20 pointer-events-none">
<div <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={{ style={{
background: 'rgba(8, 12, 24, 0.86)', background: 'rgba(8, 12, 24, 0.86)',
border: '1px solid rgba(100, 200, 255, 0.1)', border: '1px solid rgba(100, 200, 255, 0.1)',
}} }}
> >
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={14} />} /> <ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={11} />} compact />
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={14} />} label="Fit" /> <ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={11} />} label="Fit" compact />
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} /> <ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={11} />} compact />
</div> </div>
</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, icon,
label, label,
active = false, active = false,
compact = false,
mini = false,
title,
}: { }: {
onClick?: () => void; onClick?: () => void;
icon: React.ReactNode; icon: React.ReactNode;
label?: string; label?: string;
active?: boolean; active?: boolean;
compact?: boolean;
mini?: boolean;
title?: string;
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<button <button
onClick={onClick} 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 active
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]' ? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]' : 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'

View file

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

View file

@ -11,7 +11,7 @@
*/ */
import { type FileChangeEvent, type ParsedMessage } from '@main/types'; import { type FileChangeEvent, type ParsedMessage } from '@main/types';
import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl'; import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl';
import { import {
getProjectsBasePath, getProjectsBasePath,
getTasksBasePath, getTasksBasePath,
@ -765,12 +765,12 @@ export class FileWatcher extends EventEmitter {
const currentSize = fileStats.size; const currentSize = fileStats.size;
// Fast path: no size change means no new data // Fast path: no size change means no new data
if (currentSize === lastSize && lastLineCount > 0) { if (currentSize === lastSize && lastSize > 0) {
return; return;
} }
const isFirstRead = lastLineCount === 0 && lastSize === 0; const isFirstRead = lastLineCount === 0 && lastSize === 0;
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize; const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize;
let newMessages: ParsedMessage[] = []; let newMessages: ParsedMessage[] = [];
let currentLineCount: number; let currentLineCount: number;
let processedSize: number; let processedSize: number;
@ -782,12 +782,10 @@ export class FileWatcher extends EventEmitter {
processedSize = lastSize + appended.consumedBytes; processedSize = lastSize + appended.consumedBytes;
} else { } else {
// Fallback for first-read, truncation, or rewrite scenarios // Fallback for first-read, truncation, or rewrite scenarios
const messages = await parseJsonlFile(filePath); const parsedFile = await parseJsonlFileWithStats(filePath, this.fsProvider);
currentLineCount = messages.length; currentLineCount = parsedFile.parsedLineCount;
newMessages = messages.slice(lastLineCount); newMessages = parsedFile.messages.slice(lastLineCount);
// Re-stat after full parse to capture bytes written during the parse processedSize = parsedFile.consumedBytes;
const postParseStats = await this.fsProvider.stat(filePath);
processedSize = postParseStats.size;
} }
// If no new lines, skip processing // If no new lines, skip processing
@ -895,56 +893,15 @@ export class FileWatcher extends EventEmitter {
filePath: string, filePath: string,
startOffset: number startOffset: number
): Promise<AppendedParseResult> { ): Promise<AppendedParseResult> {
const parsedMessages: ParsedMessage[] = [];
const stream = this.fsProvider.createReadStream(filePath, { const stream = this.fsProvider.createReadStream(filePath, {
start: startOffset, start: startOffset,
encoding: 'utf8',
}); });
const parsed = await parseJsonlStream(stream);
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.
}
}
return { return {
messages: parsedMessages, messages: parsed.messages,
parsedLineCount, parsedLineCount: parsed.parsedLineCount,
consumedBytes, consumedBytes: parsed.consumedBytes,
}; };
} }

View file

@ -34,6 +34,7 @@ import { extractToolCalls, extractToolResults } from './toolExtraction';
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider'; import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
import type { PhaseTokenBreakdown } from '../types/domain'; import type { PhaseTokenBreakdown } from '../types/domain';
import type { Readable } from 'stream';
const logger = createLogger('Util:jsonl'); const logger = createLogger('Util:jsonl');
@ -47,6 +48,12 @@ export { checkMessagesOngoing } from './sessionStateDetection';
// Core Parsing Functions // Core Parsing Functions
// ============================================================================= // =============================================================================
export interface JsonlParseResult {
messages: ParsedMessage[];
parsedLineCount: number;
consumedBytes: number;
}
/** /**
* Parse a JSONL file line by line using streaming. * Parse a JSONL file line by line using streaming.
* This avoids loading the entire file into memory. * This avoids loading the entire file into memory.
@ -55,38 +62,130 @@ export async function parseJsonlFile(
filePath: string, filePath: string,
fsProvider: FileSystemProvider = defaultProvider fsProvider: FileSystemProvider = defaultProvider
): Promise<ParsedMessage[]> { ): Promise<ParsedMessage[]> {
const messages: ParsedMessage[] = [];
if (!(await fsProvider.exists(filePath))) { if (!(await fsProvider.exists(filePath))) {
return messages; return [];
} }
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const result = await parseJsonlStream(fsProvider.createReadStream(filePath), filePath);
const rl = readline.createInterface({ return result.messages;
input: fileStream, }
crlfDelay: Infinity,
});
let lineCount = 0; /**
for await (const line of rl) { * Parse a JSONL file and return byte accounting details for incremental readers.
if (!line.trim()) continue; */
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 { try {
const parsed = parseJsonlLine(line); const parsed = parseJsonlLine(normalized);
if (parsed) { if (parsed) {
messages.push(parsed); messages.push(parsed);
parsedLineCount += 1;
} }
} catch (error) { } catch {
logger.error(`Error parsing line in ${filePath}:`, error); malformedLineCount += 1;
} }
};
lineCount++; for await (const chunk of stream) {
if (lineCount % 250 === 0) { const chunkBuffer =
await yieldToEventLoop(); 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. * Returns null for invalid/unsupported lines.
*/ */
export function parseJsonlLine(line: string): ParsedMessage | null { export function parseJsonlLine(line: string): ParsedMessage | null {
if (!line.trim()) { const normalized = normalizeJsonlLine(line);
if (!normalized) {
return null; return null;
} }
const entry = JSON.parse(line) as ChatHistoryEntry; if (!looksLikeJsonObjectLine(normalized)) {
return null;
}
const entry = JSON.parse(normalized) as ChatHistoryEntry;
return parseChatHistoryEntry(entry); 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 // Entry Parsing
// ============================================================================= // =============================================================================

View file

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

View file

@ -6,6 +6,15 @@ import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react';
import type { TmuxStatus } from '@shared/types'; import type { TmuxStatus } from '@shared/types';
const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; 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 = type BannerState =
| { loading: true; status: null; error: null } | { loading: true; status: null; error: null }
@ -14,6 +23,33 @@ type BannerState =
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; 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 => { const PlatformInstallMatrix = (): React.JSX.Element => {
return ( return (
<div className="mt-3 grid gap-2 lg:grid-cols-3"> <div className="mt-3 grid gap-2 lg:grid-cols-3">
@ -28,10 +64,19 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
macOS macOS
</div> </div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}> <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> <code className="block rounded bg-black/20 px-2 py-1 font-mono">brew install tmux</code>
<div>MacPorts</div> <div>Alternative: MacPorts</div>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">port install tmux</code> <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>
</div> </div>
@ -46,11 +91,21 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
Linux Linux
</div> </div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}> <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> <div>Use your distro package manager:</div>
<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">
<code className="block rounded bg-black/20 px-2 py-1 font-mono">yum install tmux</code> sudo apt install tmux
<code className="block rounded bg-black/20 px-2 py-1 font-mono">zypper install tmux</code> </code>
<code className="block rounded bg-black/20 px-2 py-1 font-mono">pacman -S 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>
</div> </div>
@ -65,11 +120,20 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
Windows Windows
</div> </div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}> <div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<p>В official tmux wiki нет native Windows install command.</p> <p>The tmux docs do not provide an official native Windows install command.</p>
<p> <div>1. Install WSL</div>
Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux <code className="block rounded bg-black/20 px-2 py-1 font-mono">wsl --install</code>
команд выше, например <code className="font-mono">apt install tmux</code>. <div>2. Inside Ubuntu or another distro</div>
</p> <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> </div>
</div> </div>
@ -78,21 +142,25 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
function getPrimaryDetail(status: TmuxStatus): string { function getPrimaryDetail(status: TmuxStatus): string {
if (status.platform === 'darwin') { if (status.platform === 'darwin') {
return 'На macOS проще всего поставить tmux через Homebrew или MacPorts.'; return 'On macOS, the simplest options are Homebrew or MacPorts.';
} }
if (status.platform === 'linux') { 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') { 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 => { export const TmuxStatusBanner = (): React.JSX.Element | null => {
const isElectron = useMemo(() => isElectronMode(), []); const isElectron = useMemo(() => isElectronMode(), []);
const [state, setState] = useState<BannerState>(INITIAL_STATE); const [state, setState] = useState<BannerState>(INITIAL_STATE);
const loadStatus = useCallback(async () => {
return api.tmux.getStatus();
}, []);
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
setState( setState(
(prev) => (prev) =>
@ -104,7 +172,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
); );
try { try {
const status = await api.tmux.getStatus(); const status = await loadStatus();
setState({ loading: false, status, error: null }); setState({ loading: false, status, error: null });
} catch (error) { } catch (error) {
setState({ setState({
@ -113,14 +181,38 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
error: error instanceof Error ? error.message : 'Failed to check tmux status', error: error instanceof Error ? error.message : 'Failed to check tmux status',
}); });
} }
}, []); }, [loadStatus]);
useEffect(() => { useEffect(() => {
if (!isElectron) { if (!isElectron) {
return; 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 (!isElectron) return null;
if (state.loading && !state.status) 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" className="mt-1 text-xs leading-relaxed"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
Persistent team agents работают стабильнее в process/tmux path. Без tmux app остаётся Persistent team agents are more reliable on the process/tmux path. Without tmux, the
на более тяжёлом in-process пути. {getPrimaryDetail(state.status)} app falls back to the heavier in-process path. {getPrimaryDetail(state.status)}
</p> </p>
{state.status.error && ( {state.status.error && (
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}> <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)' }} style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
> >
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
Open guide Official guide
</button> </button>
</div> </div>
</div> </div>

View file

@ -8,6 +8,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import faviconUrl from '@renderer/favicon.png'; import faviconUrl from '@renderer/favicon.png';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Minus, Square, X } from 'lucide-react'; 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 */} {/* Window controls — no-drag so they receive clicks */}
<div className="flex shrink-0" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}> <div className="flex shrink-0" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className={`${buttonBase} ${buttonHover}`} <button
style={{ color: 'var(--color-text-secondary)' }} type="button"
onClick={() => void minimize()} className={`${buttonBase} ${buttonHover}`}
title="Minimize" style={{ color: 'var(--color-text-secondary)' }}
aria-label="Minimize" onClick={() => void minimize()}
> aria-label="Minimize"
<Minus className="size-4" strokeWidth={2.5} /> >
</button> <Minus className="size-4" strokeWidth={2.5} />
<button </button>
type="button" </TooltipTrigger>
className={`${buttonBase} ${buttonHover}`} <TooltipContent side="bottom">Minimize</TooltipContent>
style={{ color: 'var(--color-text-secondary)' }} </Tooltip>
onClick={() => void handleMaximize()} <Tooltip>
title={isMaximized ? 'Restore' : 'Maximize'} <TooltipTrigger asChild>
aria-label={isMaximized ? 'Restore' : 'Maximize'} <button
> type="button"
<Square className="size-3.5" strokeWidth={2.5} /> className={`${buttonBase} ${buttonHover}`}
</button> style={{ color: 'var(--color-text-secondary)' }}
<button onClick={() => void handleMaximize()}
type="button" aria-label={isMaximized ? 'Restore' : 'Maximize'}
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`} >
style={{ color: 'var(--color-text-secondary)' }} <Square className="size-3.5" strokeWidth={2.5} />
onClick={() => void close()} </button>
title="Close" </TooltipTrigger>
aria-label="Close" <TooltipContent side="bottom">{isMaximized ? 'Restore' : 'Maximize'}</TooltipContent>
> </Tooltip>
<X className="size-4" strokeWidth={2.5} /> <Tooltip>
</button> <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>
</div> </div>
); );

View file

@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { triggerDownload } from '@renderer/utils/sessionExporter'; import { triggerDownload } from '@renderer/utils/sessionExporter';
import { formatShortcut } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
@ -22,6 +23,7 @@ import {
Type, Type,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { SessionDetail } from '@renderer/types/data'; import type { SessionDetail } from '@renderer/types/data';
import type { Tab } from '@renderer/types/tabs'; import type { Tab } from '@renderer/types/tabs';
@ -51,12 +53,23 @@ export const MoreMenu = ({
const [hoveredId, setHoveredId] = useState<string | null>(null); const [hoveredId, setHoveredId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const openCommandPalette = useStore((s) => s.openCommandPalette); const {
const openExtensionsTab = useStore((s) => s.openExtensionsTab); openCommandPalette,
const openSessionReport = useStore((s) => s.openSessionReport); openExtensionsTab,
const openSchedulesTab = useStore((s) => s.openSchedulesTab); openSessionReport,
const openSettingsTab = useStore((s) => s.openSettingsTab); openSchedulesTab,
const openTeamsTab = useStore((s) => s.openTeamsTab); 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 // Close on outside click
useEffect(() => { useEffect(() => {
@ -212,19 +225,25 @@ export const MoreMenu = ({
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{/* Trigger button */} {/* Trigger button */}
<button <Tooltip>
onClick={() => setIsOpen(!isOpen)} <TooltipTrigger asChild>
onMouseEnter={() => setButtonHover(true)} <button
onMouseLeave={() => setButtonHover(false)} onClick={() => setIsOpen(!isOpen)}
className="rounded-md p-2 transition-colors" onMouseEnter={() => setButtonHover(true)}
style={{ onMouseLeave={() => setButtonHover(false)}
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)', className="rounded-md p-2 transition-colors"
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent', style={{
}} color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
title="More actions" backgroundColor:
> buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
<MoreHorizontal className="size-4" /> }}
</button> aria-label="More actions"
>
<MoreHorizontal className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">More actions</TooltipContent>
</Tooltip>
{/* Dropdown menu */} {/* Dropdown menu */}
{isOpen && ( {isOpen && (

View file

@ -7,6 +7,7 @@ import { useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { import {
getTeamColorSet, getTeamColorSet,
getThemedBadge, getThemedBadge,
@ -211,18 +212,23 @@ export const SortableTab = ({
}} }}
/> />
)} )}
<button <Tooltip>
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100" <TooltipTrigger asChild>
style={{ backgroundColor: 'transparent' }} <button
onClick={(e) => { className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
e.stopPropagation(); style={{ backgroundColor: 'transparent' }}
onClose(tab.id); onClick={(e) => {
}} e.stopPropagation();
onPointerDown={(e) => e.stopPropagation()} onClose(tab.id);
title="Close tab" }}
> onPointerDown={(e) => e.stopPropagation()}
<X className="size-3" /> aria-label="Close tab"
</button> >
<X className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Close tab</TooltipContent>
</Tooltip>
</div> </div>
); );
}; };

View file

@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
@ -293,19 +294,24 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
{/* Refresh button - show only for session tabs */} {/* Refresh button - show only for session tabs */}
{activeTab?.type === 'session' && ( {activeTab?.type === 'session' && (
<button <Tooltip>
className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors" <TooltipTrigger asChild>
style={{ <button
color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)', className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent', style={{
}} color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',
onMouseEnter={() => setRefreshHover(true)} backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',
onMouseLeave={() => setRefreshHover(false)} }}
onClick={handleRefresh} onMouseEnter={() => setRefreshHover(true)}
title={`Refresh Session (${formatShortcut('R')})`} onMouseLeave={() => setRefreshHover(false)}
> onClick={handleRefresh}
<RefreshCw className="size-4" /> aria-label="Refresh session"
</button> >
<RefreshCw className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{`Refresh Session (${formatShortcut('R')})`}</TooltipContent>
</Tooltip>
)} )}
</div> </div>

View file

@ -86,66 +86,93 @@ export const TabBarActions = (): React.JSX.Element => {
)} )}
{/* Notifications bell icon */} {/* Notifications bell icon */}
<button <Tooltip>
onClick={openNotificationsTab} <TooltipTrigger asChild>
onMouseEnter={() => setNotificationsHover(true)} <button
onMouseLeave={() => setNotificationsHover(false)} onClick={openNotificationsTab}
className="relative rounded-md p-2 transition-colors" onMouseEnter={() => setNotificationsHover(true)}
style={{ onMouseLeave={() => setNotificationsHover(false)}
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)', className="relative rounded-md p-2 transition-colors"
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent', style={{
}} color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
title="Notifications" backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
> }}
<Bell className="size-4" /> aria-label="Notifications"
{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"> <Bell className="size-4" />
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 0 && (
</span> <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}
</button> </span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Notifications</TooltipContent>
</Tooltip>
{/* GitHub link */} {/* GitHub link */}
<button <Tooltip>
onClick={() => <TooltipTrigger asChild>
void (isElectronMode() <button
? window.electronAPI.openExternal('https://github.com/777genius/claude_agent_teams_ui') onClick={async () => {
: window.open('https://github.com/777genius/claude_agent_teams_ui', '_blank')) if (isElectronMode()) {
} await window.electronAPI.openExternal(
onMouseEnter={() => setGithubHover(true)} 'https://github.com/777genius/claude_agent_teams_ui'
onMouseLeave={() => setGithubHover(false)} );
className="rounded-md p-2 transition-colors" return;
style={{ }
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent', window.open(
}} 'https://github.com/777genius/claude_agent_teams_ui',
title="GitHub" '_blank',
> 'noopener,noreferrer'
<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> onMouseEnter={() => setGithubHover(true)}
</button> 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 */} {/* Discord link */}
<button <Tooltip>
onClick={() => <TooltipTrigger asChild>
void (isElectronMode() <button
? window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc') onClick={async () => {
: window.open('https://discord.gg/qtqSZSyuEc', '_blank')) if (isElectronMode()) {
} await window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc');
onMouseEnter={() => setDiscordHover(true)} return;
onMouseLeave={() => setDiscordHover(false)} }
className="rounded-md p-2 transition-colors"
style={{ window.open('https://discord.gg/qtqSZSyuEc', '_blank', 'noopener,noreferrer');
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)', }}
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent', onMouseEnter={() => setDiscordHover(true)}
}} onMouseLeave={() => setDiscordHover(false)}
title="Discord" className="rounded-md p-2 transition-colors"
> style={{
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
<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" /> backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
</svg> }}
</button> 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) */} {/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */}
<MoreMenu <MoreMenu
@ -156,19 +183,24 @@ export const TabBarActions = (): React.JSX.Element => {
{/* Expand sidebar — rightmost, only when collapsed */} {/* Expand sidebar — rightmost, only when collapsed */}
{sidebarCollapsed && ( {sidebarCollapsed && (
<button <Tooltip>
onClick={toggleSidebar} <TooltipTrigger asChild>
onMouseEnter={() => setExpandHover(true)} <button
onMouseLeave={() => setExpandHover(false)} onClick={toggleSidebar}
className="mr-1 rounded-md p-2 transition-colors" onMouseEnter={() => setExpandHover(true)}
style={{ onMouseLeave={() => setExpandHover(false)}
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)', className="mr-1 rounded-md p-2 transition-colors"
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent', style={{
}} color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
title="Expand sidebar" backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
> }}
<PanelRight className="size-4" /> aria-label="Expand sidebar"
</button> >
<PanelRight className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Expand sidebar</TooltipContent>
</Tooltip>
)} )}
</div> </div>
); );

View file

@ -7,6 +7,7 @@
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
@ -70,22 +71,27 @@ export const TabBarRow = (): React.JSX.Element => {
))} ))}
{/* New tab button — right after last tab */} {/* New tab button — right after last tab */}
<button <Tooltip>
onClick={openDashboard} <TooltipTrigger asChild>
onMouseEnter={() => setNewTabHover(true)} <button
onMouseLeave={() => setNewTabHover(false)} onClick={openDashboard}
className="shrink-0 self-stretch px-2 transition-colors" onMouseEnter={() => setNewTabHover(true)}
style={ onMouseLeave={() => setNewTabHover(false)}
{ className="shrink-0 self-stretch px-2 transition-colors"
WebkitAppRegion: 'no-drag', style={
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)', {
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent', WebkitAppRegion: 'no-drag',
} as React.CSSProperties color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
} backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
title="New tab (Dashboard)" } as React.CSSProperties
> }
<Plus className="size-4" /> aria-label="New tab"
</button> >
<Plus className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">New tab (Dashboard)</TooltipContent>
</Tooltip>
</div> </div>
{/* Action buttons — right side */} {/* 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 { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { ComponentProps } from 'react'; import type { ComponentProps, CSSProperties } from 'react';
const ProjectEditorOverlay = lazy(() => const ProjectEditorOverlay = lazy(() =>
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
@ -822,6 +822,34 @@ export const TeamDetailView = ({
const provisioningBannerRef = useRef<HTMLDivElement>(null); const provisioningBannerRef = useRef<HTMLDivElement>(null);
const wasProvisioningRef = useRef(false); const wasProvisioningRef = useRef(false);
const pendingReplyRefreshTimerRef = useRef<number | null>(null); 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) // Set inert on background content when editor/graph overlay is open (a11y focus trap)
useEffect(() => { useEffect(() => {
@ -839,18 +867,12 @@ export const TeamDetailView = ({
const handler = (e: Event) => { const handler = (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
if (detail?.teamName === teamName) { if (detail?.teamName === teamName) {
const state = useStore.getState(); handleOpenGraphTab();
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
useStore.getState().openTab({
type: 'graph',
label: `${displayName} Graph`,
teamName,
});
} }
}; };
window.addEventListener('toggle-team-graph', handler); window.addEventListener('toggle-team-graph', handler);
return () => window.removeEventListener('toggle-team-graph', handler); return () => window.removeEventListener('toggle-team-graph', handler);
}, [teamName]); }, [handleOpenGraphTab, teamName]);
// Listen for graph tab actions (open task, send message) // Listen for graph tab actions (open task, send message)
useEffect(() => { useEffect(() => {
@ -2059,13 +2081,13 @@ export const TeamDetailView = ({
{data.config.description} {data.config.description}
</p> </p>
)} )}
{(data.config.projectPath || leadBranch) && ( <div
<div className={cn(
className={cn( 'mt-1 flex items-start justify-between gap-3',
'mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5', headerColorSet && 'relative z-10'
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 && ( {data.config.projectPath && (
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]"> <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)]" /> <FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
@ -2108,7 +2130,28 @@ export const TeamDetailView = ({
</span> </span>
)} )}
</div> </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 currentPath = data.config.projectPath;
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
@ -2159,27 +2202,6 @@ export const TeamDetailView = ({
defaultOpen defaultOpen
action={ action={
<div className="flex items-center gap-1"> <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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View file

@ -13,6 +13,31 @@ interface TaskActivitySectionProps {
taskId: string; 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 { function formatEntryTime(timestamp: string): string {
const date = new Date(timestamp); const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
@ -158,6 +183,15 @@ export function TaskActivitySection({
}; };
}, [entries.length, teamName, taskId]); }, [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(() => { const content = useMemo(() => {
if (loading) { if (loading) {
return ( return (
@ -177,23 +211,24 @@ export function TaskActivitySection({
); );
} }
if (entries.length === 0) { if (visibleEntries.length === 0) {
return ( return (
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
No explicit task activity was found in the available transcripts yet. Older or heuristic {hasOnlyLowSignalExecution
session logs may still be available below in Execution Sessions. ? '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> </p>
); );
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{entries.map((entry) => ( {visibleEntries.map((entry) => (
<Row key={entry.id} entry={entry} /> <Row key={entry.id} entry={entry} />
))} ))}
</div> </div>
); );
}, [entries, error, loading]); }, [error, hasOnlyLowSignalExecution, loading, visibleEntries]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -203,7 +238,7 @@ export function TaskActivitySection({
</h4> </h4>
</div> </div>
<p className="text-xs text-[var(--color-text-muted)]"> <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> </p>
{content} {content}
</div> </div>

View file

@ -33,6 +33,7 @@ interface GraphActivityHudProps {
getActivityAnchorScreenPlacement: ( getActivityAnchorScreenPlacement: (
ownerNodeId: string ownerNodeId: string
) => { x: number; y: number; scale: number; visible: boolean } | null; ) => { x: number; y: number; scale: number; visible: boolean } | null;
getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null;
focusNodeIds: ReadonlySet<string> | null; focusNodeIds: ReadonlySet<string> | null;
enabled?: boolean; enabled?: boolean;
onOpenTaskDetail?: (taskId: string) => void; onOpenTaskDetail?: (taskId: string) => void;
@ -49,12 +50,15 @@ export function GraphActivityHud({
teamName, teamName,
nodes, nodes,
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getNodeScreenPosition = () => null,
focusNodeIds, focusNodeIds,
enabled = true, enabled = true,
onOpenTaskDetail, onOpenTaskDetail,
onOpenMemberProfile, onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null { }: GraphActivityHudProps): React.JSX.Element | null {
const shellRefs = useRef(new Map<string, HTMLDivElement | 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 [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { teamData, teams } = useStore( const { teamData, teams } = useStore(
useShallow((state) => ({ useShallow((state) => ({
@ -126,6 +130,11 @@ export function GraphActivityHud({
shell.style.opacity = '0'; shell.style.opacity = '0';
} }
} }
for (const connector of connectorRefs.current.values()) {
if (connector) {
connector.style.opacity = '0';
}
}
return; return;
} }
@ -136,16 +145,57 @@ export function GraphActivityHud({
if (!shell) { if (!shell) {
continue; continue;
} }
const connector = connectorRefs.current.get(lane.node.id);
const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
const placement = getActivityAnchorScreenPlacement(lane.node.id); 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'; shell.style.opacity = '0';
if (connector) {
connector.style.opacity = '0';
}
continue; continue;
} }
const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
shell.style.opacity = String(baseOpacity); shell.style.opacity = String(baseOpacity);
shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; 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); frameId = window.requestAnimationFrame(updatePositions);
@ -155,7 +205,13 @@ export function GraphActivityHud({
return () => { return () => {
window.cancelAnimationFrame(frameId); window.cancelAnimationFrame(frameId);
}; };
}, [enabled, focusNodeIds, getActivityAnchorScreenPlacement, visibleLanes]); }, [
enabled,
focusNodeIds,
getActivityAnchorScreenPlacement,
getNodeScreenPosition,
visibleLanes,
]);
const expandedItemsByKey = useMemo(() => { const expandedItemsByKey = useMemo(() => {
const items = new Map<string, TimelineItem>(); const items = new Map<string, TimelineItem>();
@ -216,71 +272,90 @@ export function GraphActivityHud({
return ( return (
<> <>
{visibleLanes.map((lane) => ( {visibleLanes.map((lane) => (
<div <div key={lane.node.id}>
key={lane.node.id} <svg
ref={(element) => { ref={(element) => {
shellRefs.current.set(lane.node.id, element); connectorRefs.current.set(lane.node.id, element);
}} }}
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0" className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
> >
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70"> <path
Activity ref={(element) => {
</div> connectorPathRefs.current.set(lane.node.id, element);
<div className="space-y-2"> }}
{lane.entries.map((entry, index) => { d=""
const messageKey = toMessageKey(entry.message); fill="none"
const renderProps = resolveMessageRenderProps(entry.message, messageContext); stroke="rgba(148, 163, 184, 0.3)"
const timelineItem: TimelineItem = { type: 'message', message: entry.message }; strokeWidth="1.25"
const isUnread = !entry.message.read && !readSet.has(messageKey); 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 ( return (
<div <div
key={entry.graphItem.id} key={entry.graphItem.id}
className="cursor-pointer" className="cursor-pointer"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => handleMessageClick(timelineItem)} onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
handleMessageClick(timelineItem); 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 +{lane.overflowCount} more
message={entry.message} </button>
teamName={teamName} ) : null}
compactHeader </div>
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}
</div> </div>
</div> </div>
))} ))}

View file

@ -7,6 +7,7 @@ import { useCallback, useMemo } from 'react';
import { GraphView } from '@claude-teams/agent-graph'; import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useStore } from '@renderer/store';
import type { import type {
MemberActivityFilter, MemberActivityFilter,
MemberDetailTab, MemberDetailTab,
@ -69,6 +70,13 @@ export const TeamGraphOverlay = ({
}), }),
[dispatchTaskAction] [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 = { const events: GraphEventPort = {
onNodeDoubleClick: useCallback( onNodeDoubleClick: useCallback(
@ -100,10 +108,13 @@ export const TeamGraphOverlay = ({
events={events} events={events}
onRequestClose={onClose} onRequestClose={onClose}
onRequestPinAsTab={onPinAsTab} onRequestPinAsTab={onPinAsTab}
onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask}
className="team-graph-view min-w-0 flex-1" className="team-graph-view min-w-0 flex-1"
renderHud={({ renderHud={({
getLaunchAnchorScreenPlacement, getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getNodeScreenPosition,
focusNodeIds, focusNodeIds,
}) => ( }) => (
<> <>
@ -111,6 +122,7 @@ export const TeamGraphOverlay = ({
teamName={teamName} teamName={teamName}
nodes={graphData.nodes} nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement} getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
getNodeScreenPosition={getNodeScreenPosition}
focusNodeIds={focusNodeIds} focusNodeIds={focusNodeIds}
onOpenTaskDetail={onOpenTaskDetail} onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile} onOpenMemberProfile={onOpenMemberProfile}

View file

@ -7,6 +7,7 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph'; import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useStore } from '@renderer/store';
import type { import type {
MemberActivityFilter, MemberActivityFilter,
MemberDetailTab, MemberDetailTab,
@ -75,6 +76,15 @@ export const TeamGraphTab = ({
window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })), window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })),
[teamName] [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 // Task action dispatchers
const dispatchTaskAction = useCallback( const dispatchTaskAction = useCallback(
@ -139,9 +149,12 @@ export const TeamGraphTab = ({
className="team-graph-view size-full" className="team-graph-view size-full"
suspendAnimation={!isActive} suspendAnimation={!isActive}
onRequestFullscreen={() => setFullscreen(true)} onRequestFullscreen={() => setFullscreen(true)}
onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask}
renderHud={({ renderHud={({
getLaunchAnchorScreenPlacement, getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getNodeScreenPosition,
focusNodeIds, focusNodeIds,
}) => ( }) => (
<> <>
@ -149,6 +162,7 @@ export const TeamGraphTab = ({
teamName={teamName} teamName={teamName}
nodes={graphData.nodes} nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement} getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
getNodeScreenPosition={getNodeScreenPosition}
focusNodeIds={focusNodeIds} focusNodeIds={focusNodeIds}
enabled={isActive} enabled={isActive}
onOpenTaskDetail={dispatchOpenTask} onOpenTaskDetail={dispatchOpenTask}

View file

@ -221,6 +221,60 @@ describe('FileWatcher', () => {
fs.rmSync(tempDir, { recursive: true, force: true }); 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 // Catch-Up Scan Tests
// =========================================================================== // ===========================================================================

View file

@ -3,7 +3,12 @@ import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { describe, expect, it } from 'vitest'; 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'; import type { ParsedMessage } from '../../../src/main/types';
// Helper to create a minimal ParsedMessage // 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();
});
});
});