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,
|
||||
Pin,
|
||||
Play,
|
||||
Plus,
|
||||
Server,
|
||||
Users,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
|
|
@ -36,10 +38,11 @@ export interface GraphControlsProps {
|
|||
onRequestClose?: () => void;
|
||||
onRequestPinAsTab?: () => void;
|
||||
onRequestFullscreen?: () => void;
|
||||
onOpenTeamPage?: () => void;
|
||||
onCreateTask?: () => void;
|
||||
teamName: string;
|
||||
teamColor?: string;
|
||||
isAlive?: boolean;
|
||||
showBlockingHint?: boolean;
|
||||
}
|
||||
|
||||
export function GraphControls({
|
||||
|
|
@ -51,10 +54,11 @@ export function GraphControls({
|
|||
onRequestClose,
|
||||
onRequestPinAsTab,
|
||||
onRequestFullscreen,
|
||||
onOpenTeamPage,
|
||||
onCreateTask,
|
||||
teamName,
|
||||
teamColor,
|
||||
isAlive,
|
||||
showBlockingHint = false,
|
||||
}: GraphControlsProps): React.JSX.Element {
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const settingsRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -93,7 +97,21 @@ export function GraphControls({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute left-20 top-3 z-20 flex items-center gap-3 pointer-events-none">
|
||||
<div className="absolute left-20 top-3 z-20 flex items-center gap-1.5 pointer-events-none">
|
||||
{onOpenTeamPage ? (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: `1px solid ${nameColor}25`,
|
||||
}}
|
||||
>
|
||||
<ToolbarButton onClick={onOpenTeamPage} icon={<Users size={9} />} mini title="Team page" />
|
||||
{onCreateTask ? (
|
||||
<ToolbarButton onClick={onCreateTask} icon={<Plus size={9} />} mini title="Create task" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
|
||||
style={{
|
||||
|
|
@ -110,9 +128,9 @@ export function GraphControls({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-2 pointer-events-none">
|
||||
<div className="absolute right-3 top-3 z-20 flex items-center gap-0.5 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||
className="pointer-events-auto flex items-center rounded-md px-px py-px backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
|
|
@ -120,13 +138,14 @@ export function GraphControls({
|
|||
>
|
||||
<ToolbarButton
|
||||
onClick={() => toggle('paused')}
|
||||
icon={filters.paused ? <Play size={14} /> : <Pause size={14} />}
|
||||
icon={filters.paused ? <Play size={9} /> : <Pause size={9} />}
|
||||
mini
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={settingsRef} className="relative pointer-events-auto">
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||
className="flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
|
|
@ -134,9 +153,9 @@ export function GraphControls({
|
|||
>
|
||||
<ToolbarButton
|
||||
onClick={() => setIsSettingsOpen((value) => !value)}
|
||||
icon={<Settings2 size={14} />}
|
||||
label="View"
|
||||
icon={<Settings2 size={9} />}
|
||||
active={isSettingsOpen}
|
||||
mini
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -174,52 +193,39 @@ export function GraphControls({
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-lg px-3 py-1.5 backdrop-blur-sm"
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-md px-px py-px backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.8)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
{onRequestPinAsTab && <ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={13} />} />}
|
||||
{onRequestPinAsTab && (
|
||||
<ToolbarButton onClick={onRequestPinAsTab} icon={<Pin size={9} />} mini />
|
||||
)}
|
||||
{onRequestFullscreen && (
|
||||
<ToolbarButton
|
||||
onClick={onRequestFullscreen}
|
||||
icon={<Expand size={13} />}
|
||||
label="Fullscreen"
|
||||
icon={<Expand size={9} />}
|
||||
mini
|
||||
/>
|
||||
)}
|
||||
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={13} />} />}
|
||||
{onRequestClose && <ToolbarButton onClick={onRequestClose} icon={<X size={9} />} mini />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-3 right-3 z-20 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-1 py-0.5 backdrop-blur-sm"
|
||||
className="pointer-events-auto flex items-center gap-0.5 rounded-lg px-0.5 py-[2px] backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(8, 12, 24, 0.86)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={14} />} />
|
||||
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={14} />} label="Fit" />
|
||||
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={14} />} />
|
||||
<ToolbarButton onClick={onZoomOut} icon={<ZoomOut size={11} />} compact />
|
||||
<ToolbarButton onClick={onZoomToFit} icon={<Maximize2 size={11} />} label="Fit" compact />
|
||||
<ToolbarButton onClick={onZoomIn} icon={<ZoomIn size={11} />} compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showBlockingHint && (
|
||||
<div className="absolute bottom-3 left-3 z-20 pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto rounded-lg px-2.5 py-1 text-[10px] font-mono backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(40, 10, 18, 0.78)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.18)',
|
||||
color: 'rgba(254, 202, 202, 0.95)',
|
||||
}}
|
||||
>
|
||||
Red lines - blockers, click to inspect
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -231,16 +237,29 @@ function ToolbarButton({
|
|||
icon,
|
||||
label,
|
||||
active = false,
|
||||
compact = false,
|
||||
mini = false,
|
||||
title,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
icon: React.ReactNode;
|
||||
label?: string;
|
||||
active?: boolean;
|
||||
compact?: boolean;
|
||||
mini?: boolean;
|
||||
title?: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-mono transition-colors cursor-pointer ${
|
||||
title={title}
|
||||
className={`flex items-center rounded-md font-mono transition-colors cursor-pointer ${
|
||||
mini
|
||||
? 'size-5 justify-center p-0 text-[0]'
|
||||
: compact
|
||||
? 'gap-0.5 px-1 py-0.5 text-[9px]'
|
||||
: 'gap-1 px-2 py-1 text-[11px]'
|
||||
} ${
|
||||
active
|
||||
? 'text-[#aaeeff] bg-[rgba(100,200,255,0.14)]'
|
||||
: 'text-[#66ccff90] hover:text-[#aaeeff] hover:bg-[rgba(100,200,255,0.1)]'
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ export interface GraphViewProps {
|
|||
onRequestClose?: () => void;
|
||||
onRequestPinAsTab?: () => void;
|
||||
onRequestFullscreen?: () => void;
|
||||
onOpenTeamPage?: () => void;
|
||||
onCreateTask?: () => void;
|
||||
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
|
||||
renderOverlay?: (props: {
|
||||
node: GraphNode;
|
||||
|
|
@ -63,6 +65,9 @@ export interface GraphViewProps {
|
|||
getActivityAnchorScreenPlacement: (
|
||||
ownerNodeId: string,
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getNodeScreenPosition: (
|
||||
nodeId: string,
|
||||
) => { x: number; y: number; visible: boolean } | null;
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
|
@ -76,6 +81,8 @@ export function GraphView({
|
|||
onRequestClose,
|
||||
onRequestPinAsTab,
|
||||
onRequestFullscreen,
|
||||
onOpenTeamPage,
|
||||
onCreateTask,
|
||||
renderOverlay,
|
||||
renderEdgeOverlay,
|
||||
renderHud,
|
||||
|
|
@ -222,6 +229,24 @@ export function GraphView({
|
|||
viewportHeight: viewport.height,
|
||||
});
|
||||
}, [getViewportSize]);
|
||||
const getNodeScreenPosition = useCallback((nodeId: string) => {
|
||||
const viewport = getViewportSize();
|
||||
if (viewport.width <= 0 || viewport.height <= 0) {
|
||||
return null;
|
||||
}
|
||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (!node || node.x == null || node.y == null) {
|
||||
return null;
|
||||
}
|
||||
const transform = cameraRef.current.transformRef.current;
|
||||
const x = node.x * transform.zoom + transform.x;
|
||||
const y = node.y * transform.zoom + transform.y;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
|
||||
};
|
||||
}, [getViewportSize]);
|
||||
|
||||
const animate = useCallback(() => {
|
||||
if (!runningRef.current) return;
|
||||
|
|
@ -673,10 +698,11 @@ export function GraphView({
|
|||
onRequestClose={onRequestClose}
|
||||
onRequestPinAsTab={onRequestPinAsTab}
|
||||
onRequestFullscreen={onRequestFullscreen}
|
||||
onOpenTeamPage={onOpenTeamPage}
|
||||
onCreateTask={onCreateTask}
|
||||
teamName={data.teamName}
|
||||
teamColor={data.teamColor}
|
||||
isAlive={data.isAlive}
|
||||
showBlockingHint={filters.showEdges && hasBlockingEdges && !selectedNode && !selectedEdge}
|
||||
/>
|
||||
|
||||
{renderHud ? (
|
||||
|
|
@ -684,6 +710,7 @@ export function GraphView({
|
|||
{renderHud({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*/
|
||||
|
||||
import { type FileChangeEvent, type ParsedMessage } from '@main/types';
|
||||
import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl';
|
||||
import { parseJsonlFileWithStats, parseJsonlStream } from '@main/utils/jsonl';
|
||||
import {
|
||||
getProjectsBasePath,
|
||||
getTasksBasePath,
|
||||
|
|
@ -765,12 +765,12 @@ export class FileWatcher extends EventEmitter {
|
|||
const currentSize = fileStats.size;
|
||||
|
||||
// Fast path: no size change means no new data
|
||||
if (currentSize === lastSize && lastLineCount > 0) {
|
||||
if (currentSize === lastSize && lastSize > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstRead = lastLineCount === 0 && lastSize === 0;
|
||||
const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize;
|
||||
const canUseIncrementalAppend = lastSize > 0 && currentSize > lastSize;
|
||||
let newMessages: ParsedMessage[] = [];
|
||||
let currentLineCount: number;
|
||||
let processedSize: number;
|
||||
|
|
@ -782,12 +782,10 @@ export class FileWatcher extends EventEmitter {
|
|||
processedSize = lastSize + appended.consumedBytes;
|
||||
} else {
|
||||
// Fallback for first-read, truncation, or rewrite scenarios
|
||||
const messages = await parseJsonlFile(filePath);
|
||||
currentLineCount = messages.length;
|
||||
newMessages = messages.slice(lastLineCount);
|
||||
// Re-stat after full parse to capture bytes written during the parse
|
||||
const postParseStats = await this.fsProvider.stat(filePath);
|
||||
processedSize = postParseStats.size;
|
||||
const parsedFile = await parseJsonlFileWithStats(filePath, this.fsProvider);
|
||||
currentLineCount = parsedFile.parsedLineCount;
|
||||
newMessages = parsedFile.messages.slice(lastLineCount);
|
||||
processedSize = parsedFile.consumedBytes;
|
||||
}
|
||||
|
||||
// If no new lines, skip processing
|
||||
|
|
@ -895,56 +893,15 @@ export class FileWatcher extends EventEmitter {
|
|||
filePath: string,
|
||||
startOffset: number
|
||||
): Promise<AppendedParseResult> {
|
||||
const parsedMessages: ParsedMessage[] = [];
|
||||
const stream = this.fsProvider.createReadStream(filePath, {
|
||||
start: startOffset,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
let buffer = '';
|
||||
let consumedBytes = 0;
|
||||
let parsedLineCount = 0;
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const rawLine of lines) {
|
||||
consumedBytes += Buffer.byteLength(`${rawLine}\n`, 'utf8');
|
||||
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = parseJsonlLine(line);
|
||||
if (parsed) {
|
||||
parsedMessages.push(parsed);
|
||||
parsedLineCount++;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed appended lines; full parse path will recover on next rewrite.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle final line without trailing newline
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const parsed = parseJsonlLine(buffer);
|
||||
if (parsed) {
|
||||
parsedMessages.push(parsed);
|
||||
parsedLineCount++;
|
||||
consumedBytes += Buffer.byteLength(buffer, 'utf8');
|
||||
}
|
||||
} catch {
|
||||
// Keep offset pinned until this trailing partial becomes a complete line.
|
||||
}
|
||||
}
|
||||
const parsed = await parseJsonlStream(stream);
|
||||
|
||||
return {
|
||||
messages: parsedMessages,
|
||||
parsedLineCount,
|
||||
consumedBytes,
|
||||
messages: parsed.messages,
|
||||
parsedLineCount: parsed.parsedLineCount,
|
||||
consumedBytes: parsed.consumedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { extractToolCalls, extractToolResults } from './toolExtraction';
|
|||
|
||||
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
||||
import type { PhaseTokenBreakdown } from '../types/domain';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
const logger = createLogger('Util:jsonl');
|
||||
|
||||
|
|
@ -47,6 +48,12 @@ export { checkMessagesOngoing } from './sessionStateDetection';
|
|||
// Core Parsing Functions
|
||||
// =============================================================================
|
||||
|
||||
export interface JsonlParseResult {
|
||||
messages: ParsedMessage[];
|
||||
parsedLineCount: number;
|
||||
consumedBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSONL file line by line using streaming.
|
||||
* This avoids loading the entire file into memory.
|
||||
|
|
@ -55,38 +62,130 @@ export async function parseJsonlFile(
|
|||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<ParsedMessage[]> {
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
if (!(await fsProvider.exists(filePath))) {
|
||||
return messages;
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
const result = await parseJsonlStream(fsProvider.createReadStream(filePath), filePath);
|
||||
return result.messages;
|
||||
}
|
||||
|
||||
let lineCount = 0;
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
/**
|
||||
* Parse a JSONL file and return byte accounting details for incremental readers.
|
||||
*/
|
||||
export async function parseJsonlFileWithStats(
|
||||
filePath: string,
|
||||
fsProvider: FileSystemProvider = defaultProvider
|
||||
): Promise<JsonlParseResult> {
|
||||
if (!(await fsProvider.exists(filePath))) {
|
||||
return { messages: [], parsedLineCount: 0, consumedBytes: 0 };
|
||||
}
|
||||
|
||||
return parseJsonlStream(fsProvider.createReadStream(filePath), filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL data from a readable stream while tracking how many bytes were
|
||||
* safely consumed as complete lines.
|
||||
*/
|
||||
export async function parseJsonlStream(
|
||||
stream: Readable,
|
||||
filePath?: string
|
||||
): Promise<JsonlParseResult> {
|
||||
const messages: ParsedMessage[] = [];
|
||||
let pending = Buffer.alloc(0);
|
||||
let parsedLineCount = 0;
|
||||
let consumedBytes = 0;
|
||||
let completeLineCount = 0;
|
||||
let malformedLineCount = 0;
|
||||
let skippedNonJsonCount = 0;
|
||||
|
||||
const processLine = (lineBuffer: Buffer): void => {
|
||||
let effectiveBuffer = lineBuffer;
|
||||
if (effectiveBuffer.length > 0 && effectiveBuffer[effectiveBuffer.length - 1] === 0x0d) {
|
||||
effectiveBuffer = effectiveBuffer.subarray(0, -1);
|
||||
}
|
||||
|
||||
const line = effectiveBuffer.toString('utf8');
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeJsonlLine(line);
|
||||
if (!looksLikeJsonObjectLine(normalized)) {
|
||||
skippedNonJsonCount += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseJsonlLine(line);
|
||||
const parsed = parseJsonlLine(normalized);
|
||||
if (parsed) {
|
||||
messages.push(parsed);
|
||||
parsedLineCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing line in ${filePath}:`, error);
|
||||
} catch {
|
||||
malformedLineCount += 1;
|
||||
}
|
||||
};
|
||||
|
||||
lineCount++;
|
||||
if (lineCount % 250 === 0) {
|
||||
await yieldToEventLoop();
|
||||
for await (const chunk of stream) {
|
||||
const chunkBuffer =
|
||||
typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk as Uint8Array);
|
||||
pending =
|
||||
pending.length === 0
|
||||
? chunkBuffer
|
||||
: Buffer.concat([pending, chunkBuffer], pending.length + chunkBuffer.length);
|
||||
|
||||
while (true) {
|
||||
const newlineIndex = pending.indexOf(0x0a);
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const lineBuffer = pending.subarray(0, newlineIndex);
|
||||
pending = pending.subarray(newlineIndex + 1);
|
||||
consumedBytes += lineBuffer.length + 1;
|
||||
completeLineCount += 1;
|
||||
processLine(lineBuffer);
|
||||
|
||||
if (completeLineCount % 250 === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
if (pending.length > 0) {
|
||||
try {
|
||||
const trailingLine = pending.toString('utf8');
|
||||
const normalized = normalizeJsonlLine(trailingLine);
|
||||
if (looksLikeJsonObjectLine(normalized)) {
|
||||
const parsed = parseJsonlLine(normalized);
|
||||
if (parsed) {
|
||||
messages.push(parsed);
|
||||
parsedLineCount += 1;
|
||||
consumedBytes += pending.length;
|
||||
}
|
||||
} else if (normalized.length > 0) {
|
||||
// Treat non-JSON tail text as a complete malformed line and advance.
|
||||
consumedBytes += pending.length;
|
||||
}
|
||||
} catch {
|
||||
// Ignore trailing partial JSON. Callers should keep their offset pinned
|
||||
// until the line is completed by a future append.
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && (malformedLineCount > 0 || skippedNonJsonCount > 0)) {
|
||||
logger.debug(
|
||||
`Skipped invalid JSONL lines in ${filePath} malformed=${malformedLineCount} nonJson=${skippedNonJsonCount}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
parsedLineCount,
|
||||
consumedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,14 +193,28 @@ export async function parseJsonlFile(
|
|||
* Returns null for invalid/unsupported lines.
|
||||
*/
|
||||
export function parseJsonlLine(line: string): ParsedMessage | null {
|
||||
if (!line.trim()) {
|
||||
const normalized = normalizeJsonlLine(line);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(line) as ChatHistoryEntry;
|
||||
if (!looksLikeJsonObjectLine(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(normalized) as ChatHistoryEntry;
|
||||
return parseChatHistoryEntry(entry);
|
||||
}
|
||||
|
||||
function normalizeJsonlLine(line: string): string {
|
||||
const trimmed = line.trim();
|
||||
return trimmed.charCodeAt(0) === 0xfeff ? trimmed.slice(1) : trimmed;
|
||||
}
|
||||
|
||||
function looksLikeJsonObjectLine(line: string): boolean {
|
||||
return line.startsWith('{');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Entry Parsing
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const App = (): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<TooltipProvider delayDuration={150} skipDelayDuration={1500}>
|
||||
<ContextSwitchOverlay />
|
||||
<TabbedLayout />
|
||||
<ConfirmDialog />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@ import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react';
|
|||
import type { TmuxStatus } from '@shared/types';
|
||||
|
||||
const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing';
|
||||
const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README';
|
||||
const HOMEBREW_TMUX_URL = 'https://formulae.brew.sh/formula/tmux';
|
||||
const MACPORTS_TMUX_URL = 'https://ports.macports.org/port/tmux/';
|
||||
const MICROSOFT_WSL_INSTALL_URL = 'https://learn.microsoft.com/en-us/windows/wsl/install';
|
||||
|
||||
interface SourceLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type BannerState =
|
||||
| { loading: true; status: null; error: null }
|
||||
|
|
@ -14,6 +23,33 @@ type BannerState =
|
|||
|
||||
const INITIAL_STATE: BannerState = { loading: true, status: null, error: null };
|
||||
|
||||
const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => {
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-wide"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Sources
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{links.map((link) => (
|
||||
<button
|
||||
key={link.url}
|
||||
type="button"
|
||||
onClick={() => void api.openExternal(link.url)}
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors hover:bg-white/5"
|
||||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{link.label}
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformInstallMatrix = (): React.JSX.Element => {
|
||||
return (
|
||||
<div className="mt-3 grid gap-2 lg:grid-cols-3">
|
||||
|
|
@ -28,10 +64,19 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
|
|||
macOS
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<div>Homebrew</div>
|
||||
<div>Recommended: Homebrew</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">brew install tmux</code>
|
||||
<div>MacPorts</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">port install tmux</code>
|
||||
<div>Alternative: MacPorts</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
|
||||
sudo port install tmux
|
||||
</code>
|
||||
<SourceLinks
|
||||
links={[
|
||||
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
|
||||
{ label: 'Homebrew', url: HOMEBREW_TMUX_URL },
|
||||
{ label: 'MacPorts', url: MACPORTS_TMUX_URL },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -46,11 +91,21 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
|
|||
Linux
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">apt install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">dnf install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">yum install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">zypper install tmux</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">pacman -S tmux</code>
|
||||
<div>Use your distro package manager:</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
|
||||
sudo apt install tmux
|
||||
</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
|
||||
sudo dnf install tmux
|
||||
</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
|
||||
sudo yum install tmux
|
||||
</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
|
||||
sudo zypper install tmux
|
||||
</code>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">sudo pacman -S tmux</code>
|
||||
<SourceLinks links={[{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -65,11 +120,20 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
|
|||
Windows
|
||||
</div>
|
||||
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
<p>В official tmux wiki нет native Windows install command.</p>
|
||||
<p>
|
||||
Рекомендуемый путь: WSL, затем внутри Linux-дистрибутива использовать одну из Linux
|
||||
команд выше, например <code className="font-mono">apt install tmux</code>.
|
||||
</p>
|
||||
<p>The tmux docs do not provide an official native Windows install command.</p>
|
||||
<div>1. Install WSL</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">wsl --install</code>
|
||||
<div>2. Inside Ubuntu or another distro</div>
|
||||
<code className="block rounded bg-black/20 px-2 py-1 font-mono">
|
||||
sudo apt install tmux
|
||||
</code>
|
||||
<SourceLinks
|
||||
links={[
|
||||
{ label: 'tmux README', url: TMUX_README_URL },
|
||||
{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL },
|
||||
{ label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,21 +142,25 @@ const PlatformInstallMatrix = (): React.JSX.Element => {
|
|||
|
||||
function getPrimaryDetail(status: TmuxStatus): string {
|
||||
if (status.platform === 'darwin') {
|
||||
return 'На macOS проще всего поставить tmux через Homebrew или MacPorts.';
|
||||
return 'On macOS, the simplest options are Homebrew or MacPorts.';
|
||||
}
|
||||
if (status.platform === 'linux') {
|
||||
return 'На Linux команда зависит от дистрибутива: apt, dnf, yum, zypper или pacman.';
|
||||
return 'On Linux, install tmux with your distro package manager.';
|
||||
}
|
||||
if (status.platform === 'win32') {
|
||||
return 'На Windows у official tmux wiki нет native installer; safest путь — WSL и установка tmux внутри Linux-дистрибутива.';
|
||||
return 'On Windows, the clearest path is WSL, then installing tmux inside your Linux distro.';
|
||||
}
|
||||
return 'Поставь tmux через пакетный менеджер своей ОС.';
|
||||
return 'Install tmux with your operating system package manager.';
|
||||
}
|
||||
|
||||
export const TmuxStatusBanner = (): React.JSX.Element | null => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const [state, setState] = useState<BannerState>(INITIAL_STATE);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
return api.tmux.getStatus();
|
||||
}, []);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setState(
|
||||
(prev) =>
|
||||
|
|
@ -104,7 +172,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
|
|||
);
|
||||
|
||||
try {
|
||||
const status = await api.tmux.getStatus();
|
||||
const status = await loadStatus();
|
||||
setState({ loading: false, status, error: null });
|
||||
} catch (error) {
|
||||
setState({
|
||||
|
|
@ -113,14 +181,38 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
|
|||
error: error instanceof Error ? error.message : 'Failed to check tmux status',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
void fetchStatus();
|
||||
}, [fetchStatus, isElectron]);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadInitialStatus = async (): Promise<void> => {
|
||||
try {
|
||||
const status = await loadStatus();
|
||||
if (!cancelled) {
|
||||
setState({ loading: false, status, error: null });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
loading: false,
|
||||
status: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to check tmux status',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadInitialStatus();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isElectron, loadStatus]);
|
||||
|
||||
if (!isElectron) return null;
|
||||
if (state.loading && !state.status) return null;
|
||||
|
|
@ -182,8 +274,8 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
|
|||
className="mt-1 text-xs leading-relaxed"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Persistent team agents работают стабильнее в process/tmux path. Без tmux app остаётся
|
||||
на более тяжёлом in-process пути. {getPrimaryDetail(state.status)}
|
||||
Persistent team agents are more reliable on the process/tmux path. Without tmux, the
|
||||
app falls back to the heavier in-process path. {getPrimaryDetail(state.status)}
|
||||
</p>
|
||||
{state.status.error && (
|
||||
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}>
|
||||
|
|
@ -208,7 +300,7 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
|
|||
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Open guide
|
||||
Official guide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import faviconUrl from '@renderer/favicon.png';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
|
|
@ -68,36 +69,48 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
|
|||
|
||||
{/* Window controls — no-drag so they receive clicks */}
|
||||
<div className="flex shrink-0" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonBase} ${buttonHover}`}
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() => void minimize()}
|
||||
title="Minimize"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Minus className="size-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonBase} ${buttonHover}`}
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() => void handleMaximize()}
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
<Square className="size-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() => void close()}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonBase} ${buttonHover}`}
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() => void minimize()}
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<Minus className="size-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Minimize</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonBase} ${buttonHover}`}
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() => void handleMaximize()}
|
||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
<Square className="size-3.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isMaximized ? 'Restore' : 'Maximize'}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
onClick={() => void close()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" strokeWidth={2.5} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { triggerDownload } from '@renderer/utils/sessionExporter';
|
||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||
|
|
@ -22,6 +23,7 @@ import {
|
|||
Type,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { SessionDetail } from '@renderer/types/data';
|
||||
import type { Tab } from '@renderer/types/tabs';
|
||||
|
|
@ -51,12 +53,23 @@ export const MoreMenu = ({
|
|||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openCommandPalette = useStore((s) => s.openCommandPalette);
|
||||
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||
const openSessionReport = useStore((s) => s.openSessionReport);
|
||||
const openSchedulesTab = useStore((s) => s.openSchedulesTab);
|
||||
const openSettingsTab = useStore((s) => s.openSettingsTab);
|
||||
const openTeamsTab = useStore((s) => s.openTeamsTab);
|
||||
const {
|
||||
openCommandPalette,
|
||||
openExtensionsTab,
|
||||
openSessionReport,
|
||||
openSchedulesTab,
|
||||
openSettingsTab,
|
||||
openTeamsTab,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
openCommandPalette: () => s.openCommandPalette(),
|
||||
openExtensionsTab: () => s.openExtensionsTab(),
|
||||
openSessionReport: (tabId: string) => s.openSessionReport(tabId),
|
||||
openSchedulesTab: () => s.openSchedulesTab(),
|
||||
openSettingsTab: () => s.openSettingsTab(),
|
||||
openTeamsTab: () => s.openTeamsTab(),
|
||||
}))
|
||||
);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
|
|
@ -212,19 +225,25 @@ export const MoreMenu = ({
|
|||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onMouseEnter={() => setButtonHover(true)}
|
||||
onMouseLeave={() => setButtonHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="More actions"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onMouseEnter={() => setButtonHover(true)}
|
||||
onMouseLeave={() => setButtonHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor:
|
||||
buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
aria-label="More actions"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">More actions</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useCallback, useState } from 'react';
|
|||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
getTeamColorSet,
|
||||
getThemedBadge,
|
||||
|
|
@ -211,18 +212,23 @@ export const SortableTab = ({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Close tab"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(tab.id);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
aria-label="Close tab"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Close tab</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatShortcut } from '@renderer/utils/stringUtils';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
|
@ -293,19 +294,24 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
|
||||
{/* Refresh button - show only for session tabs */}
|
||||
{activeTab?.type === 'session' && (
|
||||
<button
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
style={{
|
||||
color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setRefreshHover(true)}
|
||||
onMouseLeave={() => setRefreshHover(false)}
|
||||
onClick={handleRefresh}
|
||||
title={`Refresh Session (${formatShortcut('R')})`}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md transition-colors"
|
||||
style={{
|
||||
color: refreshHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: refreshHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={() => setRefreshHover(true)}
|
||||
onMouseLeave={() => setRefreshHover(false)}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh session"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{`Refresh Session (${formatShortcut('R')})`}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -86,66 +86,93 @@ export const TabBarActions = (): React.JSX.Element => {
|
|||
)}
|
||||
|
||||
{/* Notifications bell icon */}
|
||||
<button
|
||||
onClick={openNotificationsTab}
|
||||
onMouseEnter={() => setNotificationsHover(true)}
|
||||
onMouseLeave={() => setNotificationsHover(false)}
|
||||
className="relative rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={openNotificationsTab}
|
||||
onMouseEnter={() => setNotificationsHover(true)}
|
||||
onMouseLeave={() => setNotificationsHover(false)}
|
||||
className="relative rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-medium text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Notifications</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* GitHub link */}
|
||||
<button
|
||||
onClick={() =>
|
||||
void (isElectronMode()
|
||||
? window.electronAPI.openExternal('https://github.com/777genius/claude_agent_teams_ui')
|
||||
: window.open('https://github.com/777genius/claude_agent_teams_ui', '_blank'))
|
||||
}
|
||||
onMouseEnter={() => setGithubHover(true)}
|
||||
onMouseLeave={() => setGithubHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="GitHub"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (isElectronMode()) {
|
||||
await window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude_agent_teams_ui'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(
|
||||
'https://github.com/777genius/claude_agent_teams_ui',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() => setGithubHover(true)}
|
||||
onMouseLeave={() => setGithubHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">GitHub</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Discord link */}
|
||||
<button
|
||||
onClick={() =>
|
||||
void (isElectronMode()
|
||||
? window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc')
|
||||
: window.open('https://discord.gg/qtqSZSyuEc', '_blank'))
|
||||
}
|
||||
onMouseEnter={() => setDiscordHover(true)}
|
||||
onMouseLeave={() => setDiscordHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Discord"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.3698A19.791 19.791 0 0 0 15.4319 3.0a13.873 13.873 0 0 0-.6242 1.2757 18.27 18.27 0 0 0-5.6154 0A13.872 13.872 0 0 0 8.5681 3 19.736 19.736 0 0 0 3.683 4.3698C.5334 9.1048-.319 13.7216.099 18.272a19.9 19.9 0 0 0 6.0892 3.1157 14.96 14.96 0 0 0 1.303-2.1356 12.46 12.46 0 0 1-1.9352-.9351c.1624-.1218.3217-.2462.4763-.3736 3.7294 1.7014 7.772 1.7014 11.4572 0 .1546.1274.3139.2518.4763.3736-.6163.3622-1.2638.6754-1.9352.9351.3654.7439.8041 1.4554 1.303 2.1356A19.9 19.9 0 0 0 23.901 18.272c.5003-5.2737-.8381-9.8482-3.584-13.9022ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Zm7.96 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (isElectronMode()) {
|
||||
await window.electronAPI.openExternal('https://discord.gg/qtqSZSyuEc');
|
||||
return;
|
||||
}
|
||||
|
||||
window.open('https://discord.gg/qtqSZSyuEc', '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
onMouseEnter={() => setDiscordHover(true)}
|
||||
onMouseLeave={() => setDiscordHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
aria-label="Discord"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M20.317 4.3698A19.791 19.791 0 0 0 15.4319 3.0a13.873 13.873 0 0 0-.6242 1.2757 18.27 18.27 0 0 0-5.6154 0A13.872 13.872 0 0 0 8.5681 3 19.736 19.736 0 0 0 3.683 4.3698C.5334 9.1048-.319 13.7216.099 18.272a19.9 19.9 0 0 0 6.0892 3.1157 14.96 14.96 0 0 0 1.303-2.1356 12.46 12.46 0 0 1-1.9352-.9351c.1624-.1218.3217-.2462.4763-.3736 3.7294 1.7014 7.772 1.7014 11.4572 0 .1546.1274.3139.2518.4763.3736-.6163.3622-1.2638.6754-1.9352.9351.3654.7439.8041 1.4554 1.303 2.1356A19.9 19.9 0 0 0 23.901 18.272c.5003-5.2737-.8381-9.8482-3.584-13.9022ZM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Zm7.96 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3334.9555-2.4191 2.1569-2.4191 1.2103 0 2.1757 1.0946 2.1568 2.419 0 1.3334-.9465 2.4191-2.1568 2.4191Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Discord</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* More menu (Teams, Settings, Extensions, Search, Export, Analyze, Schedules) */}
|
||||
<MoreMenu
|
||||
|
|
@ -156,19 +183,24 @@ export const TabBarActions = (): React.JSX.Element => {
|
|||
|
||||
{/* Expand sidebar — rightmost, only when collapsed */}
|
||||
{sidebarCollapsed && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onMouseEnter={() => setExpandHover(true)}
|
||||
onMouseLeave={() => setExpandHover(false)}
|
||||
className="mr-1 rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onMouseEnter={() => setExpandHover(true)}
|
||||
onMouseLeave={() => setExpandHover(false)}
|
||||
className="mr-1 rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<PanelRight className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Expand sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { Fragment, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
|
@ -70,22 +71,27 @@ export const TabBarRow = (): React.JSX.Element => {
|
|||
))}
|
||||
|
||||
{/* New tab button — right after last tab */}
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
onMouseEnter={() => setNewTabHover(true)}
|
||||
onMouseLeave={() => setNewTabHover(false)}
|
||||
className="shrink-0 self-stretch px-2 transition-colors"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
title="New tab (Dashboard)"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={openDashboard}
|
||||
onMouseEnter={() => setNewTabHover(true)}
|
||||
onMouseLeave={() => setNewTabHover(false)}
|
||||
className="shrink-0 self-stretch px-2 transition-colors"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: 'no-drag',
|
||||
color: newTabHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
aria-label="New tab"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New tab (Dashboard)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Action buttons — right side */}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ import { type MemberActivityFilter, type MemberDetailTab } from './members/membe
|
|||
|
||||
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
|
||||
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
|
||||
const ProjectEditorOverlay = lazy(() =>
|
||||
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
||||
|
|
@ -822,6 +822,34 @@ export const TeamDetailView = ({
|
|||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||
const wasProvisioningRef = useRef(false);
|
||||
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
||||
const handleOpenGraphTab = useCallback(() => {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
state.openTab({
|
||||
type: 'graph',
|
||||
label: `${displayName} Graph`,
|
||||
teamName,
|
||||
});
|
||||
}, [teamName]);
|
||||
const visualizeButtonStyle = useMemo<CSSProperties>(
|
||||
() =>
|
||||
isLight
|
||||
? {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)',
|
||||
borderColor: 'rgba(59,130,246,0.30)',
|
||||
color: '#0f172a',
|
||||
boxShadow: '0 10px 24px rgba(59,130,246,0.12)',
|
||||
}
|
||||
: {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)',
|
||||
borderColor: 'rgba(56,189,248,0.34)',
|
||||
color: 'rgba(236,253,255,0.96)',
|
||||
boxShadow: '0 12px 28px rgba(8,145,178,0.22)',
|
||||
},
|
||||
[isLight]
|
||||
);
|
||||
|
||||
// Set inert on background content when editor/graph overlay is open (a11y focus trap)
|
||||
useEffect(() => {
|
||||
|
|
@ -839,18 +867,12 @@ export const TeamDetailView = ({
|
|||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.teamName === teamName) {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
useStore.getState().openTab({
|
||||
type: 'graph',
|
||||
label: `${displayName} Graph`,
|
||||
teamName,
|
||||
});
|
||||
handleOpenGraphTab();
|
||||
}
|
||||
};
|
||||
window.addEventListener('toggle-team-graph', handler);
|
||||
return () => window.removeEventListener('toggle-team-graph', handler);
|
||||
}, [teamName]);
|
||||
}, [handleOpenGraphTab, teamName]);
|
||||
|
||||
// Listen for graph tab actions (open task, send message)
|
||||
useEffect(() => {
|
||||
|
|
@ -2059,13 +2081,13 @@ export const TeamDetailView = ({
|
|||
{data.config.description}
|
||||
</p>
|
||||
)}
|
||||
{(data.config.projectPath || leadBranch) && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5',
|
||||
headerColorSet && 'relative z-10'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 flex items-start justify-between gap-3',
|
||||
headerColorSet && 'relative z-10'
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-0.5">
|
||||
{data.config.projectPath && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-[var(--color-text-secondary)]">
|
||||
<FolderOpen size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
|
|
@ -2108,7 +2130,28 @@ export const TeamDetailView = ({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'-mt-2 h-8 shrink-0 self-start rounded-full border px-3.5 text-xs font-semibold tracking-[0.02em] transition-all',
|
||||
'hover:-translate-y-0.5 hover:brightness-[1.03] active:translate-y-0 active:brightness-[0.98]',
|
||||
isLight
|
||||
? 'hover:border-sky-400/50'
|
||||
: 'hover:border-cyan-300/50 hover:shadow-[0_14px_32px_rgba(8,145,178,0.28)]'
|
||||
)}
|
||||
style={visualizeButtonStyle}
|
||||
onClick={handleOpenGraphTab}
|
||||
>
|
||||
<Network size={13} className="shrink-0" />
|
||||
Visualize
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Open team graph</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{(() => {
|
||||
const currentPath = data.config.projectPath;
|
||||
const history = data.config.projectPathHistory?.filter((p) => p !== currentPath);
|
||||
|
|
@ -2159,27 +2202,6 @@ export const TeamDetailView = ({
|
|||
defaultOpen
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
useStore.getState().openTab({
|
||||
type: 'graph',
|
||||
label: `${data.config.name} Graph`,
|
||||
teamName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="relative">
|
||||
<Network size={12} />
|
||||
<span className="absolute -right-3.5 -top-2.5 rounded-sm bg-emerald-500/20 px-0.5 py-px text-[7px] font-bold leading-none text-emerald-400">
|
||||
NEW
|
||||
</span>
|
||||
</span>
|
||||
Graph
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,31 @@ interface TaskActivitySectionProps {
|
|||
taskId: string;
|
||||
}
|
||||
|
||||
function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean {
|
||||
return entry.linkKind !== 'execution';
|
||||
}
|
||||
|
||||
function compareTaskActivityEntriesDesc(
|
||||
left: BoardTaskActivityEntry,
|
||||
right: BoardTaskActivityEntry
|
||||
): number {
|
||||
const leftTs = Date.parse(left.timestamp);
|
||||
const rightTs = Date.parse(right.timestamp);
|
||||
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
|
||||
return rightTs - leftTs;
|
||||
}
|
||||
|
||||
if (left.source.filePath !== right.source.filePath) {
|
||||
return right.source.filePath.localeCompare(left.source.filePath);
|
||||
}
|
||||
|
||||
if (left.source.sourceOrder !== right.source.sourceOrder) {
|
||||
return right.source.sourceOrder - left.source.sourceOrder;
|
||||
}
|
||||
|
||||
return right.id.localeCompare(left.id);
|
||||
}
|
||||
|
||||
function formatEntryTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
|
@ -158,6 +183,15 @@ export function TaskActivitySection({
|
|||
};
|
||||
}, [entries.length, teamName, taskId]);
|
||||
|
||||
const visibleEntries = useMemo(
|
||||
() =>
|
||||
entries
|
||||
.filter((entry) => isHighSignalTaskActivityEntry(entry))
|
||||
.sort(compareTaskActivityEntriesDesc),
|
||||
[entries]
|
||||
);
|
||||
const hasOnlyLowSignalExecution = entries.length > 0 && visibleEntries.length === 0;
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -177,23 +211,24 @@ export function TaskActivitySection({
|
|||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (visibleEntries.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
No explicit task activity was found in the available transcripts yet. Older or heuristic
|
||||
session logs may still be available below in Execution Sessions.
|
||||
{hasOnlyLowSignalExecution
|
||||
? 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.'
|
||||
: 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
{visibleEntries.map((entry) => (
|
||||
<Row key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [entries, error, loading]);
|
||||
}, [error, hasOnlyLowSignalExecution, loading, visibleEntries]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -203,7 +238,7 @@ export function TaskActivitySection({
|
|||
</h4>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
Explicit runtime activity linked to this task from transcript metadata.
|
||||
Key explicit runtime activity linked to this task from transcript metadata.
|
||||
</p>
|
||||
{content}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface GraphActivityHudProps {
|
|||
getActivityAnchorScreenPlacement: (
|
||||
ownerNodeId: string
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null;
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
enabled?: boolean;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
|
|
@ -49,12 +50,15 @@ export function GraphActivityHud({
|
|||
teamName,
|
||||
nodes,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition = () => null,
|
||||
focusNodeIds,
|
||||
enabled = true,
|
||||
onOpenTaskDetail,
|
||||
onOpenMemberProfile,
|
||||
}: GraphActivityHudProps): React.JSX.Element | null {
|
||||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
|
||||
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||
const { teamData, teams } = useStore(
|
||||
useShallow((state) => ({
|
||||
|
|
@ -126,6 +130,11 @@ export function GraphActivityHud({
|
|||
shell.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
for (const connector of connectorRefs.current.values()) {
|
||||
if (connector) {
|
||||
connector.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -136,16 +145,57 @@ export function GraphActivityHud({
|
|||
if (!shell) {
|
||||
continue;
|
||||
}
|
||||
const connector = connectorRefs.current.get(lane.node.id);
|
||||
const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
|
||||
|
||||
const placement = getActivityAnchorScreenPlacement(lane.node.id);
|
||||
if (!placement || !placement.visible) {
|
||||
const nodeScreen = getNodeScreenPosition(lane.node.id);
|
||||
if (!placement || !placement.visible || !nodeScreen || !nodeScreen.visible) {
|
||||
shell.style.opacity = '0';
|
||||
if (connector) {
|
||||
connector.style.opacity = '0';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
|
||||
shell.style.opacity = String(baseOpacity);
|
||||
shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`;
|
||||
|
||||
if (connector && connectorPath) {
|
||||
const scaledWidth = (shell.offsetWidth || 296) * placement.scale;
|
||||
const laneCenterX = placement.x + scaledWidth / 2;
|
||||
const laneIsLeft = laneCenterX < nodeScreen.x;
|
||||
const endX = laneIsLeft ? placement.x + scaledWidth - 8 : placement.x + 8;
|
||||
const endY = placement.y + 10 * placement.scale;
|
||||
const startX = nodeScreen.x;
|
||||
const startY = nodeScreen.y - 10;
|
||||
const minX = Math.min(startX, endX);
|
||||
const minY = Math.min(startY, endY);
|
||||
const width = Math.max(1, Math.abs(endX - startX));
|
||||
const height = Math.max(1, Math.abs(endY - startY));
|
||||
const localStartX = startX - minX;
|
||||
const localStartY = startY - minY;
|
||||
const localEndX = endX - minX;
|
||||
const localEndY = endY - minY;
|
||||
const dx = localEndX - localStartX;
|
||||
const curve = Math.max(28, Math.abs(dx) * 0.35);
|
||||
const c1x = localStartX + Math.sign(dx || 1) * curve;
|
||||
const c1y = localStartY;
|
||||
const c2x = localEndX - Math.sign(dx || 1) * curve;
|
||||
const c2y = localEndY;
|
||||
|
||||
connector.style.opacity = String(baseOpacity);
|
||||
connector.style.left = `${Math.round(minX)}px`;
|
||||
connector.style.top = `${Math.round(minY)}px`;
|
||||
connector.setAttribute('width', String(Math.ceil(width)));
|
||||
connector.setAttribute('height', String(Math.ceil(height)));
|
||||
connector.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`);
|
||||
connectorPath.setAttribute(
|
||||
'd',
|
||||
`M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(updatePositions);
|
||||
|
|
@ -155,7 +205,13 @@ export function GraphActivityHud({
|
|||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [enabled, focusNodeIds, getActivityAnchorScreenPlacement, visibleLanes]);
|
||||
}, [
|
||||
enabled,
|
||||
focusNodeIds,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
visibleLanes,
|
||||
]);
|
||||
|
||||
const expandedItemsByKey = useMemo(() => {
|
||||
const items = new Map<string, TimelineItem>();
|
||||
|
|
@ -216,71 +272,90 @@ export function GraphActivityHud({
|
|||
return (
|
||||
<>
|
||||
{visibleLanes.map((lane) => (
|
||||
<div
|
||||
key={lane.node.id}
|
||||
ref={(element) => {
|
||||
shellRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0"
|
||||
>
|
||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
Activity
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lane.entries.map((entry, index) => {
|
||||
const messageKey = toMessageKey(entry.message);
|
||||
const renderProps = resolveMessageRenderProps(entry.message, messageContext);
|
||||
const timelineItem: TimelineItem = { type: 'message', message: entry.message };
|
||||
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||
<div key={lane.node.id}>
|
||||
<svg
|
||||
ref={(element) => {
|
||||
connectorRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
|
||||
>
|
||||
<path
|
||||
ref={(element) => {
|
||||
connectorPathRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
d=""
|
||||
fill="none"
|
||||
stroke="rgba(148, 163, 184, 0.3)"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
ref={(element) => {
|
||||
shellRefs.current.set(lane.node.id, element);
|
||||
}}
|
||||
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0"
|
||||
>
|
||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
Activity
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{lane.entries.map((entry, index) => {
|
||||
const messageKey = toMessageKey(entry.message);
|
||||
const renderProps = resolveMessageRenderProps(entry.message, messageContext);
|
||||
const timelineItem: TimelineItem = { type: 'message', message: entry.message };
|
||||
const isUnread = !entry.message.read && !readSet.has(messageKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.graphItem.id}
|
||||
className="cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMessageClick(timelineItem);
|
||||
}
|
||||
}}
|
||||
return (
|
||||
<div
|
||||
key={entry.graphItem.id}
|
||||
className="cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleMessageClick(timelineItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActivityItem
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
collapseMode="managed"
|
||||
isCollapsed
|
||||
canToggleCollapse={false}
|
||||
isUnread={isUnread}
|
||||
expandItemKey={messageKey}
|
||||
onExpand={handleExpandItem}
|
||||
memberRole={renderProps.memberRole}
|
||||
memberColor={renderProps.memberColor}
|
||||
recipientColor={renderProps.recipientColor}
|
||||
memberColorMap={messageContext.colorMap}
|
||||
localMemberNames={messageContext.localMemberNames}
|
||||
onMemberNameClick={handleMemberNameClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
zebraShade={index % 2 === 1}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{lane.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||
>
|
||||
<ActivityItem
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
collapseMode="managed"
|
||||
isCollapsed
|
||||
canToggleCollapse={false}
|
||||
isUnread={isUnread}
|
||||
expandItemKey={messageKey}
|
||||
onExpand={handleExpandItem}
|
||||
memberRole={renderProps.memberRole}
|
||||
memberColor={renderProps.memberColor}
|
||||
recipientColor={renderProps.recipientColor}
|
||||
memberColorMap={messageContext.colorMap}
|
||||
localMemberNames={messageContext.localMemberNames}
|
||||
onMemberNameClick={handleMemberNameClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
zebraShade={index % 2 === 1}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{lane.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => handleOpenOwnerActivity(lane.node)}
|
||||
>
|
||||
+{lane.overflowCount} more
|
||||
</button>
|
||||
) : null}
|
||||
+{lane.overflowCount} more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useCallback, useMemo } from 'react';
|
|||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
import { useStore } from '@renderer/store';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
|
|
@ -69,6 +70,13 @@ export const TeamGraphOverlay = ({
|
|||
}),
|
||||
[dispatchTaskAction]
|
||||
);
|
||||
const openTeamPage = useCallback(() => {
|
||||
useStore.getState().openTeamTab(teamName);
|
||||
onClose();
|
||||
}, [onClose, teamName]);
|
||||
const openCreateTask = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner: '' } }));
|
||||
}, [teamName]);
|
||||
|
||||
const events: GraphEventPort = {
|
||||
onNodeDoubleClick: useCallback(
|
||||
|
|
@ -100,10 +108,13 @@ export const TeamGraphOverlay = ({
|
|||
events={events}
|
||||
onRequestClose={onClose}
|
||||
onRequestPinAsTab={onPinAsTab}
|
||||
onOpenTeamPage={openTeamPage}
|
||||
onCreateTask={openCreateTask}
|
||||
className="team-graph-view min-w-0 flex-1"
|
||||
renderHud={({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
focusNodeIds,
|
||||
}) => (
|
||||
<>
|
||||
|
|
@ -111,6 +122,7 @@ export const TeamGraphOverlay = ({
|
|||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
||||
getNodeScreenPosition={getNodeScreenPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
|
|||
|
||||
import { GraphView } from '@claude-teams/agent-graph';
|
||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||
import { useStore } from '@renderer/store';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
|
|
@ -75,6 +76,15 @@ export const TeamGraphTab = ({
|
|||
window.dispatchEvent(new CustomEvent('graph:create-task', { detail: { teamName, owner } })),
|
||||
[teamName]
|
||||
);
|
||||
const openTeamPage = useCallback(() => {
|
||||
useStore.getState().openTeamTab(teamName);
|
||||
}, [teamName]);
|
||||
const openCreateTask = useCallback(() => {
|
||||
useStore.getState().openTeamTab(teamName);
|
||||
window.setTimeout(() => {
|
||||
dispatchCreateTask('');
|
||||
}, 0);
|
||||
}, [dispatchCreateTask, teamName]);
|
||||
|
||||
// Task action dispatchers
|
||||
const dispatchTaskAction = useCallback(
|
||||
|
|
@ -139,9 +149,12 @@ export const TeamGraphTab = ({
|
|||
className="team-graph-view size-full"
|
||||
suspendAnimation={!isActive}
|
||||
onRequestFullscreen={() => setFullscreen(true)}
|
||||
onOpenTeamPage={openTeamPage}
|
||||
onCreateTask={openCreateTask}
|
||||
renderHud={({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityAnchorScreenPlacement,
|
||||
getNodeScreenPosition,
|
||||
focusNodeIds,
|
||||
}) => (
|
||||
<>
|
||||
|
|
@ -149,6 +162,7 @@ export const TeamGraphTab = ({
|
|||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
|
||||
getNodeScreenPosition={getNodeScreenPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive}
|
||||
onOpenTaskDetail={dispatchOpenTask}
|
||||
|
|
|
|||
|
|
@ -221,6 +221,60 @@ describe('FileWatcher', () => {
|
|||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('pins fallback processed size to the last complete line until a trailing JSON object is completed', async () => {
|
||||
vi.useRealTimers();
|
||||
useRealExistsSync();
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-fallback-partial-'));
|
||||
const projectsDir = path.join(tempDir, 'projects');
|
||||
const projectDir = path.join(projectsDir, 'test-project');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, 'session-1.jsonl');
|
||||
const firstLine = jsonlLine('u1', 'hello');
|
||||
const partialSuffix =
|
||||
'{"type":"assistant","uuid":"u2","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"';
|
||||
fs.writeFileSync(filePath, firstLine + partialSuffix, 'utf8');
|
||||
|
||||
const dataCache = new DataCache(50, 10, false);
|
||||
const notificationManager = createMockNotificationManager();
|
||||
const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos'));
|
||||
watcher.setNotificationManager(notificationManager);
|
||||
|
||||
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||
vi.mocked(errorDetector.detectErrors).mockResolvedValue([]);
|
||||
|
||||
const watcherAny = watcher as unknown as {
|
||||
detectErrorsInSessionFile: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
filePath: string
|
||||
) => Promise<void>;
|
||||
lastProcessedLineCount: Map<string, number>;
|
||||
lastProcessedSize: Map<string, number>;
|
||||
instanceCreatedAt: number;
|
||||
};
|
||||
watcherAny.instanceCreatedAt = 0;
|
||||
|
||||
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||
|
||||
expect(errorDetector.detectErrors).toHaveBeenCalledTimes(1);
|
||||
expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(1);
|
||||
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(Buffer.byteLength(firstLine, 'utf8'));
|
||||
|
||||
fs.appendFileSync(filePath, '}]}}\n', 'utf8');
|
||||
await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath);
|
||||
|
||||
expect(errorDetector.detectErrors).toHaveBeenCalledTimes(2);
|
||||
const secondCallArgs = vi.mocked(errorDetector.detectErrors).mock.calls[1];
|
||||
expect(secondCallArgs?.[0]).toHaveLength(1);
|
||||
expect(secondCallArgs?.[0][0]?.uuid).toBe('u2');
|
||||
expect(watcherAny.lastProcessedSize.get(filePath)).toBe(fs.statSync(filePath).size);
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Catch-Up Scan Tests
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import * as os from 'os';
|
|||
import * as path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { analyzeSessionFileMetadata, calculateMetrics } from '../../../src/main/utils/jsonl';
|
||||
import {
|
||||
analyzeSessionFileMetadata,
|
||||
calculateMetrics,
|
||||
parseJsonlFile,
|
||||
parseJsonlLine,
|
||||
} from '../../../src/main/utils/jsonl';
|
||||
import type { ParsedMessage } from '../../../src/main/types';
|
||||
|
||||
// Helper to create a minimal ParsedMessage
|
||||
|
|
@ -183,4 +188,50 @@ describe('jsonl', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('tolerant parsing', () => {
|
||||
it('skips non-JSON garbage and ignores a partial trailing object', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-tolerant-'));
|
||||
try {
|
||||
const filePath = path.join(tempDir, 'session.jsonl');
|
||||
const validLine = JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'a1',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
});
|
||||
const nonJsonGarbage = '╨╕┬аAI-╤А╨░╨╖╤А╨░╨▒';
|
||||
const partialJson =
|
||||
'{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"';
|
||||
|
||||
fs.writeFileSync(filePath, `${validLine}\n${nonJsonGarbage}\n${partialJson}`, 'utf8');
|
||||
|
||||
const result = await parseJsonlFile(filePath);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.uuid).toBe('a1');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('strips a UTF-8 BOM before parsing an object line', () => {
|
||||
const parsed = parseJsonlLine(
|
||||
`\ufeff${JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: 'bom-1',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'bom' }],
|
||||
},
|
||||
})}`
|
||||
);
|
||||
|
||||
expect(parsed?.uuid).toBe('bom-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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