Merge branch 'main' into main
This commit is contained in:
commit
5a860d083b
16 changed files with 970 additions and 58 deletions
11
README.md
11
README.md
|
|
@ -43,6 +43,9 @@
|
||||||
</a>
|
</a>
|
||||||
<a href="#docker--standalone-deployment">
|
<a href="#docker--standalone-deployment">
|
||||||
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
|
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
|
||||||
|
</a>
|
||||||
|
<a href="#installation">
|
||||||
|
<img src="https://img.shields.io/badge/Homebrew-Install-FBB040?logo=homebrew&logoColor=white&style=flat" alt="Install with Homebrew" height="30" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -62,6 +65,14 @@
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
### Homebrew (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask claude-devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Download
|
||||||
|
|
||||||
| Platform | Download | Notes |
|
| Platform | Download | Notes |
|
||||||
|----------|----------|-------|
|
|----------|----------|-------|
|
||||||
| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Download the `arm64` asset. Drag to Applications. On first launch: right-click → Open |
|
| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Download the `arm64` asset. Drag to Applications. On first launch: right-click → Open |
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,9 @@
|
||||||
"icon": "resources/icons/png",
|
"icon": "resources/icons/png",
|
||||||
"category": "Development"
|
"category": "Development"
|
||||||
},
|
},
|
||||||
|
"deb": {
|
||||||
|
"afterInstall": "resources/afterInstall.sh"
|
||||||
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
|
|
|
||||||
10
resources/afterInstall.sh
Executable file
10
resources/afterInstall.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Fix chrome-sandbox permissions for SUID sandbox on Linux
|
||||||
|
# See: https://github.com/electron/electron/issues/17972
|
||||||
|
|
||||||
|
SANDBOX_PATH="/opt/${productFilename}/chrome-sandbox"
|
||||||
|
|
||||||
|
if [ -f "$SANDBOX_PATH" ]; then
|
||||||
|
chown root:root "$SANDBOX_PATH"
|
||||||
|
chmod 4755 "$SANDBOX_PATH"
|
||||||
|
fi
|
||||||
|
|
@ -267,8 +267,11 @@ export class ProjectScanner {
|
||||||
cwdGroups.set(key, group);
|
cwdGroups.set(key, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If only 1 unique cwd, return single project (current behavior)
|
// If only 1 unique real cwd, return single project (current behavior)
|
||||||
if (cwdGroups.size <= 1) {
|
// Sessions without cwd (older format) are implicitly from the same project,
|
||||||
|
// so we only count distinct real cwds to decide whether to split.
|
||||||
|
const realCwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__'));
|
||||||
|
if (realCwdKeys.length <= 1) {
|
||||||
const allSessionIds = sessionInfos.map((s) => s.sessionId);
|
const allSessionIds = sessionInfos.map((s) => s.sessionId);
|
||||||
let mostRecentSession: number | undefined;
|
let mostRecentSession: number | undefined;
|
||||||
let createdAt = Date.now();
|
let createdAt = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||||
{
|
{
|
||||||
id: 'builtin-bash-command',
|
id: 'builtin-bash-command',
|
||||||
name: '.env File Access Alert',
|
name: '.env File Access Alert',
|
||||||
enabled: true,
|
enabled: false,
|
||||||
contentType: 'tool_use',
|
contentType: 'tool_use',
|
||||||
mode: 'content_match',
|
mode: 'content_match',
|
||||||
matchPattern: '/.env',
|
matchPattern: '/.env',
|
||||||
|
|
@ -41,7 +41,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||||
{
|
{
|
||||||
id: 'builtin-tool-result-error',
|
id: 'builtin-tool-result-error',
|
||||||
name: 'Tool Result Error',
|
name: 'Tool Result Error',
|
||||||
enabled: true,
|
enabled: false,
|
||||||
contentType: 'tool_result',
|
contentType: 'tool_result',
|
||||||
mode: 'error_status',
|
mode: 'error_status',
|
||||||
requireError: true,
|
requireError: true,
|
||||||
|
|
@ -55,7 +55,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||||
{
|
{
|
||||||
id: 'builtin-high-token-usage',
|
id: 'builtin-high-token-usage',
|
||||||
name: 'High Token Usage',
|
name: 'High Token Usage',
|
||||||
enabled: true,
|
enabled: false,
|
||||||
contentType: 'tool_result',
|
contentType: 'tool_result',
|
||||||
mode: 'token_threshold',
|
mode: 'token_threshold',
|
||||||
tokenThreshold: 8000,
|
tokenThreshold: 8000,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
/**
|
||||||
|
* FlatInjectionList - Completely denested view where every individual tool call,
|
||||||
|
* thinking block, and coordination item is its own row, sorted by token size descending.
|
||||||
|
* Makes it obvious whether a single large tool or many small ones are consuming tokens.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
|
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
|
||||||
|
|
||||||
|
import { formatTokens } from '../utils/formatting';
|
||||||
|
import { parseTurnIndex } from '../utils/pathParsing';
|
||||||
|
|
||||||
|
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
'claude-md': { bg: 'rgba(99, 102, 241, 0.15)', text: '#818cf8', label: 'CLAUDE.md' },
|
||||||
|
'mentioned-file': { bg: 'rgba(52, 211, 153, 0.15)', text: '#34d399', label: 'File' },
|
||||||
|
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
|
||||||
|
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
|
||||||
|
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
|
||||||
|
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface FlatRow {
|
||||||
|
key: string;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tokens: number;
|
||||||
|
turnIndex: number;
|
||||||
|
toolUseId?: string;
|
||||||
|
isError?: boolean;
|
||||||
|
copyPath?: string;
|
||||||
|
navigationType: 'tool' | 'turn' | 'user-group';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlatInjectionListProps {
|
||||||
|
injections: ContextInjection[];
|
||||||
|
onNavigateToTurn?: (turnIndex: number) => void;
|
||||||
|
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||||
|
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function flattenInjections(injections: ContextInjection[]): FlatRow[] {
|
||||||
|
const rows: FlatRow[] = [];
|
||||||
|
|
||||||
|
for (const inj of injections) {
|
||||||
|
switch (inj.category) {
|
||||||
|
case 'tool-output':
|
||||||
|
if (inj.toolBreakdown.length > 0) {
|
||||||
|
for (const tool of inj.toolBreakdown) {
|
||||||
|
rows.push({
|
||||||
|
key: `${inj.id}-${tool.toolName}-${tool.toolUseId ?? rows.length}`,
|
||||||
|
category: 'tool-output',
|
||||||
|
label: tool.toolName,
|
||||||
|
description: `Turn ${inj.turnIndex + 1}`,
|
||||||
|
tokens: tool.tokenCount,
|
||||||
|
turnIndex: inj.turnIndex,
|
||||||
|
toolUseId: tool.toolUseId,
|
||||||
|
isError: tool.isError,
|
||||||
|
navigationType: tool.toolUseId ? 'tool' : 'turn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows.push({
|
||||||
|
key: inj.id,
|
||||||
|
category: 'tool-output',
|
||||||
|
label: `${inj.toolCount} tool${inj.toolCount !== 1 ? 's' : ''}`,
|
||||||
|
description: `Turn ${inj.turnIndex + 1}`,
|
||||||
|
tokens: inj.estimatedTokens,
|
||||||
|
turnIndex: inj.turnIndex,
|
||||||
|
navigationType: 'turn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'thinking-text':
|
||||||
|
for (const item of inj.breakdown) {
|
||||||
|
rows.push({
|
||||||
|
key: `${inj.id}-${item.type}`,
|
||||||
|
category: 'thinking-text',
|
||||||
|
label: item.type === 'thinking' ? 'Thinking' : 'Text',
|
||||||
|
description: `Turn ${inj.turnIndex + 1}`,
|
||||||
|
tokens: item.tokenCount,
|
||||||
|
turnIndex: inj.turnIndex,
|
||||||
|
navigationType: 'turn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'task-coordination':
|
||||||
|
for (const item of inj.breakdown) {
|
||||||
|
rows.push({
|
||||||
|
key: `${inj.id}-${item.type}-${item.label}`,
|
||||||
|
category: 'task-coordination',
|
||||||
|
label: item.toolName ?? item.label,
|
||||||
|
description: `Turn ${inj.turnIndex + 1}`,
|
||||||
|
tokens: item.tokenCount,
|
||||||
|
turnIndex: inj.turnIndex,
|
||||||
|
navigationType: 'turn',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'claude-md':
|
||||||
|
rows.push({
|
||||||
|
key: inj.id,
|
||||||
|
category: 'claude-md',
|
||||||
|
label: inj.displayName || inj.path,
|
||||||
|
description: '',
|
||||||
|
tokens: inj.estimatedTokens,
|
||||||
|
turnIndex: parseTurnIndex(inj.firstSeenInGroup),
|
||||||
|
copyPath: inj.path,
|
||||||
|
navigationType: 'turn',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mentioned-file':
|
||||||
|
rows.push({
|
||||||
|
key: inj.id,
|
||||||
|
category: 'mentioned-file',
|
||||||
|
label: inj.displayName,
|
||||||
|
description: '',
|
||||||
|
tokens: inj.estimatedTokens,
|
||||||
|
turnIndex: inj.firstSeenTurnIndex,
|
||||||
|
copyPath: inj.path,
|
||||||
|
navigationType: 'turn',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user-message':
|
||||||
|
rows.push({
|
||||||
|
key: inj.id,
|
||||||
|
category: 'user-message',
|
||||||
|
label: inj.textPreview,
|
||||||
|
description: '',
|
||||||
|
tokens: inj.estimatedTokens,
|
||||||
|
turnIndex: inj.turnIndex,
|
||||||
|
navigationType: 'user-group',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => b.tokens - a.tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const FlatInjectionList = ({
|
||||||
|
injections,
|
||||||
|
onNavigateToTurn,
|
||||||
|
onNavigateToTool,
|
||||||
|
onNavigateToUserGroup,
|
||||||
|
}: Readonly<FlatInjectionListProps>): React.ReactElement => {
|
||||||
|
const rows = useMemo(() => flattenInjections(injections), [injections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const categoryInfo = CATEGORY_COLORS[row.category] ?? {
|
||||||
|
bg: 'rgba(161, 161, 170, 0.15)',
|
||||||
|
text: '#a1a1aa',
|
||||||
|
label: row.category,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (): void => {
|
||||||
|
if (row.turnIndex < 0) return;
|
||||||
|
if (row.navigationType === 'tool' && row.toolUseId && onNavigateToTool) {
|
||||||
|
onNavigateToTool(row.turnIndex, row.toolUseId);
|
||||||
|
} else if (row.navigationType === 'user-group' && onNavigateToUserGroup) {
|
||||||
|
onNavigateToUserGroup(row.turnIndex);
|
||||||
|
} else if (onNavigateToTurn) {
|
||||||
|
onNavigateToTurn(row.turnIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayText = row.description
|
||||||
|
? `${row.label} \u2014 ${row.description}`
|
||||||
|
: row.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={row.key} className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{/* Category pill */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||||
|
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||||
|
>
|
||||||
|
{categoryInfo.label}
|
||||||
|
</span>
|
||||||
|
{/* Description */}
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-xs"
|
||||||
|
style={{ color: COLOR_TEXT_SECONDARY }}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
{/* Error badge */}
|
||||||
|
{row.isError && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded px-1 py-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Token count */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-xs font-medium tabular-nums"
|
||||||
|
style={{ color: COLOR_TEXT_MUTED }}
|
||||||
|
>
|
||||||
|
{formatTokens(row.tokens)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Copy path button for CLAUDE.md and File items */}
|
||||||
|
{row.copyPath && (
|
||||||
|
<span className="shrink-0">
|
||||||
|
<CopyButton text={row.copyPath} inline />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -5,9 +5,15 @@
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { COLOR_BORDER, COLOR_SURFACE, COLOR_TEXT_MUTED } from '@renderer/constants/cssVariables';
|
import {
|
||||||
|
COLOR_BORDER,
|
||||||
|
COLOR_SURFACE,
|
||||||
|
COLOR_SURFACE_OVERLAY,
|
||||||
|
COLOR_TEXT_MUTED,
|
||||||
|
} from '@renderer/constants/cssVariables';
|
||||||
|
|
||||||
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
||||||
|
import { FlatInjectionList } from './components/FlatInjectionList';
|
||||||
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
||||||
import { RankedInjectionList } from './components/RankedInjectionList';
|
import { RankedInjectionList } from './components/RankedInjectionList';
|
||||||
import { SessionContextHeader } from './components/SessionContextHeader';
|
import { SessionContextHeader } from './components/SessionContextHeader';
|
||||||
|
|
@ -46,8 +52,10 @@ export const SessionContextPanel = ({
|
||||||
selectedPhase,
|
selectedPhase,
|
||||||
onPhaseChange,
|
onPhaseChange,
|
||||||
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
|
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
|
||||||
// View mode: category sections or flat ranked list
|
// View mode: category sections or ranked list
|
||||||
const [viewMode, setViewMode] = useState<ContextViewMode>('category');
|
const [viewMode, setViewMode] = useState<ContextViewMode>('category');
|
||||||
|
// Flat sub-toggle within "By Size" view
|
||||||
|
const [flatMode, setFlatMode] = useState(false);
|
||||||
|
|
||||||
// Track which main sections are expanded
|
// Track which main sections are expanded
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
||||||
|
|
@ -252,12 +260,46 @@ export const SessionContextPanel = ({
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<RankedInjectionList
|
<>
|
||||||
injections={injections}
|
{/* Grouped / Flat sub-toggle */}
|
||||||
onNavigateToTurn={onNavigateToTurn}
|
<div className="flex items-center gap-1 pb-1">
|
||||||
onNavigateToTool={onNavigateToTool}
|
<button
|
||||||
onNavigateToUserGroup={onNavigateToUserGroup}
|
onClick={() => setFlatMode(false)}
|
||||||
/>
|
className="rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: !flatMode ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||||
|
color: !flatMode ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Grouped
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFlatMode(true)}
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: flatMode ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||||
|
color: flatMode ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Flat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{flatMode ? (
|
||||||
|
<FlatInjectionList
|
||||||
|
injections={injections}
|
||||||
|
onNavigateToTurn={onNavigateToTurn}
|
||||||
|
onNavigateToTool={onNavigateToTool}
|
||||||
|
onNavigateToUserGroup={onNavigateToUserGroup}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RankedInjectionList
|
||||||
|
injections={injections}
|
||||||
|
onNavigateToTurn={onNavigateToTurn}
|
||||||
|
onNavigateToTool={onNavigateToTool}
|
||||||
|
onNavigateToUserGroup={onNavigateToUserGroup}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -95,19 +95,92 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: JSON format
|
// Default: key-value format with readable string values
|
||||||
return (
|
return (
|
||||||
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
||||||
{JSON.stringify(input, null, 2)}
|
{Object.entries(input).map(([key, value]) => (
|
||||||
</pre>
|
<div key={key}>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap break-all">{formatInputValue(value)}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatInputValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the output section with theme-aware styling.
|
* Renders the output section with theme-aware styling.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Extracts display text from tool output content.
|
||||||
|
* Handles content block arrays from the API by extracting text fields
|
||||||
|
* and pretty-printing JSON when possible.
|
||||||
|
*/
|
||||||
|
export function extractOutputText(content: string | unknown[]): string {
|
||||||
|
let displayText: string;
|
||||||
|
|
||||||
|
// Normalize: if content is a string that parses to an array of content blocks, treat as array
|
||||||
|
let normalizedContent: string | unknown[] = content;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0 && isContentBlock(parsed[0])) {
|
||||||
|
normalizedContent = parsed as unknown[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, keep as string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof normalizedContent === 'string') {
|
||||||
|
displayText = normalizedContent;
|
||||||
|
} else if (Array.isArray(normalizedContent)) {
|
||||||
|
// Extract text from content blocks (e.g. [{"type":"text","text":"..."}])
|
||||||
|
displayText = normalizedContent
|
||||||
|
.map((block) =>
|
||||||
|
typeof block === 'object' && block !== null && 'text' in block
|
||||||
|
? (block as { text: string }).text
|
||||||
|
: JSON.stringify(block, null, 2),
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
} else {
|
||||||
|
displayText = JSON.stringify(normalizedContent, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to pretty-print if the extracted text is valid JSON
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(displayText);
|
||||||
|
displayText = JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
// Not JSON, use as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentBlock(value: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'type' in value &&
|
||||||
|
typeof (value as Record<string, unknown>).type === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderOutput(content: string | unknown[]): React.ReactElement {
|
export function renderOutput(content: string | unknown[]): React.ReactElement {
|
||||||
const displayText = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
const displayText = extractOutputText(content);
|
||||||
return (
|
return (
|
||||||
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
||||||
{displayText}
|
{displayText}
|
||||||
|
|
|
||||||
|
|
@ -128,15 +128,21 @@ export const NotificationsView = (): React.JSX.Element => {
|
||||||
rowVirtualizer.scrollToIndex(0);
|
rowVirtualizer.scrollToIndex(0);
|
||||||
}, [activeFilter, rowVirtualizer]);
|
}, [activeFilter, rowVirtualizer]);
|
||||||
|
|
||||||
// Handle mark all read
|
// Derive filtered unread count for scoped button visibility
|
||||||
|
const filteredUnreadCount = useMemo(() => {
|
||||||
|
if (activeFilter === null) return unreadCount;
|
||||||
|
return filteredNotifications.filter((n) => !n.isRead).length;
|
||||||
|
}, [activeFilter, filteredNotifications, unreadCount]);
|
||||||
|
|
||||||
|
// Handle mark all read (scoped to active filter)
|
||||||
const handleMarkAllRead = async (): Promise<void> => {
|
const handleMarkAllRead = async (): Promise<void> => {
|
||||||
await markAllNotificationsRead();
|
await markAllNotificationsRead(activeFilter ?? undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle clear all with confirmation
|
// Handle clear all with confirmation (scoped to active filter)
|
||||||
const handleClearAll = async (): Promise<void> => {
|
const handleClearAll = async (): Promise<void> => {
|
||||||
if (showClearConfirm) {
|
if (showClearConfirm) {
|
||||||
await clearNotifications();
|
await clearNotifications(activeFilter ?? undefined);
|
||||||
setShowClearConfirm(false);
|
setShowClearConfirm(false);
|
||||||
} else {
|
} else {
|
||||||
setShowClearConfirm(true);
|
setShowClearConfirm(true);
|
||||||
|
|
@ -205,7 +211,13 @@ export const NotificationsView = (): React.JSX.Element => {
|
||||||
</span>
|
</span>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
{unreadCount > 0 ? `${unreadCount} unread` : `${notifications.length} total`}
|
{activeFilter !== null
|
||||||
|
? filteredUnreadCount > 0
|
||||||
|
? `${filteredUnreadCount} unread in filter`
|
||||||
|
: `${filteredNotifications.length} in filter`
|
||||||
|
: unreadCount > 0
|
||||||
|
? `${unreadCount} unread`
|
||||||
|
: `${notifications.length} total`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -213,19 +225,21 @@ export const NotificationsView = (): React.JSX.Element => {
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Mark all read */}
|
{/* Mark all/filtered read */}
|
||||||
{unreadCount > 0 && (
|
{filteredUnreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleMarkAllRead}
|
onClick={handleMarkAllRead}
|
||||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors hover:opacity-80"
|
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors hover:opacity-80"
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
title="Mark all as read"
|
title={activeFilter !== null ? 'Mark filtered as read' : 'Mark all as read'}
|
||||||
>
|
>
|
||||||
<CheckCheck className="size-4" />
|
<CheckCheck className="size-4" />
|
||||||
<span className="hidden sm:inline">Mark all read</span>
|
<span className="hidden sm:inline">
|
||||||
|
{activeFilter !== null ? 'Mark filtered read' : 'Mark all read'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Clear all */}
|
{/* Clear all/filtered */}
|
||||||
<button
|
<button
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
className={`flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
className={`flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||||
|
|
@ -234,11 +248,17 @@ export const NotificationsView = (): React.JSX.Element => {
|
||||||
: 'hover:opacity-80'
|
: 'hover:opacity-80'
|
||||||
}`}
|
}`}
|
||||||
style={showClearConfirm ? undefined : { color: 'var(--color-text-muted)' }}
|
style={showClearConfirm ? undefined : { color: 'var(--color-text-muted)' }}
|
||||||
title="Clear all notifications"
|
title={
|
||||||
|
activeFilter !== null ? 'Clear filtered notifications' : 'Clear all notifications'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
<span className="hidden sm:inline">
|
<span className="hidden sm:inline">
|
||||||
{showClearConfirm ? 'Click to confirm' : 'Clear all'}
|
{showClearConfirm
|
||||||
|
? 'Click to confirm'
|
||||||
|
: activeFilter !== null
|
||||||
|
? 'Clear filtered'
|
||||||
|
: 'Clear all'}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
* Shows keyboard shortcut hints for actions that have them.
|
* Shows keyboard shortcut hints for actions that have them.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { MAX_PANES } from '@renderer/types/panes';
|
import { MAX_PANES } from '@renderer/types/panes';
|
||||||
import { Eye, EyeOff, Pin, PinOff } from 'lucide-react';
|
import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
interface SessionContextMenuProps {
|
interface SessionContextMenuProps {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -29,6 +29,7 @@ interface SessionContextMenuProps {
|
||||||
export const SessionContextMenu = ({
|
export const SessionContextMenu = ({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
sessionId,
|
||||||
paneCount,
|
paneCount,
|
||||||
isPinned,
|
isPinned,
|
||||||
isHidden,
|
isHidden,
|
||||||
|
|
@ -40,6 +41,7 @@ export const SessionContextMenu = ({
|
||||||
onToggleHide,
|
onToggleHide,
|
||||||
}: SessionContextMenuProps): React.JSX.Element => {
|
}: SessionContextMenuProps): React.JSX.Element => {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<'id' | 'command' | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseDown = (e: MouseEvent): void => {
|
const handleMouseDown = (e: MouseEvent): void => {
|
||||||
|
|
@ -59,7 +61,7 @@ export const SessionContextMenu = ({
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const menuWidth = 240;
|
const menuWidth = 240;
|
||||||
const menuHeight = 204;
|
const menuHeight = 290;
|
||||||
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
||||||
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
||||||
|
|
||||||
|
|
@ -68,6 +70,19 @@ export const SessionContextMenu = ({
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopy = (text: string, field: 'id' | 'command') => async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopiedField(field);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopiedField(null);
|
||||||
|
onClose();
|
||||||
|
}, 600);
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const atMaxPanes = paneCount >= MAX_PANES;
|
const atMaxPanes = paneCount >= MAX_PANES;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -101,6 +116,29 @@ export const SessionContextMenu = ({
|
||||||
icon={isHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
|
icon={isHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
|
||||||
onClick={handleClick(onToggleHide)}
|
onClick={handleClick(onToggleHide)}
|
||||||
/>
|
/>
|
||||||
|
<div className="mx-2 my-1 border-t" style={{ borderColor: 'var(--color-border)' }} />
|
||||||
|
<MenuItem
|
||||||
|
label={copiedField === 'id' ? 'Copied!' : 'Copy Session ID'}
|
||||||
|
icon={
|
||||||
|
copiedField === 'id' ? (
|
||||||
|
<Check className="size-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<ClipboardCopy className="size-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleCopy(sessionId, 'id')}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label={copiedField === 'command' ? 'Copied!' : 'Copy Resume Command'}
|
||||||
|
icon={
|
||||||
|
copiedField === 'command' ? (
|
||||||
|
<Check className="size-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Terminal className="size-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={handleCopy(`claude --resume ${sessionId}`, 'command')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,9 @@ export interface NotificationSlice {
|
||||||
// Actions
|
// Actions
|
||||||
fetchNotifications: () => Promise<void>;
|
fetchNotifications: () => Promise<void>;
|
||||||
markNotificationRead: (id: string) => Promise<void>;
|
markNotificationRead: (id: string) => Promise<void>;
|
||||||
markAllNotificationsRead: () => Promise<void>;
|
markAllNotificationsRead: (triggerName?: string) => Promise<void>;
|
||||||
deleteNotification: (id: string) => Promise<void>;
|
deleteNotification: (id: string) => Promise<void>;
|
||||||
clearNotifications: () => Promise<void>;
|
clearNotifications: (triggerName?: string) => Promise<void>;
|
||||||
navigateToError: (error: DetectedError) => void;
|
navigateToError: (error: DetectedError) => void;
|
||||||
openNotificationsTab: () => void;
|
openNotificationsTab: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -99,19 +99,41 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mark all notifications as read
|
// Mark all notifications as read (optionally scoped to a trigger)
|
||||||
markAllNotificationsRead: async () => {
|
markAllNotificationsRead: async (triggerName?: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await api.notifications.markAllRead();
|
if (triggerName !== undefined) {
|
||||||
if (!success) {
|
// Scoped: mark only matching unread notifications
|
||||||
await get().fetchNotifications();
|
const { notifications } = get();
|
||||||
return;
|
const matching = notifications.filter((n) => {
|
||||||
|
const label = n.triggerName ?? 'Other';
|
||||||
|
return label === triggerName && !n.isRead;
|
||||||
|
});
|
||||||
|
if (matching.length === 0) return;
|
||||||
|
const results = await Promise.all(matching.map((n) => api.notifications.markRead(n.id)));
|
||||||
|
if (results.some((r) => !r)) {
|
||||||
|
await get().fetchNotifications();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matchingIds = new Set(matching.map((n) => n.id));
|
||||||
|
set((state) => {
|
||||||
|
const updated = state.notifications.map((n) =>
|
||||||
|
matchingIds.has(n.id) ? { ...n, isRead: true } : n
|
||||||
|
);
|
||||||
|
return { notifications: updated, unreadCount: updated.filter((n) => !n.isRead).length };
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unscoped: mark all
|
||||||
|
const success = await api.notifications.markAllRead();
|
||||||
|
if (!success) {
|
||||||
|
await get().fetchNotifications();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
|
||||||
|
unreadCount: 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
// Optimistically update local state
|
|
||||||
set((state) => ({
|
|
||||||
notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
|
|
||||||
unreadCount: 0,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to mark all notifications as read:', error);
|
logger.error('Failed to mark all notifications as read:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -136,18 +158,42 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear all notifications
|
// Clear all notifications (optionally scoped to a trigger)
|
||||||
clearNotifications: async () => {
|
clearNotifications: async (triggerName?: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await api.notifications.clear();
|
if (triggerName !== undefined) {
|
||||||
if (!success) {
|
// Scoped: delete only matching notifications
|
||||||
await get().fetchNotifications();
|
const { notifications } = get();
|
||||||
return;
|
const matching = notifications.filter((n) => {
|
||||||
|
const label = n.triggerName ?? 'Other';
|
||||||
|
return label === triggerName;
|
||||||
|
});
|
||||||
|
if (matching.length === 0) return;
|
||||||
|
const results = await Promise.all(matching.map((n) => api.notifications.delete(n.id)));
|
||||||
|
if (results.some((r) => !r)) {
|
||||||
|
await get().fetchNotifications();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const matchingIds = new Set(matching.map((n) => n.id));
|
||||||
|
set((state) => {
|
||||||
|
const remaining = state.notifications.filter((n) => !matchingIds.has(n.id));
|
||||||
|
return {
|
||||||
|
notifications: remaining,
|
||||||
|
unreadCount: remaining.filter((n) => !n.isRead).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unscoped: clear all
|
||||||
|
const success = await api.notifications.clear();
|
||||||
|
if (!success) {
|
||||||
|
await get().fetchNotifications();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
notifications: [],
|
||||||
|
unreadCount: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
set({
|
|
||||||
notifications: [],
|
|
||||||
unreadCount: 0,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to clear notifications:', error);
|
logger.error('Failed to clear notifications:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -836,13 +836,13 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
||||||
newCounts.mentionedFiles++;
|
newCounts.mentionedFiles++;
|
||||||
break;
|
break;
|
||||||
case 'tool-output':
|
case 'tool-output':
|
||||||
newCounts.toolOutputs += (injection).toolCount;
|
newCounts.toolOutputs += injection.toolCount;
|
||||||
break;
|
break;
|
||||||
case 'thinking-text':
|
case 'thinking-text':
|
||||||
newCounts.thinkingText++;
|
newCounts.thinkingText++;
|
||||||
break;
|
break;
|
||||||
case 'task-coordination':
|
case 'task-coordination':
|
||||||
newCounts.taskCoordination += (injection).breakdown.length;
|
newCounts.taskCoordination += injection.breakdown.length;
|
||||||
break;
|
break;
|
||||||
case 'user-message':
|
case 'user-message':
|
||||||
newCounts.userMessages++;
|
newCounts.userMessages++;
|
||||||
|
|
|
||||||
105
test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
Normal file
105
test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner';
|
||||||
|
import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry';
|
||||||
|
|
||||||
|
function createSessionLine(opts: { cwd?: string; type?: string }): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
uuid: 'test-uuid',
|
||||||
|
type: opts.type ?? 'user',
|
||||||
|
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||||
|
message: { role: 'user', content: 'hello' },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectScanner cwd split logic', () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
subprojectRegistry.clear();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
for (const dir of tempDirs) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not split when sessions have a single cwd mixed with sessions without cwd', async () => {
|
||||||
|
const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
|
||||||
|
tempDirs.push(projectsDir);
|
||||||
|
|
||||||
|
// Create a project directory with encoded name
|
||||||
|
const encodedName = '-Users-test-myproject';
|
||||||
|
const projectDir = path.join(projectsDir, encodedName);
|
||||||
|
fs.mkdirSync(projectDir);
|
||||||
|
|
||||||
|
// Session WITH cwd
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectDir, 'session-with-cwd.jsonl'),
|
||||||
|
createSessionLine({ cwd: '/Users/test/myproject' }) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Session WITHOUT cwd (older format)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectDir, 'session-no-cwd.jsonl'),
|
||||||
|
createSessionLine({ type: 'system' }) +
|
||||||
|
'\n' +
|
||||||
|
createSessionLine({ type: 'user' }) +
|
||||||
|
'\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const scanner = new ProjectScanner(projectsDir);
|
||||||
|
const projects = await scanner.scan();
|
||||||
|
|
||||||
|
// Should produce 1 project, not 2 subprojects
|
||||||
|
const myProjects = projects.filter((p) => p.id.includes('myproject'));
|
||||||
|
expect(myProjects).toHaveLength(1);
|
||||||
|
|
||||||
|
// Should use the plain encoded name, not a composite ID
|
||||||
|
expect(myProjects[0].id).toBe(encodedName);
|
||||||
|
|
||||||
|
// Should include both sessions
|
||||||
|
expect(myProjects[0].sessions).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits when sessions have multiple distinct cwds', async () => {
|
||||||
|
const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
|
||||||
|
tempDirs.push(projectsDir);
|
||||||
|
|
||||||
|
const encodedName = '-Users-test-myproject';
|
||||||
|
const projectDir = path.join(projectsDir, encodedName);
|
||||||
|
fs.mkdirSync(projectDir);
|
||||||
|
|
||||||
|
// Session with cwd A
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectDir, 'session-a.jsonl'),
|
||||||
|
createSessionLine({ cwd: '/Users/test/myproject' }) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Session with cwd B (different)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectDir, 'session-b.jsonl'),
|
||||||
|
createSessionLine({ cwd: '/Users/test/other-project' }) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const scanner = new ProjectScanner(projectsDir);
|
||||||
|
const projects = await scanner.scan();
|
||||||
|
|
||||||
|
// Should produce 2 subprojects with composite IDs
|
||||||
|
const myProjects = projects.filter((p) => p.id.includes('myproject'));
|
||||||
|
expect(myProjects).toHaveLength(2);
|
||||||
|
|
||||||
|
// Both should be composite IDs
|
||||||
|
for (const proj of myProjects) {
|
||||||
|
expect(proj.id).toContain('::');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
37
test/renderer/components/renderOutput.test.ts
Normal file
37
test/renderer/components/renderOutput.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
|
||||||
|
|
||||||
|
describe('extractOutputText', () => {
|
||||||
|
it('should return plain string as-is', () => {
|
||||||
|
expect(extractOutputText('hello world')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pretty-print a plain string that is valid JSON', () => {
|
||||||
|
expect(extractOutputText('{"key":"value"}')).toBe(JSON.stringify({ key: 'value' }, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract text from content blocks with plain text', () => {
|
||||||
|
expect(extractOutputText([{ type: 'text', text: 'plain text' }])).toBe('plain text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract and pretty-print JSON from content blocks', () => {
|
||||||
|
expect(extractOutputText([{ type: 'text', text: '{"key":"value"}' }])).toBe(
|
||||||
|
JSON.stringify({ key: 'value' }, null, 2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should concatenate multiple content blocks with newline', () => {
|
||||||
|
expect(
|
||||||
|
extractOutputText([
|
||||||
|
{ type: 'text', text: 'line one' },
|
||||||
|
{ type: 'text', text: 'line two' },
|
||||||
|
]),
|
||||||
|
).toBe('line one\nline two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to stringify for blocks without text field', () => {
|
||||||
|
const block = { type: 'image', url: 'http://example.com/img.png' };
|
||||||
|
expect(extractOutputText([block])).toBe(JSON.stringify(block, null, 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -70,6 +70,221 @@ describe('notificationSlice', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scoped markAllNotificationsRead', () => {
|
||||||
|
const makeNotification = (
|
||||||
|
id: string,
|
||||||
|
triggerName: string | undefined,
|
||||||
|
isRead: boolean
|
||||||
|
): DetectedError => ({
|
||||||
|
id,
|
||||||
|
sessionId: 's1',
|
||||||
|
projectId: 'p1',
|
||||||
|
lineNumber: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
triggerName,
|
||||||
|
severity: 'error',
|
||||||
|
message: `msg-${id}`,
|
||||||
|
isRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only matching trigger notifications as read', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'tool result error', false);
|
||||||
|
const n2 = makeNotification('n2', 'high token usage', false);
|
||||||
|
const n3 = makeNotification('n3', 'tool result error', false);
|
||||||
|
store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 3 });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead('tool result error');
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.notifications.find((n) => n.id === 'n1')!.isRead).toBe(true);
|
||||||
|
expect(state.notifications.find((n) => n.id === 'n2')!.isRead).toBe(false);
|
||||||
|
expect(state.notifications.find((n) => n.id === 'n3')!.isRead).toBe(true);
|
||||||
|
expect(state.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls markRead individually for each matching notification', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead('trigger-a');
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.markRead).toHaveBeenCalledWith('n1');
|
||||||
|
expect(mockAPI.notifications.markRead).toHaveBeenCalledWith('n2');
|
||||||
|
expect(mockAPI.notifications.markAllRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses markAllRead API when no triggerName is provided', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1] as never[], unreadCount: 1 });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead();
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.markAllRead).toHaveBeenCalled();
|
||||||
|
expect(mockAPI.notifications.markRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats notifications without triggerName as "Other"', async () => {
|
||||||
|
const n1 = makeNotification('n1', undefined, false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead('Other');
|
||||||
|
|
||||||
|
expect(store.getState().notifications.find((n) => n.id === 'n1')!.isRead).toBe(true);
|
||||||
|
expect(store.getState().notifications.find((n) => n.id === 'n2')!.isRead).toBe(false);
|
||||||
|
expect(store.getState().unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips already-read notifications in scoped mode', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', true);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead('trigger-a');
|
||||||
|
|
||||||
|
// Only n2 should be sent to API (n1 already read)
|
||||||
|
expect(mockAPI.notifications.markRead).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockAPI.notifications.markRead).toHaveBeenCalledWith('n2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops when no unread notifications match the trigger', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', true);
|
||||||
|
store.setState({ notifications: [n1] as never[], unreadCount: 0 });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead('trigger-a');
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.markRead).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fetches when any scoped markRead call fails', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
mockAPI.notifications.markRead.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||||
|
mockAPI.notifications.get.mockResolvedValue({ notifications: [] });
|
||||||
|
|
||||||
|
await store.getState().markAllNotificationsRead('trigger-a');
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.get).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scoped clearNotifications', () => {
|
||||||
|
const makeNotification = (
|
||||||
|
id: string,
|
||||||
|
triggerName: string | undefined,
|
||||||
|
isRead: boolean
|
||||||
|
): DetectedError => ({
|
||||||
|
id,
|
||||||
|
sessionId: 's1',
|
||||||
|
projectId: 'p1',
|
||||||
|
lineNumber: 1,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
triggerName,
|
||||||
|
severity: 'error',
|
||||||
|
message: `msg-${id}`,
|
||||||
|
isRead,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes only matching trigger notifications', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'tool result error', false);
|
||||||
|
const n2 = makeNotification('n2', 'high token usage', false);
|
||||||
|
const n3 = makeNotification('n3', 'tool result error', true);
|
||||||
|
store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('tool result error');
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.notifications).toHaveLength(1);
|
||||||
|
expect(state.notifications[0].id).toBe('n2');
|
||||||
|
expect(state.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls delete individually for each matching notification', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', true);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('trigger-a');
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.delete).toHaveBeenCalledWith('n1');
|
||||||
|
expect(mockAPI.notifications.delete).toHaveBeenCalledWith('n2');
|
||||||
|
expect(mockAPI.notifications.clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses clear API when no triggerName is provided', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1] as never[], unreadCount: 1 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications();
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.clear).toHaveBeenCalled();
|
||||||
|
expect(mockAPI.notifications.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats notifications without triggerName as "Other"', async () => {
|
||||||
|
const n1 = makeNotification('n1', undefined, false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('Other');
|
||||||
|
|
||||||
|
const state = store.getState();
|
||||||
|
expect(state.notifications).toHaveLength(1);
|
||||||
|
expect(state.notifications[0].id).toBe('n2');
|
||||||
|
expect(state.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears both read and unread notifications for the trigger', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', true);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('trigger-a');
|
||||||
|
|
||||||
|
expect(store.getState().notifications).toHaveLength(0);
|
||||||
|
expect(store.getState().unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops when no notifications match the trigger', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-b', false);
|
||||||
|
store.setState({ notifications: [n1] as never[], unreadCount: 1 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('trigger-a');
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.delete).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().notifications).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-fetches when any scoped delete call fails', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-a', false);
|
||||||
|
store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
mockAPI.notifications.delete.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||||
|
mockAPI.notifications.get.mockResolvedValue({ notifications: [] });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('trigger-a');
|
||||||
|
|
||||||
|
expect(mockAPI.notifications.get).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly recalculates unreadCount after scoped clear', async () => {
|
||||||
|
const n1 = makeNotification('n1', 'trigger-a', false);
|
||||||
|
const n2 = makeNotification('n2', 'trigger-b', false);
|
||||||
|
const n3 = makeNotification('n3', 'trigger-b', true);
|
||||||
|
store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 2 });
|
||||||
|
|
||||||
|
await store.getState().clearNotifications('trigger-a');
|
||||||
|
|
||||||
|
// n1 removed (trigger-a, unread), n2+n3 remain
|
||||||
|
expect(store.getState().notifications).toHaveLength(2);
|
||||||
|
expect(store.getState().unreadCount).toBe(1); // only n2 is unread
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('navigateToError', () => {
|
describe('navigateToError', () => {
|
||||||
const createMockError = (overrides?: Partial<DetectedError>): DetectedError => ({
|
const createMockError = (overrides?: Partial<DetectedError>): DetectedError => ({
|
||||||
id: 'error-1',
|
id: 'error-1',
|
||||||
|
|
|
||||||
59
test/renderer/utils/renderHelpers.test.ts
Normal file
59
test/renderer/utils/renderHelpers.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
|
||||||
|
|
||||||
|
describe('renderHelpers', () => {
|
||||||
|
describe('extractOutputText', () => {
|
||||||
|
it('should return plain string content as-is', () => {
|
||||||
|
expect(extractOutputText('hello world')).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pretty-print string content that is valid JSON', () => {
|
||||||
|
const json = '{"name":"test","value":42}';
|
||||||
|
expect(extractOutputText(json)).toBe('{\n "name": "test",\n "value": 42\n}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract text from content block arrays', () => {
|
||||||
|
const content = [{ type: 'text', text: 'hello world' }];
|
||||||
|
expect(extractOutputText(content)).toBe('hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract and pretty-print JSON from content block arrays', () => {
|
||||||
|
const inner = { teams: [{ id: '1', name: 'Test' }] };
|
||||||
|
const content = [{ type: 'text', text: JSON.stringify(inner) }];
|
||||||
|
expect(extractOutputText(content)).toBe(JSON.stringify(inner, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle serialized content block arrays (string wrapping content blocks)', () => {
|
||||||
|
// This is what SemanticStepExtractor produces when content is an array
|
||||||
|
const inner = { teams: [{ id: '1', name: 'Test' }] };
|
||||||
|
const contentBlocks = [{ type: 'text', text: JSON.stringify(inner) }];
|
||||||
|
const serialized = JSON.stringify(contentBlocks);
|
||||||
|
|
||||||
|
const result = extractOutputText(serialized);
|
||||||
|
expect(result).toBe(JSON.stringify(inner, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle serialized content blocks with plain text', () => {
|
||||||
|
const contentBlocks = [{ type: 'text', text: 'Some plain text\nwith newlines' }];
|
||||||
|
const serialized = JSON.stringify(contentBlocks);
|
||||||
|
|
||||||
|
const result = extractOutputText(serialized);
|
||||||
|
expect(result).toBe('Some plain text\nwith newlines');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should join multiple content blocks with newlines', () => {
|
||||||
|
const content = [
|
||||||
|
{ type: 'text', text: 'first' },
|
||||||
|
{ type: 'text', text: 'second' },
|
||||||
|
];
|
||||||
|
expect(extractOutputText(content)).toBe('first\nsecond');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stringify non-text content blocks', () => {
|
||||||
|
const content = [{ type: 'image', url: 'http://example.com/img.png' }];
|
||||||
|
const result = extractOutputText(content);
|
||||||
|
expect(result).toContain('"type": "image"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue