Merge upstream/main

This commit is contained in:
iliya 2026-02-21 10:39:25 +02:00
commit ae7a183112
20 changed files with 1120 additions and 65 deletions

View file

@ -43,6 +43,9 @@
</a>&nbsp;&nbsp;
<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>&nbsp;&nbsp;
<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 |

View file

@ -175,6 +175,9 @@
"icon": "resources/icons/png",
"category": "Development"
},
"deb": {
"afterInstall": "resources/afterInstall.sh"
},
"nsis": {
"oneClick": false,
"perMachine": false,

10
resources/afterInstall.sh Executable file
View 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

View file

@ -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();

View file

@ -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,

View file

@ -478,7 +478,10 @@ export const ContextBadge = ({
{newToolOutputInjections.length > 0 && (
<PopoverSection
title="Tool Outputs"
count={newToolOutputInjections.length}
count={newToolOutputInjections.reduce(
(sum, inj) => sum + inj.toolBreakdown.length,
0
)}
tokenCount={toolOutputTokens}
>
{newToolOutputInjections.map((injection) =>
@ -501,7 +504,10 @@ export const ContextBadge = ({
{newTaskCoordinationInjections.length > 0 && (
<PopoverSection
title="Task Coordination"
count={newTaskCoordinationInjections.length}
count={newTaskCoordinationInjections.reduce(
(sum, inj) => sum + inj.breakdown.length,
0
)}
tokenCount={taskCoordinationTokens}
>
{newTaskCoordinationInjections.map((injection) =>

View file

@ -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>
);
};

View file

@ -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>

View file

@ -6,7 +6,7 @@
import React from 'react';
import { CodeBlockViewer } from '@renderer/components/chat/viewers';
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
import type { LinkedToolItem } from '@renderer/types/groups';
@ -20,13 +20,47 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool })
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || '';
const isCreate = toolUseResult?.type === 'create';
const isMarkdownFile = /\.mdx?$/i.test(filePath);
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
return (
<div className="space-y-2">
<div className="mb-1 text-xs text-zinc-500">
{isCreate ? 'Created file' : 'Wrote to file'}
</div>
<CodeBlockViewer fileName={filePath} content={content} startLine={1} />
{isMarkdownFile && (
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setViewMode('code')}
className="rounded px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: viewMode === 'code' ? 'var(--tag-bg)' : 'transparent',
color: viewMode === 'code' ? 'var(--tag-text)' : 'var(--color-text-muted)',
border: '1px solid var(--tag-border)',
}}
>
Code
</button>
<button
type="button"
onClick={() => setViewMode('preview')}
className="rounded px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: viewMode === 'preview' ? 'var(--tag-bg)' : 'transparent',
color: viewMode === 'preview' ? 'var(--tag-text)' : 'var(--color-text-muted)',
border: '1px solid var(--tag-border)',
}}
>
Preview
</button>
</div>
)}
{isMarkdownFile && viewMode === 'preview' ? (
<MarkdownViewer content={content} label="Markdown Preview" copyable />
) : (
<CodeBlockViewer fileName={filePath} content={content} startLine={1} />
)}
</div>
);
};

View file

@ -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}

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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);
}

View file

@ -836,13 +836,13 @@ function computeContextStats(params: ComputeContextStatsParams): ContextStats {
newCounts.mentionedFiles++;
break;
case 'tool-output':
newCounts.toolOutputs++;
newCounts.toolOutputs += injection.toolCount;
break;
case 'thinking-text':
newCounts.thinkingText++;
break;
case 'task-coordination':
newCounts.taskCoordination++;
newCounts.taskCoordination += injection.breakdown.length;
break;
case 'user-message':
newCounts.userMessages++;

View file

@ -423,16 +423,20 @@ export function buildDisplayItemsFromMessages(
}
continue;
}
// Plain-text user message (subagent input prompt)
if (rawText.trim()) {
// Only treat as subagent input if there are NO tool_result blocks in this message
const hasToolResults =
Array.isArray(msg.content) &&
msg.content.some((b) => b.type === 'tool_result');
if (rawText.trim() && !hasToolResults) {
displayItems.push({
type: 'subagent_input',
content: rawText.trim(),
timestamp: msgTimestamp,
tokenCount: estimateTokens(rawText),
});
continue;
}
continue;
// Fall through to tool result processing below if message has tool_results
}
if (msg.type === 'assistant' && Array.isArray(msg.content)) {

View 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('::');
}
});
});

View 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));
});
});

View file

@ -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',

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import { buildDisplayItemsFromMessages } from '../../../src/renderer/utils/displayItemBuilder';
import type { ParsedMessage } from '../../../src/main/types/messages';
/**
* Helper to create a minimal ParsedMessage for testing.
*/
function makeMessage(overrides: Partial<ParsedMessage> & Pick<ParsedMessage, 'type' | 'content'>): ParsedMessage {
return {
uuid: `msg-${Math.random().toString(36).slice(2, 8)}`,
parentUuid: null,
timestamp: new Date('2025-01-01T00:00:00Z'),
isMeta: false,
isSidechain: false,
toolCalls: [],
toolResults: [],
...overrides,
} as ParsedMessage;
}
describe('buildDisplayItemsFromMessages', () => {
describe('subagent tool results with isMeta=false', () => {
it('should collect tool results from user messages without isMeta field', () => {
// Simulates real subagent JSONL where user messages with tool_result
// blocks have isMeta absent (defaults to false after parsing).
const toolUseId = 'toolu_test123';
const assistantMsg = makeMessage({
uuid: 'assistant-1',
type: 'assistant',
content: [
{
type: 'tool_use',
id: toolUseId,
name: 'Bash',
input: { command: 'echo hello' },
},
],
timestamp: new Date('2025-01-01T00:00:00Z'),
});
// This is the key scenario: user message with tool_result but isMeta: false
// (simulating subagent JSONL where isMeta field is absent)
const toolResultMsg = makeMessage({
uuid: 'user-result-1',
type: 'user',
isMeta: false,
content: [
{
type: 'tool_result',
tool_use_id: toolUseId,
content: 'hello\n',
is_error: false,
},
],
toolResults: [
{
toolUseId: toolUseId,
content: 'hello\n',
isError: false,
},
],
timestamp: new Date('2025-01-01T00:00:01Z'),
});
const items = buildDisplayItemsFromMessages([assistantMsg, toolResultMsg], []);
const toolItems = items.filter((item) => item.type === 'tool');
expect(toolItems).toHaveLength(1);
const tool = toolItems[0];
if (tool.type !== 'tool') throw new Error('Expected tool item');
// The critical assertion: result must be present, not orphaned
expect(tool.tool.isOrphaned).toBe(false);
expect(tool.tool.result).toBeDefined();
expect(tool.tool.result?.content).toBe('hello\n');
expect(tool.tool.name).toBe('Bash');
});
it('should still render subagent_input for plain text user messages without tool results', () => {
const userMsg = makeMessage({
uuid: 'user-input-1',
type: 'user',
isMeta: false,
content: 'Please run the tests',
toolResults: [],
timestamp: new Date('2025-01-01T00:00:00Z'),
});
const items = buildDisplayItemsFromMessages([userMsg], []);
const inputItems = items.filter((item) => item.type === 'subagent_input');
expect(inputItems).toHaveLength(1);
if (inputItems[0].type !== 'subagent_input') throw new Error('Expected subagent_input');
expect(inputItems[0].content).toBe('Please run the tests');
});
});
});

View 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"');
});
});
});