diff --git a/README.md b/README.md index d47e53a4..12542ea6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@    Deploy with Docker +    + + Install with Homebrew

@@ -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 | diff --git a/package.json b/package.json index ac50ab09..ee829eca 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,9 @@ "icon": "resources/icons/png", "category": "Development" }, + "deb": { + "afterInstall": "resources/afterInstall.sh" + }, "nsis": { "oneClick": false, "perMachine": false, diff --git a/resources/afterInstall.sh b/resources/afterInstall.sh new file mode 100755 index 00000000..4f472b3a --- /dev/null +++ b/resources/afterInstall.sh @@ -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 diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 90884236..31e00af9 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -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(); diff --git a/src/main/services/infrastructure/TriggerManager.ts b/src/main/services/infrastructure/TriggerManager.ts index b1ad8961..51374e8d 100644 --- a/src/main/services/infrastructure/TriggerManager.ts +++ b/src/main/services/infrastructure/TriggerManager.ts @@ -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, diff --git a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx new file mode 100644 index 00000000..cb2d9e39 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx @@ -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 = { + '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): React.ReactElement => { + const rows = useMemo(() => flattenInjections(injections), [injections]); + + return ( +
+ {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 ( +
+ + {/* Copy path button for CLAUDE.md and File items */} + {row.copyPath && ( + + + + )} +
+ ); + })} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx index 28c540e2..19f8cc79 100644 --- a/src/renderer/components/chat/SessionContextPanel/index.tsx +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -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): React.ReactElement => { - // View mode: category sections or flat ranked list + // View mode: category sections or ranked list const [viewMode, setViewMode] = useState('category'); + // Flat sub-toggle within "By Size" view + const [flatMode, setFlatMode] = useState(false); // Track which main sections are expanded const [expandedSections, setExpandedSections] = useState>( @@ -252,12 +260,46 @@ export const SessionContextPanel = ({ /> ) : ( - + <> + {/* Grouped / Flat sub-toggle */} +
+ + +
+ {flatMode ? ( + + ) : ( + + )} + )} diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 634ee641..4b64eed4 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -95,19 +95,92 @@ export function renderInput(toolName: string, input: Record): R ); } - // Default: JSON format + // Default: key-value format with readable string values return ( -
-      {JSON.stringify(input, null, 2)}
-    
+
+ {Object.entries(input).map(([key, value]) => ( +
+
+ {key} +
+
{formatInputValue(value)}
+
+ ))} +
); } +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).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 (
       {displayText}
diff --git a/src/renderer/components/notifications/NotificationsView.tsx b/src/renderer/components/notifications/NotificationsView.tsx
index e7303e0b..839cd45b 100644
--- a/src/renderer/components/notifications/NotificationsView.tsx
+++ b/src/renderer/components/notifications/NotificationsView.tsx
@@ -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 => {
-    await markAllNotificationsRead();
+    await markAllNotificationsRead(activeFilter ?? undefined);
   };
 
-  // Handle clear all with confirmation
+  // Handle clear all with confirmation (scoped to active filter)
   const handleClearAll = async (): Promise => {
     if (showClearConfirm) {
-      await clearNotifications();
+      await clearNotifications(activeFilter ?? undefined);
       setShowClearConfirm(false);
     } else {
       setShowClearConfirm(true);
@@ -205,7 +211,13 @@ export const NotificationsView = (): React.JSX.Element => {
             
             {notifications.length > 0 && (
               
-                {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`}
               
             )}
           
@@ -213,19 +225,21 @@ export const NotificationsView = (): React.JSX.Element => {
           {/* Action Buttons */}
           {notifications.length > 0 && (
             
- {/* Mark all read */} - {unreadCount > 0 && ( + {/* Mark all/filtered read */} + {filteredUnreadCount > 0 && ( )} - {/* Clear all */} + {/* Clear all/filtered */}
diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx index 9aa1073b..4adbf6ee 100644 --- a/src/renderer/components/sidebar/SessionContextMenu.tsx +++ b/src/renderer/components/sidebar/SessionContextMenu.tsx @@ -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(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 ? : } onClick={handleClick(onToggleHide)} /> +
+ + ) : ( + + ) + } + onClick={handleCopy(sessionId, 'id')} + /> + + ) : ( + + ) + } + onClick={handleCopy(`claude --resume ${sessionId}`, 'command')} + />
); }; diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts index e4e00541..320c71a7 100644 --- a/src/renderer/store/slices/notificationSlice.ts +++ b/src/renderer/store/slices/notificationSlice.ts @@ -29,9 +29,9 @@ export interface NotificationSlice { // Actions fetchNotifications: () => Promise; markNotificationRead: (id: string) => Promise; - markAllNotificationsRead: () => Promise; + markAllNotificationsRead: (triggerName?: string) => Promise; deleteNotification: (id: string) => Promise; - clearNotifications: () => Promise; + clearNotifications: (triggerName?: string) => Promise; navigateToError: (error: DetectedError) => void; openNotificationsTab: () => void; } @@ -99,19 +99,41 @@ export const createNotificationSlice: StateCreator { + // 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 { + // 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); } diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index d9756145..78eb4986 100644 --- a/src/renderer/utils/contextTracker.ts +++ b/src/renderer/utils/contextTracker.ts @@ -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++; diff --git a/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts new file mode 100644 index 00000000..5a851846 --- /dev/null +++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts @@ -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('::'); + } + }); +}); diff --git a/test/renderer/components/renderOutput.test.ts b/test/renderer/components/renderOutput.test.ts new file mode 100644 index 00000000..da52e9e0 --- /dev/null +++ b/test/renderer/components/renderOutput.test.ts @@ -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)); + }); +}); diff --git a/test/renderer/store/notificationSlice.test.ts b/test/renderer/store/notificationSlice.test.ts index f96f06e0..5ffb106d 100644 --- a/test/renderer/store/notificationSlice.test.ts +++ b/test/renderer/store/notificationSlice.test.ts @@ -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 => ({ id: 'error-1', diff --git a/test/renderer/utils/renderHelpers.test.ts b/test/renderer/utils/renderHelpers.test.ts new file mode 100644 index 00000000..f1aaa35d --- /dev/null +++ b/test/renderer/utils/renderHelpers.test.ts @@ -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"'); + }); + }); +});