diff --git a/README.md b/README.md
index d47e53a4..12542ea6 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,9 @@
+
+
+
- {JSON.stringify(input, null, 2)}
-
+ {formatInputValue(value)}
+
{displayText}
diff --git a/src/renderer/components/notifications/NotificationsView.tsx b/src/renderer/components/notifications/NotificationsView.tsx
index e7303e0b..839cd45b 100644
--- a/src/renderer/components/notifications/NotificationsView.tsx
+++ b/src/renderer/components/notifications/NotificationsView.tsx
@@ -128,15 +128,21 @@ export const NotificationsView = (): React.JSX.Element => {
rowVirtualizer.scrollToIndex(0);
}, [activeFilter, rowVirtualizer]);
- // Handle mark all read
+ // Derive filtered unread count for scoped button visibility
+ const filteredUnreadCount = useMemo(() => {
+ if (activeFilter === null) return unreadCount;
+ return filteredNotifications.filter((n) => !n.isRead).length;
+ }, [activeFilter, filteredNotifications, unreadCount]);
+
+ // Handle mark all read (scoped to active filter)
const handleMarkAllRead = async (): Promise => {
- await markAllNotificationsRead();
+ await markAllNotificationsRead(activeFilter ?? undefined);
};
- // Handle clear all with confirmation
+ // Handle clear all with confirmation (scoped to active filter)
const handleClearAll = async (): Promise => {
if (showClearConfirm) {
- await clearNotifications();
+ await clearNotifications(activeFilter ?? undefined);
setShowClearConfirm(false);
} else {
setShowClearConfirm(true);
@@ -205,7 +211,13 @@ export const NotificationsView = (): React.JSX.Element => {
{notifications.length > 0 && (
- {unreadCount > 0 ? `${unreadCount} unread` : `${notifications.length} total`}
+ {activeFilter !== null
+ ? filteredUnreadCount > 0
+ ? `${filteredUnreadCount} unread in filter`
+ : `${filteredNotifications.length} in filter`
+ : unreadCount > 0
+ ? `${unreadCount} unread`
+ : `${notifications.length} total`}
)}
@@ -213,19 +225,21 @@ export const NotificationsView = (): React.JSX.Element => {
{/* Action Buttons */}
{notifications.length > 0 && (
- {/* Mark all read */}
- {unreadCount > 0 && (
+ {/* Mark all/filtered read */}
+ {filteredUnreadCount > 0 && (
)}
- {/* Clear all */}
+ {/* Clear all/filtered */}
diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx
index 9aa1073b..4adbf6ee 100644
--- a/src/renderer/components/sidebar/SessionContextMenu.tsx
+++ b/src/renderer/components/sidebar/SessionContextMenu.tsx
@@ -4,10 +4,10 @@
* Shows keyboard shortcut hints for actions that have them.
*/
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { MAX_PANES } from '@renderer/types/panes';
-import { Eye, EyeOff, Pin, PinOff } from 'lucide-react';
+import { Check, ClipboardCopy, Eye, EyeOff, Pin, PinOff, Terminal } from 'lucide-react';
interface SessionContextMenuProps {
x: number;
@@ -29,6 +29,7 @@ interface SessionContextMenuProps {
export const SessionContextMenu = ({
x,
y,
+ sessionId,
paneCount,
isPinned,
isHidden,
@@ -40,6 +41,7 @@ export const SessionContextMenu = ({
onToggleHide,
}: SessionContextMenuProps): React.JSX.Element => {
const menuRef = useRef(null);
+ const [copiedField, setCopiedField] = useState<'id' | 'command' | null>(null);
useEffect(() => {
const handleMouseDown = (e: MouseEvent): void => {
@@ -59,7 +61,7 @@ export const SessionContextMenu = ({
}, [onClose]);
const menuWidth = 240;
- const menuHeight = 204;
+ const menuHeight = 290;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
@@ -68,6 +70,19 @@ export const SessionContextMenu = ({
onClose();
};
+ const handleCopy = (text: string, field: 'id' | 'command') => async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopiedField(field);
+ setTimeout(() => {
+ setCopiedField(null);
+ onClose();
+ }, 600);
+ } catch {
+ // Silently fail
+ }
+ };
+
const atMaxPanes = paneCount >= MAX_PANES;
return (
@@ -101,6 +116,29 @@ export const SessionContextMenu = ({
icon={isHidden ? : }
onClick={handleClick(onToggleHide)}
/>
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleCopy(sessionId, 'id')}
+ />
+
+ ) : (
+
+ )
+ }
+ onClick={handleCopy(`claude --resume ${sessionId}`, 'command')}
+ />
);
};
diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts
index e4e00541..320c71a7 100644
--- a/src/renderer/store/slices/notificationSlice.ts
+++ b/src/renderer/store/slices/notificationSlice.ts
@@ -29,9 +29,9 @@ export interface NotificationSlice {
// Actions
fetchNotifications: () => Promise;
markNotificationRead: (id: string) => Promise;
- markAllNotificationsRead: () => Promise;
+ markAllNotificationsRead: (triggerName?: string) => Promise;
deleteNotification: (id: string) => Promise;
- clearNotifications: () => Promise;
+ clearNotifications: (triggerName?: string) => Promise;
navigateToError: (error: DetectedError) => void;
openNotificationsTab: () => void;
}
@@ -99,19 +99,41 @@ export const createNotificationSlice: StateCreator {
+ // Mark all notifications as read (optionally scoped to a trigger)
+ markAllNotificationsRead: async (triggerName?: string) => {
try {
- const success = await api.notifications.markAllRead();
- if (!success) {
- await get().fetchNotifications();
- return;
+ if (triggerName !== undefined) {
+ // Scoped: mark only matching unread notifications
+ const { notifications } = get();
+ const matching = notifications.filter((n) => {
+ const label = n.triggerName ?? 'Other';
+ return label === triggerName && !n.isRead;
+ });
+ if (matching.length === 0) return;
+ const results = await Promise.all(matching.map((n) => api.notifications.markRead(n.id)));
+ if (results.some((r) => !r)) {
+ await get().fetchNotifications();
+ return;
+ }
+ const matchingIds = new Set(matching.map((n) => n.id));
+ set((state) => {
+ const updated = state.notifications.map((n) =>
+ matchingIds.has(n.id) ? { ...n, isRead: true } : n
+ );
+ return { notifications: updated, unreadCount: updated.filter((n) => !n.isRead).length };
+ });
+ } else {
+ // Unscoped: mark all
+ const success = await api.notifications.markAllRead();
+ if (!success) {
+ await get().fetchNotifications();
+ return;
+ }
+ set((state) => ({
+ notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
+ unreadCount: 0,
+ }));
}
- // Optimistically update local state
- set((state) => ({
- notifications: state.notifications.map((n) => ({ ...n, isRead: true })),
- unreadCount: 0,
- }));
} catch (error) {
logger.error('Failed to mark all notifications as read:', error);
}
@@ -136,18 +158,42 @@ export const createNotificationSlice: StateCreator {
+ // Clear all notifications (optionally scoped to a trigger)
+ clearNotifications: async (triggerName?: string) => {
try {
- const success = await api.notifications.clear();
- if (!success) {
- await get().fetchNotifications();
- return;
+ if (triggerName !== undefined) {
+ // Scoped: delete only matching notifications
+ const { notifications } = get();
+ const matching = notifications.filter((n) => {
+ const label = n.triggerName ?? 'Other';
+ return label === triggerName;
+ });
+ if (matching.length === 0) return;
+ const results = await Promise.all(matching.map((n) => api.notifications.delete(n.id)));
+ if (results.some((r) => !r)) {
+ await get().fetchNotifications();
+ return;
+ }
+ const matchingIds = new Set(matching.map((n) => n.id));
+ set((state) => {
+ const remaining = state.notifications.filter((n) => !matchingIds.has(n.id));
+ return {
+ notifications: remaining,
+ unreadCount: remaining.filter((n) => !n.isRead).length,
+ };
+ });
+ } else {
+ // Unscoped: clear all
+ const success = await api.notifications.clear();
+ if (!success) {
+ await get().fetchNotifications();
+ return;
+ }
+ set({
+ notifications: [],
+ unreadCount: 0,
+ });
}
- set({
- notifications: [],
- unreadCount: 0,
- });
} catch (error) {
logger.error('Failed to clear notifications:', error);
}
diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts
index 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++;
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/main/services/discovery/ProjectScanner.cwdSplit.test.ts b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
new file mode 100644
index 00000000..5a851846
--- /dev/null
+++ b/test/main/services/discovery/ProjectScanner.cwdSplit.test.ts
@@ -0,0 +1,105 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner';
+import { subprojectRegistry } from '../../../../src/main/services/discovery/SubprojectRegistry';
+
+function createSessionLine(opts: { cwd?: string; type?: string }): string {
+ return JSON.stringify({
+ uuid: 'test-uuid',
+ type: opts.type ?? 'user',
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
+ message: { role: 'user', content: 'hello' },
+ timestamp: new Date().toISOString(),
+ });
+}
+
+describe('ProjectScanner cwd split logic', () => {
+ const tempDirs: string[] = [];
+
+ afterEach(async () => {
+ subprojectRegistry.clear();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ for (const dir of tempDirs) {
+ try {
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+ } catch {
+ // Ignore cleanup failures
+ }
+ }
+ tempDirs.length = 0;
+ });
+
+ it('does not split when sessions have a single cwd mixed with sessions without cwd', async () => {
+ const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
+ tempDirs.push(projectsDir);
+
+ // Create a project directory with encoded name
+ const encodedName = '-Users-test-myproject';
+ const projectDir = path.join(projectsDir, encodedName);
+ fs.mkdirSync(projectDir);
+
+ // Session WITH cwd
+ fs.writeFileSync(
+ path.join(projectDir, 'session-with-cwd.jsonl'),
+ createSessionLine({ cwd: '/Users/test/myproject' }) + '\n'
+ );
+
+ // Session WITHOUT cwd (older format)
+ fs.writeFileSync(
+ path.join(projectDir, 'session-no-cwd.jsonl'),
+ createSessionLine({ type: 'system' }) +
+ '\n' +
+ createSessionLine({ type: 'user' }) +
+ '\n'
+ );
+
+ const scanner = new ProjectScanner(projectsDir);
+ const projects = await scanner.scan();
+
+ // Should produce 1 project, not 2 subprojects
+ const myProjects = projects.filter((p) => p.id.includes('myproject'));
+ expect(myProjects).toHaveLength(1);
+
+ // Should use the plain encoded name, not a composite ID
+ expect(myProjects[0].id).toBe(encodedName);
+
+ // Should include both sessions
+ expect(myProjects[0].sessions).toHaveLength(2);
+ });
+
+ it('splits when sessions have multiple distinct cwds', async () => {
+ const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'));
+ tempDirs.push(projectsDir);
+
+ const encodedName = '-Users-test-myproject';
+ const projectDir = path.join(projectsDir, encodedName);
+ fs.mkdirSync(projectDir);
+
+ // Session with cwd A
+ fs.writeFileSync(
+ path.join(projectDir, 'session-a.jsonl'),
+ createSessionLine({ cwd: '/Users/test/myproject' }) + '\n'
+ );
+
+ // Session with cwd B (different)
+ fs.writeFileSync(
+ path.join(projectDir, 'session-b.jsonl'),
+ createSessionLine({ cwd: '/Users/test/other-project' }) + '\n'
+ );
+
+ const scanner = new ProjectScanner(projectsDir);
+ const projects = await scanner.scan();
+
+ // Should produce 2 subprojects with composite IDs
+ const myProjects = projects.filter((p) => p.id.includes('myproject'));
+ expect(myProjects).toHaveLength(2);
+
+ // Both should be composite IDs
+ for (const proj of myProjects) {
+ expect(proj.id).toContain('::');
+ }
+ });
+});
diff --git a/test/renderer/components/renderOutput.test.ts b/test/renderer/components/renderOutput.test.ts
new file mode 100644
index 00000000..da52e9e0
--- /dev/null
+++ b/test/renderer/components/renderOutput.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+
+import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
+
+describe('extractOutputText', () => {
+ it('should return plain string as-is', () => {
+ expect(extractOutputText('hello world')).toBe('hello world');
+ });
+
+ it('should pretty-print a plain string that is valid JSON', () => {
+ expect(extractOutputText('{"key":"value"}')).toBe(JSON.stringify({ key: 'value' }, null, 2));
+ });
+
+ it('should extract text from content blocks with plain text', () => {
+ expect(extractOutputText([{ type: 'text', text: 'plain text' }])).toBe('plain text');
+ });
+
+ it('should extract and pretty-print JSON from content blocks', () => {
+ expect(extractOutputText([{ type: 'text', text: '{"key":"value"}' }])).toBe(
+ JSON.stringify({ key: 'value' }, null, 2),
+ );
+ });
+
+ it('should concatenate multiple content blocks with newline', () => {
+ expect(
+ extractOutputText([
+ { type: 'text', text: 'line one' },
+ { type: 'text', text: 'line two' },
+ ]),
+ ).toBe('line one\nline two');
+ });
+
+ it('should fallback to stringify for blocks without text field', () => {
+ const block = { type: 'image', url: 'http://example.com/img.png' };
+ expect(extractOutputText([block])).toBe(JSON.stringify(block, null, 2));
+ });
+});
diff --git a/test/renderer/store/notificationSlice.test.ts b/test/renderer/store/notificationSlice.test.ts
index f96f06e0..5ffb106d 100644
--- a/test/renderer/store/notificationSlice.test.ts
+++ b/test/renderer/store/notificationSlice.test.ts
@@ -70,6 +70,221 @@ describe('notificationSlice', () => {
});
});
+ describe('scoped markAllNotificationsRead', () => {
+ const makeNotification = (
+ id: string,
+ triggerName: string | undefined,
+ isRead: boolean
+ ): DetectedError => ({
+ id,
+ sessionId: 's1',
+ projectId: 'p1',
+ lineNumber: 1,
+ timestamp: Date.now(),
+ triggerName,
+ severity: 'error',
+ message: `msg-${id}`,
+ isRead,
+ });
+
+ it('marks only matching trigger notifications as read', async () => {
+ const n1 = makeNotification('n1', 'tool result error', false);
+ const n2 = makeNotification('n2', 'high token usage', false);
+ const n3 = makeNotification('n3', 'tool result error', false);
+ store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 3 });
+
+ await store.getState().markAllNotificationsRead('tool result error');
+
+ const state = store.getState();
+ expect(state.notifications.find((n) => n.id === 'n1')!.isRead).toBe(true);
+ expect(state.notifications.find((n) => n.id === 'n2')!.isRead).toBe(false);
+ expect(state.notifications.find((n) => n.id === 'n3')!.isRead).toBe(true);
+ expect(state.unreadCount).toBe(1);
+ });
+
+ it('calls markRead individually for each matching notification', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ const n2 = makeNotification('n2', 'trigger-a', false);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
+
+ await store.getState().markAllNotificationsRead('trigger-a');
+
+ expect(mockAPI.notifications.markRead).toHaveBeenCalledWith('n1');
+ expect(mockAPI.notifications.markRead).toHaveBeenCalledWith('n2');
+ expect(mockAPI.notifications.markAllRead).not.toHaveBeenCalled();
+ });
+
+ it('uses markAllRead API when no triggerName is provided', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ store.setState({ notifications: [n1] as never[], unreadCount: 1 });
+
+ await store.getState().markAllNotificationsRead();
+
+ expect(mockAPI.notifications.markAllRead).toHaveBeenCalled();
+ expect(mockAPI.notifications.markRead).not.toHaveBeenCalled();
+ });
+
+ it('treats notifications without triggerName as "Other"', async () => {
+ const n1 = makeNotification('n1', undefined, false);
+ const n2 = makeNotification('n2', 'trigger-a', false);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
+
+ await store.getState().markAllNotificationsRead('Other');
+
+ expect(store.getState().notifications.find((n) => n.id === 'n1')!.isRead).toBe(true);
+ expect(store.getState().notifications.find((n) => n.id === 'n2')!.isRead).toBe(false);
+ expect(store.getState().unreadCount).toBe(1);
+ });
+
+ it('skips already-read notifications in scoped mode', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', true);
+ const n2 = makeNotification('n2', 'trigger-a', false);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });
+
+ await store.getState().markAllNotificationsRead('trigger-a');
+
+ // Only n2 should be sent to API (n1 already read)
+ expect(mockAPI.notifications.markRead).toHaveBeenCalledTimes(1);
+ expect(mockAPI.notifications.markRead).toHaveBeenCalledWith('n2');
+ });
+
+ it('no-ops when no unread notifications match the trigger', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', true);
+ store.setState({ notifications: [n1] as never[], unreadCount: 0 });
+
+ await store.getState().markAllNotificationsRead('trigger-a');
+
+ expect(mockAPI.notifications.markRead).not.toHaveBeenCalled();
+ });
+
+ it('re-fetches when any scoped markRead call fails', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ const n2 = makeNotification('n2', 'trigger-a', false);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
+
+ mockAPI.notifications.markRead.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
+ mockAPI.notifications.get.mockResolvedValue({ notifications: [] });
+
+ await store.getState().markAllNotificationsRead('trigger-a');
+
+ expect(mockAPI.notifications.get).toHaveBeenCalled();
+ });
+ });
+
+ describe('scoped clearNotifications', () => {
+ const makeNotification = (
+ id: string,
+ triggerName: string | undefined,
+ isRead: boolean
+ ): DetectedError => ({
+ id,
+ sessionId: 's1',
+ projectId: 'p1',
+ lineNumber: 1,
+ timestamp: Date.now(),
+ triggerName,
+ severity: 'error',
+ message: `msg-${id}`,
+ isRead,
+ });
+
+ it('deletes only matching trigger notifications', async () => {
+ const n1 = makeNotification('n1', 'tool result error', false);
+ const n2 = makeNotification('n2', 'high token usage', false);
+ const n3 = makeNotification('n3', 'tool result error', true);
+ store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 2 });
+
+ await store.getState().clearNotifications('tool result error');
+
+ const state = store.getState();
+ expect(state.notifications).toHaveLength(1);
+ expect(state.notifications[0].id).toBe('n2');
+ expect(state.unreadCount).toBe(1);
+ });
+
+ it('calls delete individually for each matching notification', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ const n2 = makeNotification('n2', 'trigger-a', true);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });
+
+ await store.getState().clearNotifications('trigger-a');
+
+ expect(mockAPI.notifications.delete).toHaveBeenCalledWith('n1');
+ expect(mockAPI.notifications.delete).toHaveBeenCalledWith('n2');
+ expect(mockAPI.notifications.clear).not.toHaveBeenCalled();
+ });
+
+ it('uses clear API when no triggerName is provided', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ store.setState({ notifications: [n1] as never[], unreadCount: 1 });
+
+ await store.getState().clearNotifications();
+
+ expect(mockAPI.notifications.clear).toHaveBeenCalled();
+ expect(mockAPI.notifications.delete).not.toHaveBeenCalled();
+ });
+
+ it('treats notifications without triggerName as "Other"', async () => {
+ const n1 = makeNotification('n1', undefined, false);
+ const n2 = makeNotification('n2', 'trigger-a', false);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
+
+ await store.getState().clearNotifications('Other');
+
+ const state = store.getState();
+ expect(state.notifications).toHaveLength(1);
+ expect(state.notifications[0].id).toBe('n2');
+ expect(state.unreadCount).toBe(1);
+ });
+
+ it('clears both read and unread notifications for the trigger', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ const n2 = makeNotification('n2', 'trigger-a', true);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 1 });
+
+ await store.getState().clearNotifications('trigger-a');
+
+ expect(store.getState().notifications).toHaveLength(0);
+ expect(store.getState().unreadCount).toBe(0);
+ });
+
+ it('no-ops when no notifications match the trigger', async () => {
+ const n1 = makeNotification('n1', 'trigger-b', false);
+ store.setState({ notifications: [n1] as never[], unreadCount: 1 });
+
+ await store.getState().clearNotifications('trigger-a');
+
+ expect(mockAPI.notifications.delete).not.toHaveBeenCalled();
+ expect(store.getState().notifications).toHaveLength(1);
+ });
+
+ it('re-fetches when any scoped delete call fails', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ const n2 = makeNotification('n2', 'trigger-a', false);
+ store.setState({ notifications: [n1, n2] as never[], unreadCount: 2 });
+
+ mockAPI.notifications.delete.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
+ mockAPI.notifications.get.mockResolvedValue({ notifications: [] });
+
+ await store.getState().clearNotifications('trigger-a');
+
+ expect(mockAPI.notifications.get).toHaveBeenCalled();
+ });
+
+ it('correctly recalculates unreadCount after scoped clear', async () => {
+ const n1 = makeNotification('n1', 'trigger-a', false);
+ const n2 = makeNotification('n2', 'trigger-b', false);
+ const n3 = makeNotification('n3', 'trigger-b', true);
+ store.setState({ notifications: [n1, n2, n3] as never[], unreadCount: 2 });
+
+ await store.getState().clearNotifications('trigger-a');
+
+ // n1 removed (trigger-a, unread), n2+n3 remain
+ expect(store.getState().notifications).toHaveLength(2);
+ expect(store.getState().unreadCount).toBe(1); // only n2 is unread
+ });
+ });
+
describe('navigateToError', () => {
const createMockError = (overrides?: Partial): DetectedError => ({
id: 'error-1',
diff --git a/test/renderer/utils/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');
+ });
+ });
+});
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"');
+ });
+ });
+});