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 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" />
|
||||
</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>
|
||||
</p>
|
||||
|
||||
|
|
@ -62,6 +65,14 @@
|
|||
|
||||
## Installation
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew install --cask claude-devtools
|
||||
```
|
||||
|
||||
### Direct Download
|
||||
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -163,6 +163,9 @@
|
|||
"icon": "resources/icons/png",
|
||||
"category": "Development"
|
||||
},
|
||||
"deb": {
|
||||
"afterInstall": "resources/afterInstall.sh"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": 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);
|
||||
}
|
||||
|
||||
// If only 1 unique cwd, return single project (current behavior)
|
||||
if (cwdGroups.size <= 1) {
|
||||
// If only 1 unique real cwd, return single project (current behavior)
|
||||
// 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);
|
||||
let mostRecentSession: number | undefined;
|
||||
let createdAt = Date.now();
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
|||
{
|
||||
id: 'builtin-bash-command',
|
||||
name: '.env File Access Alert',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
contentType: 'tool_use',
|
||||
mode: 'content_match',
|
||||
matchPattern: '/.env',
|
||||
|
|
@ -41,7 +41,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
|||
{
|
||||
id: 'builtin-tool-result-error',
|
||||
name: 'Tool Result Error',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
contentType: 'tool_result',
|
||||
mode: 'error_status',
|
||||
requireError: true,
|
||||
|
|
@ -55,7 +55,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
|||
{
|
||||
id: 'builtin-high-token-usage',
|
||||
name: 'High Token Usage',
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
contentType: 'tool_result',
|
||||
mode: 'token_threshold',
|
||||
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 { 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 { FlatInjectionList } from './components/FlatInjectionList';
|
||||
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
||||
import { RankedInjectionList } from './components/RankedInjectionList';
|
||||
import { SessionContextHeader } from './components/SessionContextHeader';
|
||||
|
|
@ -46,8 +52,10 @@ export const SessionContextPanel = ({
|
|||
selectedPhase,
|
||||
onPhaseChange,
|
||||
}: 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');
|
||||
// Flat sub-toggle within "By Size" view
|
||||
const [flatMode, setFlatMode] = useState(false);
|
||||
|
||||
// Track which main sections are expanded
|
||||
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
||||
|
|
@ -252,12 +260,46 @@ export const SessionContextPanel = ({
|
|||
/>
|
||||
</>
|
||||
) : (
|
||||
<RankedInjectionList
|
||||
injections={injections}
|
||||
onNavigateToTurn={onNavigateToTurn}
|
||||
onNavigateToTool={onNavigateToTool}
|
||||
onNavigateToUserGroup={onNavigateToUserGroup}
|
||||
/>
|
||||
<>
|
||||
{/* Grouped / Flat sub-toggle */}
|
||||
<div className="flex items-center gap-1 pb-1">
|
||||
<button
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
||||
{JSON.stringify(input, null, 2)}
|
||||
</pre>
|
||||
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
||||
{Object.entries(input).map(([key, value]) => (
|
||||
<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.
|
||||
*/
|
||||
/**
|
||||
* 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 {
|
||||
const displayText = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
const displayText = extractOutputText(content);
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
||||
{displayText}
|
||||
|
|
|
|||
|
|
@ -128,15 +128,21 @@ export const NotificationsView = (): React.JSX.Element => {
|
|||
rowVirtualizer.scrollToIndex(0);
|
||||
}, [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> => {
|
||||
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> => {
|
||||
if (showClearConfirm) {
|
||||
await clearNotifications();
|
||||
await clearNotifications(activeFilter ?? undefined);
|
||||
setShowClearConfirm(false);
|
||||
} else {
|
||||
setShowClearConfirm(true);
|
||||
|
|
@ -205,7 +211,13 @@ export const NotificationsView = (): React.JSX.Element => {
|
|||
</span>
|
||||
{notifications.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -213,19 +225,21 @@ export const NotificationsView = (): React.JSX.Element => {
|
|||
{/* Action Buttons */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mark all read */}
|
||||
{unreadCount > 0 && (
|
||||
{/* Mark all/filtered read */}
|
||||
{filteredUnreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
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)' }}
|
||||
title="Mark all as read"
|
||||
title={activeFilter !== null ? 'Mark filtered as read' : 'Mark all as read'}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{/* Clear all */}
|
||||
{/* Clear all/filtered */}
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
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'
|
||||
}`}
|
||||
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" />
|
||||
<span className="hidden sm:inline">
|
||||
{showClearConfirm ? 'Click to confirm' : 'Clear all'}
|
||||
{showClearConfirm
|
||||
? 'Click to confirm'
|
||||
: activeFilter !== null
|
||||
? 'Clear filtered'
|
||||
: 'Clear all'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
* 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 { Eye, EyeOff, Pin, PinOff } from 'lucide-react';
|
||||
import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react';
|
||||
|
||||
interface SessionContextMenuProps {
|
||||
x: number;
|
||||
|
|
@ -29,6 +29,7 @@ interface SessionContextMenuProps {
|
|||
export const SessionContextMenu = ({
|
||||
x,
|
||||
y,
|
||||
sessionId,
|
||||
paneCount,
|
||||
isPinned,
|
||||
isHidden,
|
||||
|
|
@ -40,6 +41,7 @@ export const SessionContextMenu = ({
|
|||
onToggleHide,
|
||||
}: SessionContextMenuProps): React.JSX.Element => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [copiedField, setCopiedField] = useState<'id' | 'command' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (e: MouseEvent): void => {
|
||||
|
|
@ -59,7 +61,7 @@ export const SessionContextMenu = ({
|
|||
}, [onClose]);
|
||||
|
||||
const menuWidth = 240;
|
||||
const menuHeight = 204;
|
||||
const menuHeight = 290;
|
||||
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
||||
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
||||
|
||||
|
|
@ -68,6 +70,19 @@ export const SessionContextMenu = ({
|
|||
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;
|
||||
|
||||
return (
|
||||
|
|
@ -101,6 +116,29 @@ export const SessionContextMenu = ({
|
|||
icon={isHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ export interface NotificationSlice {
|
|||
// Actions
|
||||
fetchNotifications: () => Promise<void>;
|
||||
markNotificationRead: (id: string) => Promise<void>;
|
||||
markAllNotificationsRead: () => Promise<void>;
|
||||
markAllNotificationsRead: (triggerName?: string) => Promise<void>;
|
||||
deleteNotification: (id: string) => Promise<void>;
|
||||
clearNotifications: () => Promise<void>;
|
||||
clearNotifications: (triggerName?: string) => Promise<void>;
|
||||
navigateToError: (error: DetectedError) => void;
|
||||
openNotificationsTab: () => void;
|
||||
}
|
||||
|
|
@ -99,19 +99,41 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
}
|
||||
},
|
||||
|
||||
// Mark all notifications as read
|
||||
markAllNotificationsRead: async () => {
|
||||
// Mark all notifications as read (optionally scoped to a trigger)
|
||||
markAllNotificationsRead: async (triggerName?: string) => {
|
||||
try {
|
||||
const success = await api.notifications.markAllRead();
|
||||
if (!success) {
|
||||
await get().fetchNotifications();
|
||||
return;
|
||||
if (triggerName !== undefined) {
|
||||
// Scoped: mark only matching unread notifications
|
||||
const { notifications } = get();
|
||||
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) {
|
||||
logger.error('Failed to mark all notifications as read:', error);
|
||||
}
|
||||
|
|
@ -136,18 +158,42 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
|||
}
|
||||
},
|
||||
|
||||
// Clear all notifications
|
||||
clearNotifications: async () => {
|
||||
// Clear all notifications (optionally scoped to a trigger)
|
||||
clearNotifications: async (triggerName?: string) => {
|
||||
try {
|
||||
const success = await api.notifications.clear();
|
||||
if (!success) {
|
||||
await get().fetchNotifications();
|
||||
return;
|
||||
if (triggerName !== undefined) {
|
||||
// Scoped: delete only matching notifications
|
||||
const { notifications } = get();
|
||||
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) {
|
||||
logger.error('Failed to clear notifications:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -836,13 +836,13 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
|
|||
newCounts.mentionedFiles++;
|
||||
break;
|
||||
case 'tool-output':
|
||||
newCounts.toolOutputs += (injection).toolCount;
|
||||
newCounts.toolOutputs += injection.toolCount;
|
||||
break;
|
||||
case 'thinking-text':
|
||||
newCounts.thinkingText++;
|
||||
break;
|
||||
case 'task-coordination':
|
||||
newCounts.taskCoordination += (injection).breakdown.length;
|
||||
newCounts.taskCoordination += injection.breakdown.length;
|
||||
break;
|
||||
case 'user-message':
|
||||
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', () => {
|
||||
const createMockError = (overrides?: Partial<DetectedError>): DetectedError => ({
|
||||
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