diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx index 7bf24642..f916a6f8 100644 --- a/src/renderer/components/chat/AIChatGroup.tsx +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -1,6 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { TriStateCheckbox } from '@renderer/components/common/TriStateCheckbox'; import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; import { enhanceAIGroup, type PrecedingSlashInfo } from '@renderer/utils/aiGroupEnhancer'; @@ -24,6 +26,7 @@ import type { EnhancedAIGroup, UserGroup, } from '@renderer/types/groups'; +import type { ToolFieldKey } from '@renderer/utils/conversationExtractor'; import type { TriggerColor } from '@shared/constants/triggerColors'; /** @@ -120,6 +123,57 @@ function containsToolUseId(items: AIGroupDisplayItem[], toolUseId: string): bool * - DisplayItemList: Shows items when expanded with inline expansion support * - Manages local expansion state and inline item expansion */ + +// Parent checkbox for the last-output block. +// Tool results get a tristate checkbox (name/summary/input/output fields as children). +// Non-tool results get a simple checkbox. +const LastOutputCheckbox = ({ + exportId, + isToolResult, + getToolFields, + setToolItemFieldsAll, + isSelected, + toggle, +}: { + exportId: string; + isToolResult: boolean; + getToolFields: (id: string) => Set; + setToolItemFieldsAll: (id: string, enabled: boolean) => void; + isSelected: (id: string) => boolean; + toggle: (id: string) => void; +}): React.JSX.Element => { + if (isToolResult) { + const fields = getToolFields(exportId); + const numOn = fields.size; + const isPartial = numOn > 0 && numOn < 4; + return ( + 0} + indeterminate={isPartial} + onChange={() => setToolItemFieldsAll(exportId, numOn === 0)} + className="mt-2 shrink-0 cursor-pointer accent-indigo-500" + title={ + numOn === 0 + ? 'Select tool result' + : isPartial + ? 'Partial — click to deselect' + : 'Deselect tool result' + } + /> + ); + } + return ( + toggle(exportId)} + className="mt-2 shrink-0 cursor-pointer accent-indigo-500" + title="Include in copy" + aria-label="Include in copy" + /> + ); +}; + const AIChatGroupInner = ({ aiGroup, highlightToolUseId, @@ -134,6 +188,8 @@ const AIChatGroupInner = ({ getExpandedDisplayItemIds, toggleDisplayItemExpansion, expandDisplayItem, + expandMany, + expandAllSignal, } = useTabUI(); // Per-tab session data, falling back to global state @@ -379,6 +435,46 @@ const AIChatGroupInner = ({ expandDisplayItem, ]); + // When "Expand All" is triggered, expand every display item + subagent trace + // in this group via a single batched store update. + const prevExpandAllSignalRef = useRef(0); + useEffect(() => { + if (expandAllSignal === 0 || expandAllSignal === prevExpandAllSignalRef.current) return; + prevExpandAllSignalRef.current = expandAllSignal; + const itemIds: string[] = []; + const subagentIds: string[] = []; + enhanced.displayItems.forEach((item, i) => { + switch (item.type) { + case 'thinking': + itemIds.push(`thinking-${i}`); + break; + case 'output': + itemIds.push(`output-${i}`); + break; + case 'tool': + itemIds.push(`tool-${item.tool.id}-${i}`); + break; + case 'subagent': + itemIds.push(`subagent-${item.subagent.id}-${i}`); + subagentIds.push(item.subagent.id); + break; + case 'slash': + itemIds.push(`slash-${item.slash.name}-${i}`); + break; + case 'teammate_message': + itemIds.push(`teammate-${item.teammateMessage.id}-${i}`); + break; + case 'subagent_input': + itemIds.push(`input-${i}`); + break; + case 'compact_boundary': + itemIds.push(`compact-${i}`); + break; + } + }); + expandMany(aiGroup.id, itemIds, subagentIds); + }, [expandAllSignal, enhanced.displayItems, aiGroup.id, expandMany]); + // Determine if there's content to toggle const hasToggleContent = enhanced.displayItems.length > 0; @@ -387,6 +483,18 @@ const AIChatGroupInner = ({ toggleDisplayItemExpansion(aiGroup.id, itemId); }; + const { + isActive: isSelectionActive, + isSelected, + toggle, + getToolFields, + setToolItemFieldsAll, + } = useExportSelection(); + const lastOutputExportId = `ai-last-${aiGroup.id}`; + const showLastOutputCheckbox = + isSelectionActive && enhanced.lastOutput !== null && enhanced.lastOutput.type !== 'ongoing'; + const lastOutputIsToolResult = enhanced.lastOutput?.type === 'tool_result'; + return (
{/* Header Row */} @@ -514,13 +622,26 @@ const AIChatGroupInner = ({ )} {/* Always-visible Output */} -
- +
+ {showLastOutputCheckbox && ( + + )} +
+ +
); diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 33159f14..7385a253 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -1,12 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ExportSelectionContext } from '@renderer/contexts/ExportSelectionContext'; import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; import { useStore } from '@renderer/store'; +import { + assembleToolContent, + type ExportItemType, + extractExportItems, + type ToolFieldKey, +} from '@renderer/utils/conversationExtractor'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { ChevronsDown } from 'lucide-react'; +import { Check, ChevronsDown, Clipboard } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { SessionContextPanel } from './SessionContextPanel/index'; @@ -21,6 +28,9 @@ import { ChatHistoryItem } from './ChatHistoryItem'; import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; import type { ContextInjection } from '@renderer/types/contextInjection'; +import type { ExportItem } from '@renderer/utils/conversationExtractor'; + +const ALL_TOOL_FIELDS = new Set(['name', 'summary', 'input', 'output']); /** * Waits for two requestAnimationFrame cycles, allowing the virtualizer to render. @@ -47,6 +57,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { savedScrollTop, saveScrollPosition, expandAIGroup, + expandAllAIGroups, + triggerExpandAll, expandSubagentTrace, selectedContextPhase, setSelectedContextPhase, @@ -100,6 +112,252 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { sessionDetail, } = tabData; + // Export selection mode + const { exportSelectionMode, closeExportSelectionMode } = useStore( + useShallow((s) => ({ + exportSelectionMode: s.exportSelectionMode, + closeExportSelectionMode: s.closeExportSelectionMode, + })) + ); + + // Pre-computed export items (recalculated when selection mode opens or conversation changes) + const [exportItems, setExportItems] = useState([]); + // selectedExportIds: source of truth for non-tool items; for tool items it's kept in sync with toolItemFields + const [selectedExportIds, setSelectedExportIds] = useState>(new Set()); + const [copyConfirmed, setCopyConfirmed] = useState(false); + // Global defaults for toolbar batch toggles + const [toolFieldsEnabled, setToolFieldsEnabled] = useState>( + new Set(['name', 'summary', 'input', 'output']) + ); + // Per-tool-item field sets. Invariant: selectedExportIds.has(id) <=> toolItemFields.get(id)?.size > 0 + const [toolItemFields, setToolItemFields] = useState>>(new Map()); + + useEffect(() => { + if (exportSelectionMode && conversation) { + const items = extractExportItems(conversation); + setExportItems(items); + const selectedIds = new Set(); + const fieldMap = new Map>(); + for (const item of items) { + if (item.selected) selectedIds.add(item.id); + if (item.type === 'tool') { + fieldMap.set(item.id, new Set(['name', 'summary', 'input', 'output'])); + } + } + setSelectedExportIds(selectedIds); + setToolItemFields(fieldMap); + } else if (!exportSelectionMode) { + setExportItems([]); + setSelectedExportIds(new Set()); + setToolItemFields(new Map()); + } + }, [exportSelectionMode, conversation]); + + // Non-tool items: simple toggle + const toggleExportItem = useCallback((id: string) => { + setSelectedExportIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + // Tool items: toggle a single field; auto-syncs selectedExportIds + const toggleToolItemField = useCallback( + (id: string, field: ToolFieldKey) => { + const current = toolItemFields.get(id) ?? ALL_TOOL_FIELDS; + const updated = new Set(current); + if (updated.has(field)) updated.delete(field); + else updated.add(field); + const newItemFields = new Map(toolItemFields); + newItemFields.set(id, updated); + setToolItemFields(newItemFields); + // Sync selectedExportIds + setSelectedExportIds((prev) => { + const next = new Set(prev); + if (updated.size === 0) next.delete(id); + else next.add(id); + return next; + }); + }, + [toolItemFields] + ); + + // Tool items: set all 4 fields on or off at once; auto-syncs selectedExportIds + const setToolItemFieldsAll = useCallback( + (id: string, enabled: boolean) => { + const newFields = enabled ? new Set(ALL_TOOL_FIELDS) : new Set(); + const newItemFields = new Map(toolItemFields); + newItemFields.set(id, newFields); + setToolItemFields(newItemFields); + setSelectedExportIds((prev) => { + const next = new Set(prev); + if (enabled) next.add(id); + else next.delete(id); + return next; + }); + }, + [toolItemFields] + ); + + // Toolbar: batch-toggle one field across all tool items; also syncs selectedExportIds + const toggleToolField = useCallback( + (field: ToolFieldKey) => { + const willEnable = !toolFieldsEnabled.has(field); + const newGlobal = new Set(toolFieldsEnabled); + if (willEnable) newGlobal.add(field); + else newGlobal.delete(field); + setToolFieldsEnabled(newGlobal); + + const newItemFields = new Map(toolItemFields); + const newSelectedIds = new Set(selectedExportIds); + for (const [id, fields] of toolItemFields) { + const updated = new Set(fields); + if (willEnable) updated.add(field); + else updated.delete(field); + newItemFields.set(id, updated); + if (updated.size === 0) newSelectedIds.delete(id); + else newSelectedIds.add(id); + } + setToolItemFields(newItemFields); + setSelectedExportIds(newSelectedIds); + }, + [toolFieldsEnabled, toolItemFields, selectedExportIds] + ); + + // Category helpers — operate on selectedExportIds (non-tool items) or all fields (tool items) + const getCategoryItems = useCallback( + (type: ExportItemType) => exportItems.filter((i) => i.type === type), + [exportItems] + ); + + const isCategoryAllSelected = useCallback( + (type: ExportItemType) => { + const cat = exportItems.filter((i) => i.type === type); + if (cat.length === 0) return false; + if (type === 'tool') { + return cat.every((i) => (toolItemFields.get(i.id)?.size ?? 0) === 4); + } + return cat.every((i) => selectedExportIds.has(i.id)); + }, + [exportItems, selectedExportIds, toolItemFields] + ); + + const isCategoryAnySelected = useCallback( + (type: ExportItemType) => { + if (type === 'tool') { + return exportItems.some( + (i) => i.type === 'tool' && (toolItemFields.get(i.id)?.size ?? 0) > 0 + ); + } + return exportItems.some((i) => i.type === type && selectedExportIds.has(i.id)); + }, + [exportItems, selectedExportIds, toolItemFields] + ); + + const toggleCategory = useCallback( + (type: ExportItemType) => { + const cat = exportItems.filter((i) => i.type === type); + if (cat.length === 0) return; + if (type === 'tool') { + const allFull = cat.every((i) => (toolItemFields.get(i.id)?.size ?? 0) === 4); + const enable = !allFull; + const newItemFields = new Map(toolItemFields); + const newSelectedIds = new Set(selectedExportIds); + for (const item of cat) { + newItemFields.set(item.id, enable ? new Set(ALL_TOOL_FIELDS) : new Set()); + if (enable) newSelectedIds.add(item.id); + else newSelectedIds.delete(item.id); + } + setToolItemFields(newItemFields); + setSelectedExportIds(newSelectedIds); + } else { + const allSelected = cat.every((i) => selectedExportIds.has(i.id)); + setSelectedExportIds((prev) => { + const next = new Set(prev); + if (allSelected) cat.forEach((i) => next.delete(i.id)); + else cat.forEach((i) => next.add(i.id)); + return next; + }); + } + }, + [exportItems, selectedExportIds, toolItemFields] + ); + + // Select-all / deselect-all also mirror state into toolItemFields so the + // invariant (item selected ⟺ at least one field enabled) holds for tool items. + const selectAll = useCallback(() => { + const newItemFields = new Map(toolItemFields); + for (const item of exportItems) { + if (item.type === 'tool') newItemFields.set(item.id, new Set(ALL_TOOL_FIELDS)); + } + setToolItemFields(newItemFields); + setSelectedExportIds(new Set(exportItems.map((i) => i.id))); + }, [exportItems, toolItemFields]); + + const deselectAll = useCallback(() => { + const newItemFields = new Map(toolItemFields); + for (const item of exportItems) { + if (item.type === 'tool') newItemFields.set(item.id, new Set()); + } + setToolItemFields(newItemFields); + setSelectedExportIds(new Set()); + }, [exportItems, toolItemFields]); + + // Hold the copy-confirmation timer in a ref so it can be cleared on unmount + // (and on subsequent clicks while the previous confirmation is still showing). + const copyConfirmedTimerRef = useRef | null>(null); + useEffect(() => { + return () => { + if (copyConfirmedTimerRef.current) clearTimeout(copyConfirmedTimerRef.current); + }; + }, []); + + const handleCopySelected = useCallback(async () => { + const text = exportItems + .filter((i) => selectedExportIds.has(i.id)) + .map((i) => { + if (i.type === 'tool' && i.toolFields) { + const fields = toolItemFields.get(i.id) ?? toolFieldsEnabled; + return assembleToolContent(i.toolFields, fields); + } + return i.content; + }) + .filter(Boolean) + .join('\n\n'); + try { + await navigator.clipboard.writeText(text); + setCopyConfirmed(true); + if (copyConfirmedTimerRef.current) clearTimeout(copyConfirmedTimerRef.current); + copyConfirmedTimerRef.current = setTimeout(() => { + setCopyConfirmed(false); + copyConfirmedTimerRef.current = null; + }, 2000); + } catch { + // clipboard unavailable + } + }, [exportItems, selectedExportIds, toolItemFields, toolFieldsEnabled]); + + const exportCtxValue = useMemo( + () => ({ + isActive: exportSelectionMode, + isSelected: (id: string) => selectedExportIds.has(id), + toggle: toggleExportItem, + getToolFields: (id: string) => toolItemFields.get(id) ?? ALL_TOOL_FIELDS, + toggleToolItemField, + setToolItemFieldsAll, + }), + [ + exportSelectionMode, + selectedExportIds, + toggleExportItem, + toolItemFields, + toggleToolItemField, + setToolItemFieldsAll, + ] + ); + // State for Context button hover (local state OK - doesn't need per-tab isolation) const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); @@ -745,148 +1003,303 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { if (!conversation || conversation.items.length === 0) return ; return ( -
-
- {/* Chat content */} -
- {/* Sticky Context button */} - {allContextInjections.length > 0 && ( -
+ +
+ {/* Export selection toolbar — shown when selection mode is active */} + {exportSelectionMode && ( +
+ {/* Row 1: category toggles + All/None shortcuts + count + Copy + Done */} +
+ {( + [ + ['user', 'User'], + ['ai-text', 'Claude'], + ['thinking', 'Thinking'], + ['tool', 'Tool Calls'], + ] as [ExportItemType, string][] + ) + .filter(([type]) => getCategoryItems(type).length > 0) + .map(([type, label]) => { + const anyOn = isCategoryAnySelected(type); + const allOn = isCategoryAllSelected(type); + return ( + + ); + })} + + | + + + + + | + + +
+ + {selectedExportIds.size}/{exportItems.length} + +
- )} + + {/* Row 2: tool field toggles — only shown when tool items exist */} + {exportItems.some((i) => i.type === 'tool') && ( +
+ + Tool fields: + + {( + [ + ['name', 'Name'], + ['summary', 'Intent'], + ['input', 'Input'], + ['output', 'Output'], + ] as [ToolFieldKey, string][] + ).map(([field, label]) => { + const on = toolFieldsEnabled.has(field); + return ( + + ); + })} +
+ )} +
+ )} + +
+ {/* Chat content */}
0 ? '-2rem' : 0 }} + ref={scrollContainerRef} + className="flex-1 overflow-y-auto" + style={{ backgroundColor: 'var(--color-surface)' }} + onScroll={checkScrollButton} > -
- {shouldVirtualize ? ( -
0 && ( +
+
- ) : ( - conversation.items.map((item) => ( - - )) - )} + Context ({allContextInjections.length}) + +
+ )} +
0 ? '-2rem' : 0 }} + > +
+ {shouldVirtualize ? ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = conversation.items[virtualRow.index]; + if (!item) return null; + return ( +
+ +
+ ); + })} +
+ ) : ( + conversation.items.map((item) => ( + + )) + )} +
-
- {/* Scroll to bottom button */} - {showScrollButton && ( - - )} + {/* Scroll to bottom button */} + {showScrollButton && ( + + )} - {/* Context panel sidebar */} - {isContextPanelVisible && allContextInjections.length > 0 && ( -
- setContextPanelVisible(false)} - projectRoot={sessionDetail?.session?.projectPath} - onNavigateToTurn={handleNavigateToTurn} - onNavigateToTool={handleNavigateToTool} - onNavigateToUserGroup={handleNavigateToUserGroup} - totalSessionTokens={lastAiGroupTotalTokens} - phaseInfo={sessionPhaseInfo ?? undefined} - selectedPhase={selectedContextPhase} - onPhaseChange={setSelectedContextPhase} - /> -
- )} + {/* Context panel sidebar */} + {isContextPanelVisible && allContextInjections.length > 0 && ( +
+ setContextPanelVisible(false)} + projectRoot={sessionDetail?.session?.projectPath} + onNavigateToTurn={handleNavigateToTurn} + onNavigateToTool={handleNavigateToTool} + onNavigateToUserGroup={handleNavigateToUserGroup} + totalSessionTokens={lastAiGroupTotalTokens} + phaseInfo={sessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} + /> +
+ )} +
-
+
); }; diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index 1b633c2f..8f4c85a3 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from 'react'; +import { TriStateCheckbox } from '@renderer/components/common/TriStateCheckbox'; import { CODE_BG, CODE_BORDER, @@ -8,6 +9,7 @@ import { TOOL_CALL_BORDER, TOOL_CALL_TEXT, } from '@renderer/constants/cssVariables'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { format } from 'date-fns'; import { ChevronRight, Layers, MailOpen } from 'lucide-react'; @@ -78,6 +80,14 @@ export const DisplayItemList = React.memo(function DisplayItemList({ setReplyLinkToolId(toolId); }, []); + const { + isActive: isSelectionActive, + isSelected, + toggle, + getToolFields, + setToolItemFieldsAll, + } = useExportSelection(); + /** Check if an item is part of the currently highlighted reply link */ const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { if (!replyLinkToolId) return false; @@ -95,11 +105,19 @@ export const DisplayItemList = React.memo(function DisplayItemList({ ); } + // Track count of extractable items (thinking / output / tool) seen so far. + // Used to generate stable export IDs that match conversationExtractor.ts. + let extractableCount = 0; + return (
{items.map((item, index) => { let itemKey = ''; let element: React.ReactNode = null; + const isExtractable = + item.type === 'thinking' || item.type === 'output' || item.type === 'tool'; + const exportId = isExtractable ? `ai-${aiGroupId}-${extractableCount}` : ''; + if (isExtractable) extractableCount++; switch (item.type) { case 'thinking': { @@ -161,6 +179,7 @@ export const DisplayItemList = React.memo(function DisplayItemList({ registerRef={ registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined } + exportId={isSelectionActive ? exportId : undefined} /> ); break; @@ -328,13 +347,51 @@ export const DisplayItemList = React.memo(function DisplayItemList({ return (
- {element} + {isSelectionActive && + isExtractable && + (() => { + if (item.type === 'tool') { + const fields = getToolFields(exportId); + const numOn = fields.size; + const isChecked = numOn === 4; + const isPartial = numOn > 0 && numOn < 4; + return ( + setToolItemFieldsAll(exportId, numOn === 0)} + className="mt-1.5 shrink-0 cursor-pointer accent-indigo-500" + title={ + isChecked + ? 'Deselect tool' + : isPartial + ? 'Toggle tool (partial)' + : 'Select tool' + } + /> + ); + } + return ( + toggle(exportId)} + className="mt-1.5 shrink-0 cursor-pointer accent-indigo-500" + title="Include in copy" + aria-label="Include in copy" + /> + ); + })()} +
+ {element} +
); })} diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index dbd94b78..d7108632 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; import { useStore } from '@renderer/store'; import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react'; import remarkGfm from 'remark-gfm'; @@ -21,6 +22,8 @@ interface LastOutputDisplayProps { isLastGroup?: boolean; /** Whether the session is ongoing (from sessions array, same source as sidebar) */ isSessionOngoing?: boolean; + /** Export ID — when provided, shows per-field checkboxes in selection mode */ + exportId?: string; } /** @@ -39,7 +42,10 @@ export const LastOutputDisplay = ({ aiGroupId, isLastGroup = false, isSessionOngoing = false, + exportId, }: Readonly): React.JSX.Element | null => { + const { isActive: isSelectionActive, getToolFields, toggleToolItemField } = useExportSelection(); + const showFieldCheckboxes = isSelectionActive && Boolean(exportId); // Only re-render if THIS AI group has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => { @@ -129,16 +135,28 @@ export const LastOutputDisplay = ({ }} /> {lastOutput.toolName && ( - - {lastOutput.toolName} - + <> + {showFieldCheckboxes && ( + toggleToolItemField(exportId!, 'name')} + title="Include tool name in copy" + aria-label="Include tool name in copy" + className="cursor-pointer accent-indigo-500" + /> + )} + + {lastOutput.toolName} + + )} {isError && ( + {showFieldCheckboxes && ( +
+ + Output + + toggleToolItemField(exportId!, 'output')} + title="Include output in copy" + aria-label="Include output in copy" + className="cursor-pointer accent-indigo-500" + /> +
+ )}
): React.
   const hasImages = content.images.length > 0;
   // Use rawText to preserve /commands inline
   const textContent = content.rawText ?? content.text ?? '';
+
+  const { isActive: isSelectionActive, isSelected, toggle } = useExportSelection();
+  const exportId = `user-${userGroup.id}`;
   const isLongContent = textContent.length > 500;
 
   // Parse task notifications from the original message content (before sanitization)
@@ -439,7 +443,17 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
     isLongContent && !isExpanded ? textContent.slice(0, 500) + '...' : textContent;
 
   return (
-    
+
+ {isSelectionActive && textContent && ( + toggle(exportId)} + className="mt-8 shrink-0 cursor-pointer accent-indigo-500" + title="Include in copy" + aria-label="Include in copy" + /> + )}
{/* Header - right aligned with improved hierarchy */}
@@ -509,10 +523,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. border: '1px solid var(--card-border)', }} > - +
): React. > {cmdName}
-
+
{notif.status} {exitCode != null && exit {exitCode}} {notif.outputFile && ( diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 23249830..e0d0beb4 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -6,12 +6,19 @@ import { ChevronRight } from 'lucide-react'; import { formatDuration, formatTokens, getStatusDotColor } from './baseItemHelpers'; +import type { ToolFieldKey } from '@renderer/utils/conversationExtractor'; + // ============================================================================= // Types // ============================================================================= export type ItemStatus = 'ok' | 'error' | 'pending' | 'orphaned'; +export interface BaseItemExportSelection { + getField: (f: ToolFieldKey) => boolean; + toggleField: (f: ToolFieldKey) => void; +} + interface BaseItemProps { /** Icon component to display */ icon: React.ReactNode; @@ -41,6 +48,8 @@ interface BaseItemProps { notificationDotColor?: TriggerColor; /** Children rendered when expanded */ children?: React.ReactNode; + /** When set, renders inline field checkboxes for export selection mode */ + exportSelection?: BaseItemExportSelection; } // ============================================================================= @@ -59,6 +68,28 @@ export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { ); }; +// Checkbox that stops click propagation so it doesn't expand/collapse the item. +const FieldCheckbox: React.FC<{ checked: boolean; onChange: () => void; title: string }> = ({ + checked, + onChange, + title, +}) => ( + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + +); + // ============================================================================= // Main Component // ============================================================================= @@ -87,6 +118,7 @@ export const BaseItem: React.FC = ({ highlightStyle, notificationDotColor, children, + exportSelection, }) => { return (
= ({ {icon} + {/* Name field checkbox — before the label */} + {exportSelection && ( + exportSelection.toggleField('name')} + title="Include tool name in copy" + /> + )} + {/* Label */} {label} @@ -129,6 +170,14 @@ export const BaseItem: React.FC = ({ - + {/* Intent/summary field checkbox — before the summary text */} + {exportSelection && ( + exportSelection.toggleField('summary')} + title="Include intent in copy" + /> + )} {summary} diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 910c2be3..115d2457 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -10,6 +10,7 @@ import React, { useRef } from 'react'; import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; import { getToolContextTokens, getToolStatus, @@ -40,6 +41,7 @@ import { } from './linkedTool'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; +import type { ToolFieldKey } from '@renderer/utils/conversationExtractor'; interface LinkedToolItemProps { linkedTool: LinkedToolItemType; @@ -53,6 +55,8 @@ interface LinkedToolItemProps { notificationDotColor?: TriggerColor; /** Optional ref registration callback for external scroll control */ registerRef?: (el: HTMLDivElement | null) => void; + /** Export ID for this item — enables per-field checkboxes in selection mode */ + exportId?: string; } export const LinkedToolItem: React.FC = React.memo(function LinkedToolItem({ @@ -63,11 +67,21 @@ export const LinkedToolItem: React.FC = React.memo(function highlightColor, notificationDotColor, registerRef, + exportId, }) { const status = getToolStatus(linkedTool); const summary = getToolSummary(linkedTool.name, linkedTool.input); const elementRef = useRef(null); + const { isActive, getToolFields, toggleToolItemField } = useExportSelection(); + const exportSelection = + isActive && exportId + ? { + getField: (f: ToolFieldKey) => getToolFields(exportId).has(f), + toggleField: (f: ToolFieldKey) => toggleToolItemField(exportId, f), + } + : undefined; + // Combined ref callback - handles both internal ref and external registration const handleRef = (el: HTMLDivElement | null): void => { // Update internal ref @@ -164,21 +178,26 @@ export const LinkedToolItem: React.FC = React.memo(function highlightClasses={highlightClasses} highlightStyle={highlightStyle} notificationDotColor={notificationDotColor} + exportSelection={exportSelection} > {/* Read tool with CodeBlockViewer */} - {useReadViewer && } + {useReadViewer && } {/* Edit tool with DiffViewer */} - {useEditViewer && } + {useEditViewer && ( + + )} {/* Write tool */} - {useWriteViewer && } + {useWriteViewer && } {/* Skill tool with instructions */} - {useSkillViewer && } + {useSkillViewer && } {/* Default rendering for other tools */} - {useDefaultViewer && } + {useDefaultViewer && ( + + )} {/* Error output for Read tool */} {showReadError && } diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx index de6fb699..67191658 100644 --- a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; import { ChevronDown, ChevronRight } from 'lucide-react'; import { type ItemStatus, StatusDot } from '../BaseItem'; @@ -16,27 +17,52 @@ interface CollapsibleOutputSectionProps { children: React.ReactNode; /** Label shown in the header (default: "Output") */ label?: string; + /** Export ID of the parent tool item — when set, shows an output field checkbox */ + exportId?: string; } export const CollapsibleOutputSection: React.FC = ({ status, children, label = 'Output', + exportId, }) => { const [isExpanded, setIsExpanded] = useState(false); + const { isActive, getToolFields, toggleToolItemField } = useExportSelection(); + + const showCheckbox = isActive && Boolean(exportId); + const outputChecked = exportId ? getToolFields(exportId).has('output') : true; return (
- +
+ + {showCheckbox && ( + toggleToolItemField(exportId!, 'output')} + title="Include output in copy" + aria-label="Include output in copy" + className="cursor-pointer accent-indigo-500" + /> + )} +
{isExpanded && (
= backgroundColor: 'var(--code-bg)', border: '1px solid var(--code-border)', color: - status === 'error' - ? 'var(--tool-result-error-text)' - : 'var(--color-text-secondary)', + status === 'error' ? 'var(--tool-result-error-text)' : 'var(--color-text-secondary)', }} > {children} diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index c0a06fcf..29ed153f 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -6,6 +6,8 @@ import React from 'react'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; + import { type ItemStatus } from '../BaseItem'; import { CollapsibleOutputSection } from './CollapsibleOutputSection'; @@ -16,15 +18,36 @@ import type { LinkedToolItem } from '@renderer/types/groups'; interface DefaultToolViewerProps { linkedTool: LinkedToolItem; status: ItemStatus; + exportId?: string; } -export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { +export const DefaultToolViewer: React.FC = ({ + linkedTool, + status, + exportId, +}) => { + const { isActive, getToolFields, toggleToolItemField } = useExportSelection(); + const showCheckbox = isActive && Boolean(exportId); + const inputChecked = exportId ? getToolFields(exportId).has('input') : true; + return ( <> {/* Input Section */}
-
- Input +
+ + Input + + {showCheckbox && ( + toggleToolItemField(exportId!, 'input')} + title="Include input in copy" + aria-label="Include input in copy" + className="cursor-pointer accent-indigo-500" + /> + )}
= ({ linkedTool {/* Output Section — Collapsed by default */} {!linkedTool.isOrphaned && linkedTool.result && ( - + {renderOutput(linkedTool.result.content)} )} diff --git a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx index b924a9be..97282de0 100644 --- a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { DiffViewer } from '@renderer/components/chat/viewers'; +import { useExportSelection } from '@renderer/contexts/ExportSelectionContext'; import { type ItemStatus, StatusDot } from '../BaseItem'; import { formatTokens } from '../baseItemHelpers'; @@ -18,9 +19,15 @@ import type { LinkedToolItem } from '@renderer/types/groups'; interface EditToolViewerProps { linkedTool: LinkedToolItem; status: ItemStatus; + exportId?: string; } -export const EditToolViewer: React.FC = ({ linkedTool, status }) => { +export const EditToolViewer: React.FC = ({ linkedTool, status, exportId }) => { + const { isActive, getToolFields, toggleToolItemField } = useExportSelection(); + const showCheckbox = isActive && Boolean(exportId); + const inputChecked = exportId ? getToolFields(exportId).has('input') : true; + const outputChecked = exportId ? getToolFields(exportId).has('output') : true; + const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); @@ -31,14 +38,32 @@ export const EditToolViewer: React.FC = ({ linkedTool, stat return (
- + {/* Input: the diff */} +
+ {showCheckbox && ( +
+ + Diff + + toggleToolItemField(exportId!, 'input')} + title="Include diff in copy" + aria-label="Include diff in copy" + className="cursor-pointer accent-indigo-500" + /> +
+ )} + +
- {/* Show result status if available */} + {/* Output: result status */} {!linkedTool.isOrphaned && linkedTool.result != null && (
= ({ linkedTool, stat ~{formatTokens(linkedTool.result.tokenCount)} tokens )} + {showCheckbox && ( + toggleToolItemField(exportId!, 'output')} + title="Include result in copy" + aria-label="Include result in copy" + className="cursor-pointer accent-indigo-500" + /> + )}
= ({ linkedTool }) => { +export const ReadToolViewer: React.FC = ({ linkedTool, exportId }) => { + const { isActive, getToolFields, toggleToolItemField } = useExportSelection(); + const showCheckbox = isActive && Boolean(exportId); + const outputChecked = exportId ? getToolFields(exportId).has('output') : true; + const filePath = linkedTool.input.file_path as string; // Prefer enriched toolUseResult data const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; const fileData = toolUseResult?.file as - | { - content?: string; - startLine?: number; - totalLines?: number; - numLines?: number; - } + | { content?: string; startLine?: number; totalLines?: number; numLines?: number } | undefined; // Get content: prefer enriched file data, fall back to raw result content @@ -55,10 +56,29 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => : undefined; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>( + isMarkdownFile ? 'preview' : 'code' + ); return (
+ {/* Output label + checkbox */} + {showCheckbox && ( +
+ + File content + + toggleToolItemField(exportId!, 'output')} + title="Include file content in copy" + aria-label="Include file content in copy" + className="cursor-pointer accent-indigo-500" + /> +
+ )} + {isMarkdownFile && (