feat(graph-controls): add team page and task creation buttons, improve toolbar button styles
This commit is contained in:
parent
6fbf8518dc
commit
07682eca37
20 changed files with 1111 additions and 403 deletions
|
|
@ -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)]'
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue