From eeec1e7178cd0aef5aa90c8fb06dcf6236f2dcca Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Feb 2026 01:26:27 +0900 Subject: [PATCH 01/10] feat(package): add afterInstall script for chrome-sandbox permissions (#27) - Updated package.json to include an afterInstall script that adjusts permissions for the chrome-sandbox on Linux. - Added new afterInstall.sh script to ensure proper ownership and permissions for the sandbox file, enhancing security and functionality. --- package.json | 3 ++- resources/afterInstall.sh | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100755 resources/afterInstall.sh diff --git a/package.json b/package.json index ac50ab09..4a9093b0 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,8 @@ "pacman" ], "icon": "resources/icons/png", - "category": "Development" + "category": "Development", + "afterInstall": "resources/afterInstall.sh" }, "nsis": { "oneClick": 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 From e570bbebde6971c8c28003afaeaf9051d2bc136b Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Feb 2026 01:27:10 +0900 Subject: [PATCH 02/10] feat(package): add afterInstall script for chrome-sandbox permissions (#27) (#28) - Updated package.json to include an afterInstall script that adjusts permissions for the chrome-sandbox on Linux. - Added new afterInstall.sh script to ensure proper ownership and permissions for the sandbox file, enhancing security and functionality. --- package.json | 3 ++- resources/afterInstall.sh | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100755 resources/afterInstall.sh diff --git a/package.json b/package.json index ac50ab09..4a9093b0 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,8 @@ "pacman" ], "icon": "resources/icons/png", - "category": "Development" + "category": "Development", + "afterInstall": "resources/afterInstall.sh" }, "nsis": { "oneClick": 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 From 7574d0defc88c9b3434a119663aa4e49df528cf0 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Feb 2026 01:37:43 +0900 Subject: [PATCH 03/10] Fix/linux sandbox permissions (#29) * feat(package): add afterInstall script for chrome-sandbox permissions - Updated package.json to include an afterInstall script that adjusts permissions for the chrome-sandbox on Linux. - Added new afterInstall.sh script to ensure proper ownership and permissions for the sandbox file, enhancing security and functionality. * chore(package): add deb configuration section in package.json - Introduced a new "deb" section in package.json to specify post-installation scripts for Debian packages. - Enhanced the package configuration for better support of Linux distributions. --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index 4a9093b0..05ce23be 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,9 @@ "category": "Development", "afterInstall": "resources/afterInstall.sh" }, + "deb": { + "afterInstall": "resources/afterInstall.sh" + }, "nsis": { "oneClick": false, "perMachine": false, From a00c7a459bca6d5e8b332af1371777787af44d46 Mon Sep 17 00:00:00 2001 From: Cesar Augusto Fonseca Date: Thu, 19 Feb 2026 17:33:12 -0300 Subject: [PATCH 04/10] feat: improve MCP tool input/output rendering Closes matt1398/claude-devtools#32 --- .../chat/items/linkedTool/renderHelpers.tsx | 83 +++++++++++++++++-- test/renderer/components/renderOutput.test.ts | 37 +++++++++ test/renderer/utils/renderHelpers.test.ts | 59 +++++++++++++ 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 test/renderer/components/renderOutput.test.ts create mode 100644 test/renderer/utils/renderHelpers.test.ts 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/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/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"');
+    });
+  });
+});

From 0cd75a4f1a6fa4bd93aeb5d8ad16f4eed96e2470 Mon Sep 17 00:00:00 2001
From: matt 
Date: Fri, 20 Feb 2026 12:46:40 +0900
Subject: [PATCH 05/10] feat: enhance notification handling with scoped actions

- Updated `markAllNotificationsRead` and `clearNotifications` functions to support optional filtering by trigger name, allowing for more granular control over notification management.
- Adjusted the `NotificationsView` component to reflect the new scoped functionality, including updates to button labels and unread count displays based on active filters.
- Added tests to verify the behavior of scoped actions for marking notifications as read and clearing notifications.
---
 .../notifications/NotificationsView.tsx       |  44 +++-
 .../store/slices/notificationSlice.ts         |  92 ++++++--
 test/renderer/store/notificationSlice.test.ts | 215 ++++++++++++++++++
 3 files changed, 316 insertions(+), 35 deletions(-)

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/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/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', From 1590f04dc2219864a73b2d704869c3e8326ff9d3 Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Feb 2026 12:53:08 +0900 Subject: [PATCH 06/10] feat: add copy functionality to session context menu - Introduced new options to copy Session ID and Resume Command in the SessionContextMenu component. - Added visual feedback for copied actions with appropriate icons and labels. - Updated menu height to accommodate new items and maintain layout consistency. --- .../components/sidebar/SessionContextMenu.tsx | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) 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')} + />
); }; From 2fcf111f77e9fa14ce58eb7651af390e5d2fb63f Mon Sep 17 00:00:00 2001 From: matt Date: Fri, 20 Feb 2026 13:27:34 +0900 Subject: [PATCH 07/10] feat: enhance ContextBadge and SessionContextPanel with new FlatInjectionList view - Updated ContextBadge to display the total count of tool outputs and task coordination items based on their breakdowns. - Introduced FlatInjectionList component for a denested view of injections, allowing users to toggle between grouped and flat views in SessionContextPanel. - Added state management for flat view toggle and integrated FlatInjectionList into the existing layout. --- src/renderer/components/chat/ContextBadge.tsx | 10 +- .../components/FlatInjectionList.tsx | 250 ++++++++++++++++++ .../chat/SessionContextPanel/index.tsx | 58 +++- src/renderer/utils/contextTracker.ts | 4 +- 4 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx diff --git a/src/renderer/components/chat/ContextBadge.tsx b/src/renderer/components/chat/ContextBadge.tsx index 737ee229..ba62943c 100644 --- a/src/renderer/components/chat/ContextBadge.tsx +++ b/src/renderer/components/chat/ContextBadge.tsx @@ -478,7 +478,10 @@ export const ContextBadge = ({ {newToolOutputInjections.length > 0 && ( sum + inj.toolBreakdown.length, + 0 + )} tokenCount={toolOutputTokens} > {newToolOutputInjections.map((injection) => @@ -501,7 +504,10 @@ export const ContextBadge = ({ {newTaskCoordinationInjections.length > 0 && ( sum + inj.breakdown.length, + 0 + )} tokenCount={taskCoordinationTokens} > {newTaskCoordinationInjections.map((injection) => 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/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts index 021afb5a..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++; + 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++; From 6c20a4d4047433c4be4c17be9033cd59cbb95261 Mon Sep 17 00:00:00 2001 From: Cesar Augusto Fonseca Date: Fri, 20 Feb 2026 11:28:15 -0300 Subject: [PATCH 08/10] fix: prevent false cwd split that hides all sessions Sessions without the cwd field (older JSONL format) were creating a separate subproject group, even when all sessions with cwd shared the same value. The orphan subproject got a relative fallback path that failed git identity resolution, causing zero sessions to load on select. Now only counts distinct real cwds when deciding whether to split, treating cwd-less sessions as belonging to the same project. --- src/main/services/discovery/ProjectScanner.ts | 7 +- .../discovery/ProjectScanner.cwdSplit.test.ts | 105 ++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 test/main/services/discovery/ProjectScanner.cwdSplit.test.ts 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/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('::'); + } + }); +}); From d7755811186402a44167226d36d10893b309bc92 Mon Sep 17 00:00:00 2001 From: matt Date: Sat, 21 Feb 2026 14:13:29 +0900 Subject: [PATCH 09/10] feat: add Homebrew installation instructions and badge to README - Included a new badge for Homebrew installation in the README. - Added Homebrew installation instructions for macOS users to enhance accessibility. --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 | From c5db4e3303d43a1cff9de58172f230697680de51 Mon Sep 17 00:00:00 2001 From: matt Date: Sat, 21 Feb 2026 15:50:59 +0900 Subject: [PATCH 10/10] feat: disable default notification triggers for better control - Updated the default notification triggers to set 'enabled' to false for the .env File Access Alert, Tool Result Error, and High Token Usage triggers, allowing for more controlled notification management. --- src/main/services/infrastructure/TriggerManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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,