From 94f722d993dca4e8aa6cd2f3197cbe7d3bdcba9c Mon Sep 17 00:00:00 2001
From: Sanath Samarasinghe
Date: Thu, 19 Feb 2026 06:03:16 +0100
Subject: [PATCH 01/12] feat: add markdown preview toggle for Write tool (#21)
---
.../chat/items/linkedTool/WriteToolViewer.tsx | 38 ++++++++++++++++++-
1 file changed, 36 insertions(+), 2 deletions(-)
diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx
index 22cd90b1..d08d7005 100644
--- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx
@@ -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 = ({ 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 (
{isCreate ? 'Created file' : 'Wrote to file'}
-
+ {isMarkdownFile && (
+
+
+
+
+ )}
+ {isMarkdownFile && viewMode === 'preview' ? (
+
+ ) : (
+
+ )}
);
};
From 4ec272758cfddf65dcaa8f427f0e974f89adf96f Mon Sep 17 00:00:00 2001
From: Cesar Augusto Fonseca <38327047+cesarafonseca@users.noreply.github.com>
Date: Thu, 19 Feb 2026 02:07:30 -0300
Subject: [PATCH 02/12] fix: collect tool results from subagent messages with
absent isMeta field (#23)
User messages in subagent JSONLs lack the isMeta field, defaulting to false.
An unconditional `continue` in the !isMeta branch skipped tool result
collection for these messages, causing all subagent tools to show
"No result received". Now we check for tool_result blocks before continuing,
allowing them to fall through to the result collection logic.
---
src/renderer/utils/displayItemBuilder.ts | 10 +-
.../renderer/utils/displayItemBuilder.test.ts | 99 +++++++++++++++++++
2 files changed, 106 insertions(+), 3 deletions(-)
create mode 100644 test/renderer/utils/displayItemBuilder.test.ts
diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts
index 953dc645..07b4e786 100644
--- a/src/renderer/utils/displayItemBuilder.ts
+++ b/src/renderer/utils/displayItemBuilder.ts
@@ -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)) {
diff --git a/test/renderer/utils/displayItemBuilder.test.ts b/test/renderer/utils/displayItemBuilder.test.ts
new file mode 100644
index 00000000..1a843713
--- /dev/null
+++ b/test/renderer/utils/displayItemBuilder.test.ts
@@ -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 & Pick): 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');
+ });
+ });
+});
From eeec1e7178cd0aef5aa90c8fb06dcf6236f2dcca Mon Sep 17 00:00:00 2001
From: matt
Date: Fri, 20 Feb 2026 01:26:27 +0900
Subject: [PATCH 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 10/12] 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 11/12] 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 @@
+
+
+
@@ -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 12/12] 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,