From a6f9515966016ff2440db15d75b55ae7fbe1b6f9 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 13 Mar 2026 23:29:24 +0200 Subject: [PATCH] refactor: clean up pricing.json and enhance CLI logs styling - Removed deprecated entries for Claude models from pricing.json to streamline configuration. - Added compact styling for CLI logs, including padding adjustments and hiding timestamps and chevrons for a cleaner look. - Updated DisplayItemList to support customizable preview text length for better content display. - Enhanced BaseItem and TextItem components to utilize the new preview length feature, improving text handling. - Refactored MarkdownViewer to support team click actions, enhancing interactivity in team-related components. --- resources/pricing.json | 211 +---- .../components/chat/DisplayItemList.tsx | 9 +- .../components/chat/items/BaseItem.tsx | 7 +- .../components/chat/items/TextItem.tsx | 15 +- .../components/chat/items/ThinkingItem.tsx | 15 +- .../chat/viewers/MarkdownViewer.tsx | 59 +- .../components/team/ClaudeLogsDialog.tsx | 71 ++ .../components/team/ClaudeLogsPanel.tsx | 165 ++++ .../components/team/ClaudeLogsSection.tsx | 743 ++---------------- .../components/team/CliLogsRichView.tsx | 332 ++++++-- .../team/CollapsibleTeamSection.tsx | 4 +- .../components/team/TeamDetailView.tsx | 72 +- .../components/team/activity/ActivityItem.tsx | 38 +- .../team/dialogs/StatusHistoryTimeline.tsx | 8 +- .../components/team/kanban/KanbanTaskCard.tsx | 54 +- .../team/messages/MessagesPanel.tsx | 51 +- .../team/review/ChangeReviewDialog.tsx | 7 +- .../team/review/ChangesLoadingAnimation.tsx | 85 ++ .../team/useClaudeLogsController.ts | 655 +++++++++++++++ .../components/ui/MentionInteractionLayer.tsx | 121 +++ .../components/ui/MentionableTextarea.tsx | 10 + src/renderer/index.css | 13 + src/renderer/utils/chipUtils.ts | 78 ++ src/renderer/utils/streamJsonParser.ts | 32 +- src/shared/utils/toolSummary.ts | 21 +- 25 files changed, 1794 insertions(+), 1082 deletions(-) create mode 100644 src/renderer/components/team/ClaudeLogsDialog.tsx create mode 100644 src/renderer/components/team/ClaudeLogsPanel.tsx create mode 100644 src/renderer/components/team/review/ChangesLoadingAnimation.tsx create mode 100644 src/renderer/components/team/useClaudeLogsController.ts create mode 100644 src/renderer/components/ui/MentionInteractionLayer.tsx diff --git a/resources/pricing.json b/resources/pricing.json index 3f65c218..17b13eec 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -1222,60 +1222,6 @@ "supports_response_schema": true, "supports_tool_choice": true }, - "claude-3-5-haiku-20241022": { - "cache_creation_input_token_cost": 0.000001, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 8e-8, - "deprecation_date": "2025-10-01", - "input_cost_per_token": 8e-7, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000004, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 264 - }, - "claude-3-5-haiku-latest": { - "cache_creation_input_token_cost": 0.00000125, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 1e-7, - "deprecation_date": "2025-10-01", - "input_cost_per_token": 0.000001, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000005, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 264 - }, "claude-haiku-4-5-20251001": { "cache_creation_input_token_cost": 0.00000125, "cache_creation_input_token_cost_above_1hr": 0.000002, @@ -1318,83 +1264,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "claude-3-5-sonnet-20240620": { - "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 3e-7, - "deprecation_date": "2025-06-01", - "input_cost_per_token": 0.000003, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000015, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 - }, - "claude-3-5-sonnet-20241022": { - "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 3e-7, - "deprecation_date": "2025-10-01", - "input_cost_per_token": 0.000003, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000015, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 159 - }, - "claude-3-5-sonnet-latest": { - "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 3e-7, - "deprecation_date": "2025-06-01", - "input_cost_per_token": 0.000003, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000015, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "supports_web_search": true, - "tool_use_system_prompt_tokens": 159 - }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost_above_1hr": 0.000006, @@ -1424,34 +1293,6 @@ "supports_web_search": true, "tool_use_system_prompt_tokens": 159 }, - "claude-3-7-sonnet-latest": { - "cache_creation_input_token_cost": 0.00000375, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 3e-7, - "deprecation_date": "2025-06-01", - "input_cost_per_token": 0.000003, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 64000, - "max_tokens": 64000, - "mode": "chat", - "output_cost_per_token": 0.000015, - "search_context_cost_per_query": { - "search_context_size_high": 0.01, - "search_context_size_low": 0.01, - "search_context_size_medium": 0.01 - }, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 159 - }, "claude-3-haiku-20240307": { "cache_creation_input_token_cost": 3e-7, "cache_creation_input_token_cost_above_1hr": 0.000006, @@ -1491,26 +1332,6 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 395 }, - "claude-3-opus-latest": { - "cache_creation_input_token_cost": 0.00001875, - "cache_creation_input_token_cost_above_1hr": 0.000006, - "cache_read_input_token_cost": 0.0000015, - "deprecation_date": "2025-03-01", - "input_cost_per_token": 0.000015, - "litellm_provider": "anthropic", - "max_input_tokens": 200000, - "max_output_tokens": 4096, - "max_tokens": 4096, - "mode": "chat", - "output_cost_per_token": 0.000075, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true, - "supports_vision": true, - "tool_use_system_prompt_tokens": 395 - }, "claude-4-opus-20250514": { "cache_creation_input_token_cost": 0.00001875, "cache_read_input_token_cost": 0.0000015, @@ -3592,36 +3413,6 @@ "supports_tool_choice": true, "supports_vision": true }, - "vertex_ai/claude-3-5-sonnet-v2": { - "input_cost_per_token": 0.000003, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000015, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_tool_choice": true, - "supports_vision": true - }, - "vertex_ai/claude-3-5-sonnet-v2@20241022": { - "input_cost_per_token": 0.000003, - "litellm_provider": "vertex_ai-anthropic_models", - "max_input_tokens": 200000, - "max_output_tokens": 8192, - "max_tokens": 8192, - "mode": "chat", - "output_cost_per_token": 0.000015, - "supports_assistant_prefill": true, - "supports_computer_use": true, - "supports_function_calling": true, - "supports_pdf_input": true, - "supports_tool_choice": true, - "supports_vision": true - }, "vertex_ai/claude-3-5-sonnet@20240620": { "input_cost_per_token": 0.000003, "litellm_provider": "vertex_ai-anthropic_models", @@ -3639,7 +3430,7 @@ "vertex_ai/claude-3-7-sonnet@20250219": { "cache_creation_input_token_cost": 0.00000375, "cache_read_input_token_cost": 3e-7, - "deprecation_date": "2025-06-01", + "deprecation_date": "2026-05-11", "input_cost_per_token": 0.000003, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index 7bc6ee21..f0281c8d 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -41,6 +41,8 @@ interface DisplayItemListProps { notificationColorMap?: Map; /** Optional callback to register tool element refs for scroll targeting */ registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; + /** Max characters for preview text in item headers (default: 150 for thinking/output, 80 for input) */ + previewMaxLength?: number; } /** @@ -76,6 +78,7 @@ export const DisplayItemList = ({ highlightColor, notificationColorMap, registerToolRef, + previewMaxLength, }: Readonly): React.JSX.Element => { // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair const [replyLinkToolId, setReplyLinkToolId] = useState(null); @@ -127,7 +130,7 @@ export const DisplayItemList = ({ element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} timestamp={item.timestamp} @@ -153,7 +156,7 @@ export const DisplayItemList = ({ element = ( onItemClick(itemKey)} isExpanded={expandedItemIds.has(itemKey)} timestamp={item.timestamp} @@ -249,7 +252,7 @@ export const DisplayItemList = ({ } label="Input" - summary={truncateText(inputContent, 80)} + summary={truncateText(inputContent, previewMaxLength ?? 80)} tokenCount={inputTokenCount} timestamp={item.timestamp} onClick={() => onItemClick(itemKey)} diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 72a0896e..ade71014 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -175,7 +175,10 @@ export const BaseItem: React.FC = ({ {/* Timestamp — rightmost info element */} {timestamp && ( - + {format(timestamp, 'HH:mm:ss')} )} @@ -183,7 +186,7 @@ export const BaseItem: React.FC = ({ {/* Expand/collapse chevron */} {hasExpandableContent && ( )} diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 43e823b7..e5aeadc1 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -6,7 +6,6 @@ import { highlightQueryInText } from '../searchHighlightUtils'; import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; -import { truncateText } from './baseItemHelpers'; import type { SemanticStep } from '@renderer/types/data'; import type { TriggerColor } from '@shared/constants/triggerColors'; @@ -43,17 +42,11 @@ export const TextItem: React.FC = ({ notificationDotColor, }) => { const fullContent = step.content.outputText ?? preview; - const truncatedPreview = truncateText(preview, 60); const summary = searchQueryOverride - ? highlightQueryInText( - truncatedPreview, - searchQueryOverride, - `${markdownItemId ?? step.id}:summary`, - { - forceAllActive: true, - } - ) - : truncatedPreview; + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 480747c0..1e3306f1 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -6,7 +6,6 @@ import { highlightQueryInText } from '../searchHighlightUtils'; import { MarkdownViewer } from '../viewers'; import { BaseItem } from './BaseItem'; -import { truncateText } from './baseItemHelpers'; import type { SemanticStep } from '@renderer/types/data'; import type { TriggerColor } from '@shared/constants/triggerColors'; @@ -43,17 +42,11 @@ export const ThinkingItem: React.FC = ({ notificationDotColor, }) => { const fullContent = step.content.thinkingText ?? preview; - const truncatedPreview = truncateText(preview, 60); const summary = searchQueryOverride - ? highlightQueryInText( - truncatedPreview, - searchQueryOverride, - `${markdownItemId ?? step.id}:summary`, - { - forceAllActive: true, - } - ) - : truncatedPreview; + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; // Get token count from step.tokens.output or step.content.tokenCount const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index f9502d7c..0d954f9e 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -161,7 +161,8 @@ function hastToText(node: HastNode): string { function createViewerMarkdownComponents( searchCtx: SearchContext | null, isLight = false, - teamColorByName: ReadonlyMap = new Map() + teamColorByName: ReadonlyMap = new Map(), + onTeamClick?: (teamName: string) => void ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; @@ -259,20 +260,39 @@ function createViewerMarkdownComponents( const teamColor = teamColorByName.get(teamLabel); const colorSet = teamColor ? getTeamColorSet(teamColor) : nameColorSet(teamLabel, isLight); const bg = getThemedBadge(colorSet, isLight); + const badgeStyle: React.CSSProperties = { + backgroundColor: bg, + color: colorSet.text, + borderRadius: '3px', + boxShadow: `0 0 0 1.5px ${bg}`, + fontSize: 'inherit', + cursor: onTeamClick ? 'pointer' : 'default', + display: 'inline-flex', + alignItems: 'center', + gap: '2px', + border: 'none', + padding: 0, + font: 'inherit', + lineHeight: 'inherit', + }; + if (onTeamClick && teamLabel) { + return ( + + ); + } return ( - + {children} @@ -475,9 +495,6 @@ function createViewerMarkdownComponents( }; } -/** Default components without search highlighting */ -const defaultComponents = createViewerMarkdownComponents(null); - // Markdown + syntax highlighting can freeze the renderer on some inputs // (very large text, huge code blocks, pathological markdown). Keep the UI responsive: // - for medium/large content: disable syntax highlighting @@ -506,6 +523,8 @@ export const MarkdownViewer: React.FC = ({ const { isLight } = useTheme(); const teams = useStore((s) => s.teams); + const openTeamTab = useStore((s) => s.openTeamTab); + const teamColorByName = React.useMemo(() => { const result = new Map(); for (const team of teams) { @@ -670,10 +689,10 @@ export const MarkdownViewer: React.FC = ({ // When search is active, create fresh each render (match counter is stateful and must start at 0) // useMemo would cache stale closures when parent re-renders without search deps changing const baseComponents = searchCtx - ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName) + ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, openTeamTab) : isLight - ? createViewerMarkdownComponents(null, true, teamColorByName) - : createViewerMarkdownComponents(null, false, teamColorByName); + ? createViewerMarkdownComponents(null, true, teamColorByName, openTeamTab) + : createViewerMarkdownComponents(null, false, teamColorByName, openTeamTab); // When baseDir is set (editor preview), override img to load local files via IPC const components = baseDir diff --git a/src/renderer/components/team/ClaudeLogsDialog.tsx b/src/renderer/components/team/ClaudeLogsDialog.tsx new file mode 100644 index 00000000..d93f671a --- /dev/null +++ b/src/renderer/components/team/ClaudeLogsDialog.tsx @@ -0,0 +1,71 @@ +/** + * ClaudeLogsDialog + * + * Fullscreen-style dialog for viewing Claude logs in a large viewport. + * Uses the same ClaudeLogsPanel as the compact sidebar but with more space. + * Only one CliLogsRichView is mounted at a time — when this dialog is open, + * the compact panel hides its log viewer. + */ + +import React from 'react'; + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog'; +import { Terminal } from 'lucide-react'; + +import { ClaudeLogsPanel } from './ClaudeLogsPanel'; + +import type { ClaudeLogsController } from './useClaudeLogsController'; + +// ============================================================================= +// Props +// ============================================================================= + +interface ClaudeLogsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + ctrl: ClaudeLogsController; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const ClaudeLogsDialog = ({ + open, + onOpenChange, + ctrl, +}: ClaudeLogsDialogProps): React.JSX.Element => { + return ( + + + + + + + + Claude logs + {ctrl.badge != null && ( + + ({ctrl.badge}) + + )} + {ctrl.online && ( + + + + + )} + + + +
+ +
+
+
+ ); +}; diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx new file mode 100644 index 00000000..af8f4162 --- /dev/null +++ b/src/renderer/components/team/ClaudeLogsPanel.tsx @@ -0,0 +1,165 @@ +/** + * ClaudeLogsPanel + * + * Shared rendering surface for Claude logs — used both in the compact sidebar + * section and the fullscreen dialog. Renders the toolbar (search, filter, + * pending button), the rich log viewer, and empty/error states. + */ + +import React from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { cn } from '@renderer/lib/utils'; +import { Search, X } from 'lucide-react'; + +import { ClaudeLogsFilterPopover } from './ClaudeLogsFilterPopover'; +import { CliLogsRichView } from './CliLogsRichView'; + +import type { ClaudeLogsController } from './useClaudeLogsController'; + +// ============================================================================= +// Props +// ============================================================================= + +interface ClaudeLogsPanelProps { + ctrl: ClaudeLogsController; + /** Maximum height class for the log viewer (e.g. "max-h-[213px]" for compact). */ + viewerClassName?: string; + /** Extra className for the panel wrapper. */ + className?: string; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const ClaudeLogsPanel = ({ + ctrl, + viewerClassName, + className, +}: ClaudeLogsPanelProps): React.JSX.Element => { + const { + data, + loading, + loadingMore, + error, + pendingNewCount, + isAlive, + filteredText, + showMoreVisible, + searchQuery, + setSearchQuery, + filter, + setFilter, + filterOpen, + setFilterOpen, + viewerState, + onViewerStateChange, + applyPending, + loadOlderLogs, + containerRefCallback, + handleScroll, + } = ctrl; + + return ( +
+ {/* Toolbar */} +
+ + {data.total > 0 ? ( + <> + {Math.min(data.total, data.lines.length)} of{' '} + {data.total} + + ) : isAlive ? ( + 'No logs yet.' + ) : ( + 'Team is not running.' + )} + +
+ {data.total > 0 ? ( + <> +
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + + ) : null} + {pendingNewCount > 0 && ( + + )} +
+
+ + {/* Log viewer */} +
+ {error ?

{error}

: null} + {!error && filteredText.trim().length > 0 ? ( + + +
+ ) : null + } + /> + ) : null} + {!error && data.lines.length === 0 && isAlive ? ( +

+ {loading ? 'Loading…' : 'No logs captured.'} +

+ ) : null} + {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( +

No matching logs.

+ ) : null} +
+ + ); +}; diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index c349cc8b..e8dfa2c0 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -1,132 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; -import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { useStore } from '@renderer/store'; -import { Brain, MessageSquare, Search, Terminal, Wrench, X } from 'lucide-react'; +import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react'; -import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; -import { CliLogsRichView } from './CliLogsRichView'; +import { ClaudeLogsDialog } from './ClaudeLogsDialog'; +import { ClaudeLogsPanel } from './ClaudeLogsPanel'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { useClaudeLogsController } from './useClaudeLogsController'; -import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; -import type { TeamClaudeLogsResponse } from '@shared/types'; +import type { LastLogPreview } from './useClaudeLogsController'; -const PAGE_SIZE = 100; -const POLL_MS = 2000; -const ONLINE_WINDOW_MS = 10_000; -const LOAD_MORE_THRESHOLD_PX = 48; - -type StreamType = 'stdout' | 'stderr'; - -interface ClaudeLogsSectionProps { - teamName: string; - position?: 'sidebar' | 'inline'; -} - -function isRecent(updatedAt: string | undefined): boolean { - if (!updatedAt) return false; - const t = Date.parse(updatedAt); - if (Number.isNaN(t)) return false; - return Date.now() - t <= ONLINE_WINDOW_MS; -} - -/** - * System JSON subtypes that carry no user-facing value in the logs UI. - * These appear at session start before any assistant messages arrive. - */ -const SYSTEM_NOISE_SUBTYPES = new Set(['hook_started', 'hook_response', 'init']); - -/** - * Returns true if the raw JSON string represents a system message - * that should be filtered from the logs view. - */ -function isSystemNoiseLine(jsonStr: string): boolean { - try { - const parsed = JSON.parse(jsonStr); - if (!parsed || typeof parsed !== 'object') return false; - const obj = parsed as Record; - if (obj.type !== 'system') return false; - // Filter known noise subtypes; if no subtype, still filter generic system lines - if (typeof obj.subtype === 'string') { - return SYSTEM_NOISE_SUBTYPES.has(obj.subtype); - } - return true; - } catch { - return false; - } -} - -/** Info about the most recent log item for the header preview. */ -interface LastLogPreview { - type: 'output' | 'thinking' | 'tool'; - label: string; - summary: string; -} - -/** - * Extracts the preview of the most recent log item from newest-first lines. - * Lightweight: only parses until the first usable assistant message is found. - */ -function extractLastLogPreview(linesNewestFirst: string[]): LastLogPreview | null { - for (const rawLine of linesNewestFirst) { - const line = rawLine?.trim(); - if (!line) continue; - // Skip markers - if (line === '[stdout]' || line === '[stderr]') continue; - - // Strip stream prefix - let content = line; - if (line.startsWith('[stdout] ')) content = line.slice('[stdout] '.length); - else if (line.startsWith('[stderr] ')) content = line.slice('[stderr] '.length); - - // Skip system noise - if (content.trimStart().startsWith('{') && isSystemNoiseLine(content)) continue; - - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { - continue; - } - - if (!parsed || typeof parsed !== 'object') continue; - const obj = parsed as Record; - if (obj.type !== 'assistant') continue; - - // Extract content blocks - type ContentBlock = { type: string; text?: string; thinking?: string; name?: string }; - let blocks: ContentBlock[] | null = null; - if (Array.isArray(obj.content)) { - blocks = obj.content as ContentBlock[]; - } else if (obj.message && typeof obj.message === 'object') { - const msg = obj.message as Record; - if (Array.isArray(msg.content)) blocks = msg.content as ContentBlock[]; - } - - if (!blocks || blocks.length === 0) continue; - - // Take the last non-empty block - for (let i = blocks.length - 1; i >= 0; i--) { - const b = blocks[i]; - if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { - return { type: 'output', label: 'Output', summary: b.text.trim().replace(/\n+/g, ' ') }; - } - if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) { - return { - type: 'thinking', - label: 'Thinking', - summary: b.thinking.trim().replace(/\n+/g, ' '), - }; - } - if (b.type === 'tool_use' && typeof b.name === 'string') { - return { type: 'tool', label: b.name, summary: '' }; - } - } - } - return null; -} +// ============================================================================= +// Constants +// ============================================================================= const PREVIEW_ICONS = { output: , @@ -134,6 +22,15 @@ const PREVIEW_ICONS = { tool: , } as const; +// ============================================================================= +// Sub-components +// ============================================================================= + +interface ClaudeLogsSectionProps { + teamName: string; + position?: 'sidebar' | 'inline'; +} + /** * Compact inline preview of the most recent log item, shown in the section header. */ @@ -166,549 +63,83 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E ); }; -function normalizeToStreamJsonText(linesNewestFirst: string[]): string { - // We want to feed CliLogsRichView the exact format it expects: - // - marker lines: "[stdout]" / "[stderr]" - // - raw JSON lines without any "[stdout] " prefix - const chronological = [...linesNewestFirst].reverse(); - - const out: string[] = []; - let lastStream: StreamType | null = null; - - const pushMarker = (stream: StreamType): void => { - if (lastStream === stream) return; - lastStream = stream; - out.push(stream === 'stdout' ? '[stdout]' : '[stderr]'); - }; - - for (const rawLine of chronological) { - const line = rawLine ?? ''; - if (line === '[stdout]' || line === '[stderr]') { - lastStream = line === '[stdout]' ? 'stdout' : 'stderr'; - out.push(line); - continue; - } - - let content = line; - if (line.startsWith('[stdout] ')) { - pushMarker('stdout'); - content = line.slice('[stdout] '.length); - } else if (line.startsWith('[stderr] ')) { - pushMarker('stderr'); - content = line.slice('[stderr] '.length); - } - - // Skip system noise lines (hook_started, hook_response, init) - if (content.trimStart().startsWith('{') && isSystemNoiseLine(content)) { - continue; - } - - if (content !== line) { - // Already stripped prefix above - out.push(content); - } else { - out.push(line); - } - } - - return out.join('\n'); -} - -function getOverlapSize( - existingLinesNewestFirst: string[], - olderLinesNewestFirst: string[] -): number { - const maxOverlap = Math.min(existingLinesNewestFirst.length, olderLinesNewestFirst.length); - - for (let size = maxOverlap; size > 0; size -= 1) { - let matches = true; - for (let i = 0; i < size; i += 1) { - if ( - existingLinesNewestFirst[existingLinesNewestFirst.length - size + i] !== - olderLinesNewestFirst[i] - ) { - matches = false; - break; - } - } - if (matches) return size; - } - - return 0; -} - -function appendOlderLines( - existingLinesNewestFirst: string[], - olderLinesNewestFirst: string[] -): string[] { - if (existingLinesNewestFirst.length === 0) return olderLinesNewestFirst; - if (olderLinesNewestFirst.length === 0) return existingLinesNewestFirst; - - const overlapSize = getOverlapSize(existingLinesNewestFirst, olderLinesNewestFirst); - return existingLinesNewestFirst.concat(olderLinesNewestFirst.slice(overlapSize)); -} - -type AssistantContentBlock = - | { type: 'text'; text?: string } - | { type: 'thinking'; thinking?: string } - | { type: 'tool_use'; id?: string; name?: string; input?: Record } - | { type: string; [key: string]: unknown }; - -function filterStreamJsonText( - linesNewestFirst: string[], - queryRaw: string, - filter: ClaudeLogsFilterState -): string { - const q = queryRaw.trim().toLowerCase(); - const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); - - let currentStream: StreamType | null = null; - let lastEmittedStream: StreamType | null = null; - const out: string[] = []; - - const emitMarker = (): void => { - if (!currentStream) return; - if (lastEmittedStream === currentStream) return; - out.push(currentStream === 'stdout' ? '[stdout]' : '[stderr]'); - lastEmittedStream = currentStream; - }; - - const extractBlocks = (parsed: Record): AssistantContentBlock[] | null => { - if (parsed.type !== 'assistant') return null; - if (Array.isArray(parsed.content)) { - return parsed.content as AssistantContentBlock[]; - } - const msg = parsed.message; - if (msg && typeof msg === 'object') { - const inner = msg as Record; - if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[]; - } - return null; - }; - - const writeBlocks = ( - parsed: Record, - blocks: AssistantContentBlock[] - ): Record => { - if (Array.isArray(parsed.content)) { - return { ...parsed, content: blocks }; - } - const msg = parsed.message; - if (msg && typeof msg === 'object') { - return { ...parsed, message: { ...(msg as Record), content: blocks } }; - } - return parsed; - }; - - for (const rawLine of chronological) { - const line = rawLine.trimEnd(); - if (!line) continue; - - if (line === '[stdout]' || line === '[stderr]') { - currentStream = line === '[stdout]' ? 'stdout' : 'stderr'; - continue; - } - - if (currentStream && !filter.streams.has(currentStream)) { - continue; - } - - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - // Non-JSON lines are ignored to keep view consistent with CliLogsRichView. - continue; - } - - if (!parsed || typeof parsed !== 'object') continue; - const obj = parsed as Record; - - const blocks = extractBlocks(obj); - if (!blocks) { - // Keep only assistant messages for now (CliLogsRichView renders these richly). - continue; - } - - const filteredBlocks = blocks.filter((b) => { - if (!b || typeof b !== 'object') return false; - if (b.type === 'text') return filter.kinds.has('output'); - if (b.type === 'thinking') return filter.kinds.has('thinking'); - if (b.type === 'tool_use') return filter.kinds.has('tool'); - // Unknown block types: keep (they're rare, and dropping can hide content) - return true; - }); - if (filteredBlocks.length === 0) continue; - - const searchTextParts: string[] = []; - for (const b of filteredBlocks) { - if (b.type === 'text' && typeof b.text === 'string') searchTextParts.push(b.text); - if (b.type === 'thinking' && typeof b.thinking === 'string') searchTextParts.push(b.thinking); - if (b.type === 'tool_use') { - if (typeof b.name === 'string') searchTextParts.push(b.name); - if (b.input && typeof b.input === 'object') { - try { - searchTextParts.push(JSON.stringify(b.input)); - } catch { - // ignore - } - } - } - } - const haystack = searchTextParts.join('\n').toLowerCase(); - if (q && !haystack.includes(q)) { - continue; - } - - emitMarker(); - const nextObj = writeBlocks(obj, filteredBlocks); - out.push(JSON.stringify(nextObj)); - } - - return out.join('\n'); -} +// ============================================================================= +// Main component +// ============================================================================= export const ClaudeLogsSection = ({ teamName, position = 'inline', }: ClaudeLogsSectionProps): React.JSX.Element => { - const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); - const [loadedCount, setLoadedCount] = useState(PAGE_SIZE); - const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); - const [pending, setPending] = useState(null); - const [pendingNewCount, setPendingNewCount] = useState(0); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const inFlightRef = useRef(false); - const loadingMoreRef = useRef(false); - const applyingPendingRef = useRef(false); - const atTopRef = useRef(true); - const latestRef = useRef(null); - const logContainerRef = useRef(null); - const committedRef = useRef({ lines: [], total: 0, hasMore: false }); - const pendingCountRef = useRef(0); - const [searchQuery, setSearchQuery] = useState(''); - const [filter, setFilter] = useState(() => ({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - })); - const [filterOpen, setFilterOpen] = useState(false); + const ctrl = useClaudeLogsController(teamName); + const [dialogOpen, setDialogOpen] = useState(false); + const isSidebar = position === 'sidebar'; - const isNearBottom = useCallback( - (scrollTop: number, scrollHeight: number, clientHeight: number) => { - return scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD_PX; - }, - [] + + const sectionHeaderExtra = useMemo( + () => ( + + {ctrl.online ? ( + + + + + ) : null} + {ctrl.lastLogPreview ? : null} + + ), + [ctrl.online, ctrl.lastLogPreview, isSidebar] ); - useEffect(() => { - setLoadedCount(PAGE_SIZE); - setData({ lines: [], total: 0, hasMore: false }); - setPending(null); - setPendingNewCount(0); - latestRef.current = null; - atTopRef.current = true; - setError(null); - setSearchQuery(''); - setFilter({ - streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), - kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), - }); - }, [teamName]); - - useEffect(() => { - committedRef.current = data; - }, [data]); - - useEffect(() => { - pendingCountRef.current = pendingNewCount; - }, [pendingNewCount]); - - useEffect(() => { - let cancelled = false; - - const computeNewCount = ( - committed: TeamClaudeLogsResponse, - latest: TeamClaudeLogsResponse - ): number => { - if (committed.lines.length === 0) return latest.lines.length; - const marker = committed.lines[0]; - const idx = latest.lines.indexOf(marker); - if (idx >= 0) return idx; - const diff = - (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); - return Math.max(0, diff); - }; - - const fetchLogs = async (): Promise => { - if (inFlightRef.current) return; - inFlightRef.current = true; - try { - setLoading(true); - const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); - if (cancelled) return; - latestRef.current = next; - if (atTopRef.current) { - setData(next); - setPending(null); - setPendingNewCount(0); - } else { - setPending(next); - const base = computeNewCount(committedRef.current, next); - setPendingNewCount((prev) => Math.max(prev, base)); - } - setError(null); - } catch (e) { - if (cancelled) return; - setError(e instanceof Error ? e.message : String(e)); - } finally { - inFlightRef.current = false; - if (!cancelled) setLoading(false); - } - }; - - void fetchLogs(); - const id = window.setInterval(() => void fetchLogs(), POLL_MS); - return () => { - cancelled = true; - window.clearInterval(id); - }; - }, [teamName, loadedCount]); - - const loadOlderLogs = useCallback(async (): Promise => { - if (loadingMoreRef.current || inFlightRef.current) return; - - const current = committedRef.current; - if (!current.hasMore) return; - - loadingMoreRef.current = true; - setLoadingMore(true); - - try { - const older = await api.teams.getClaudeLogs(teamName, { - offset: current.lines.length + pendingCountRef.current, - limit: PAGE_SIZE, - }); - - setData((prev) => ({ - ...prev, - lines: appendOlderLines(prev.lines, older.lines), - total: older.total, - hasMore: older.hasMore, - updatedAt: older.updatedAt ?? prev.updatedAt, - })); - setLoadedCount((count) => count + PAGE_SIZE); - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - loadingMoreRef.current = false; - setLoadingMore(false); - } - }, [teamName]); - - useEffect(() => { - const el = logContainerRef.current; - if (!el || loading || loadingMore || !data.hasMore || data.lines.length === 0) return; - - if ( - el.scrollHeight <= el.clientHeight || - isNearBottom(el.scrollTop, el.scrollHeight, el.clientHeight) - ) { - void loadOlderLogs(); - } - }, [data.hasMore, data.lines.length, isNearBottom, loadOlderLogs, loading, loadingMore]); - - const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); - const badge = data.total > 0 ? data.total : undefined; - const showMoreVisible = data.hasMore || loadingMore; - - const lastLogPreview = useMemo( - () => (data.lines.length > 0 ? extractLastLogPreview(data.lines) : null), - [data.lines] - ); - - const filteredText = useMemo(() => { - if (data.lines.length === 0) return ''; - const isDefault = - filter.streams.size === DEFAULT_CLAUDE_LOGS_FILTER.streams.size && - filter.kinds.size === DEFAULT_CLAUDE_LOGS_FILTER.kinds.size && - [...DEFAULT_CLAUDE_LOGS_FILTER.streams].every((s) => filter.streams.has(s)) && - [...DEFAULT_CLAUDE_LOGS_FILTER.kinds].every((k) => filter.kinds.has(k)); - - if (!searchQuery.trim() && isDefault) { - return normalizeToStreamJsonText(data.lines); - } - return filterStreamJsonText(data.lines, searchQuery, filter); - }, [data.lines, searchQuery, filter]); - - const applyPending = useCallback(async (): Promise => { - if (applyingPendingRef.current) return; - - applyingPendingRef.current = true; - try { - let latest = latestRef.current ?? pending; - const expectedVisibleCount = latest ? Math.min(loadedCount, latest.total) : loadedCount; - - if (!latest || latest.lines.length < expectedVisibleCount) { - latest = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); - latestRef.current = latest; - } - - setData(latest); - setPending(null); - setPendingNewCount(0); - setError(null); - - // Jump to newest - if (logContainerRef.current) { - logContainerRef.current.scrollTop = 0; - } - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - applyingPendingRef.current = false; - } - }, [loadedCount, pending, teamName]); - return ( - - - - } - badge={badge} - headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1' : undefined} - headerExtra={ - - {online ? ( - - - - - ) : null} - {lastLogPreview ? : null} - - } - defaultOpen={false} - // Prevent scroll anchoring from "pulling" the parent container when logs update. - contentClassName="pt-0 [overflow-anchor:none]" - > -
- - {data.total > 0 ? ( - <> - Showing {Math.min(data.total, data.lines.length)}{' '} - of {data.total} - - ) : isAlive ? ( - 'No logs yet.' - ) : ( - 'Team is not running.' - )} - -
- {data.total > 0 ? ( - <> -
- - setSearchQuery(e.target.value)} - className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" - /> - {searchQuery && ( - - )} -
- - - ) : null} - {pendingNewCount > 0 && ( - - )} -
-
+ <> + + +
+ } + badge={ctrl.badge} + afterBadge={ + ctrl.data.total > 0 ? ( + + + + + Fullscreen + + ) : undefined + } + headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-3' : 'pr-3'} + headerExtra={sectionHeaderExtra} + defaultOpen={false} + contentClassName="pt-0 [overflow-anchor:none]" + > + {/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */} + {dialogOpen ? ( +
+ + Viewing in fullscreen mode +
+ ) : ( + + )} + -
- {error ?

{error}

: null} - {!error && filteredText.trim().length > 0 ? ( - { - logContainerRef.current = el; - }} - onScroll={({ scrollTop, scrollHeight, clientHeight }) => { - const atTop = scrollTop <= 8; - atTopRef.current = atTop; - if (atTop && pendingCountRef.current > 0) { - void applyPending(); - return; - } - - if (isNearBottom(scrollTop, scrollHeight, clientHeight)) { - void loadOlderLogs(); - } - }} - footer={ - showMoreVisible ? ( -
- -
- ) : null - } - /> - ) : null} - {!error && data.lines.length === 0 && isAlive ? ( -

- {loading ? 'Loading…' : 'No logs captured.'} -

- ) : null} - {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( -

No matching logs.

- ) : null} -
- + + ); }; diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 65a67f35..9cd465c8 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -4,7 +4,10 @@ * Renders CLI stream-json logs using the same rich components as session views: * thinking blocks, tool call cards, markdown text output. * - * Replaces raw JSON display in ProvisioningProgressBlock. + * Supports two modes: + * - **Uncontrolled** (default): manages its own expansion and viewport state internally. + * - **Controlled**: accepts external state + callbacks so the parent can persist + * expansion and viewport across surface switches (e.g. compact ↔ dialog). */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -17,7 +20,26 @@ import { Bot, ChevronRight } from 'lucide-react'; import type { StreamJsonGroup, SubagentSection } from '@renderer/utils/streamJsonParser'; -type CliLogsOrder = 'oldest-first' | 'newest-first'; +// ============================================================================= +// Viewport state types +// ============================================================================= + +export type CliLogsOrder = 'oldest-first' | 'newest-first'; + +export type ClaudeLogsViewportState = + | { mode: 'edge'; edge: 'newest' | 'oldest' } + | { mode: 'anchor'; anchorId: string; offsetTop: number }; + +export interface ClaudeLogsViewerState { + collapsedGroupIds: Set; + expandedItemIds: Set; + expandedSubagentIds: Set; + viewport: ClaudeLogsViewportState; +} + +// ============================================================================= +// Props +// ============================================================================= interface CliLogsRichViewProps { cliLogsTail: string; @@ -29,8 +51,18 @@ interface CliLogsRichViewProps { className?: string; /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ footer?: React.ReactNode; + + // ── Controlled mode (optional — all-or-nothing) ────────────────────── + /** When provided, the component uses external expansion state. */ + viewerState?: ClaudeLogsViewerState; + /** Called whenever expansion or viewport state changes in controlled mode. */ + onViewerStateChange?: (state: ClaudeLogsViewerState) => void; } +// ============================================================================= +// Helpers +// ============================================================================= + /** * Derives a scoped Set for a single group from the global prefixed Set. * Global keys are stored as `groupId::itemId`; this strips the prefix. @@ -46,6 +78,57 @@ function scopedItemIds(globalIds: Set, groupId: string): Set { return scoped; } +/** Finds the first visible anchor element and returns its id + offset from container top. */ +function computeAnchorViewport(container: HTMLDivElement): ClaudeLogsViewportState | null { + const anchors = container.querySelectorAll('[data-log-anchor]'); + const containerTop = container.getBoundingClientRect().top; + const containerBottom = containerTop + container.clientHeight; + + for (const anchor of anchors) { + const rect = anchor.getBoundingClientRect(); + // First anchor whose bottom is within or below the container top + if (rect.bottom > containerTop && rect.top < containerBottom) { + const anchorId = anchor.dataset.logAnchor; + if (anchorId) { + return { mode: 'anchor', anchorId, offsetTop: rect.top - containerTop }; + } + } + } + return null; +} + +/** Restores scroll position from a viewport state after mount/layout. */ +function restoreViewport( + container: HTMLDivElement, + viewport: ClaudeLogsViewportState, + order: CliLogsOrder +): void { + if (viewport.mode === 'edge') { + if (viewport.edge === 'newest') { + // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation + container.scrollTop = order === 'newest-first' ? 0 : container.scrollHeight; + } else { + // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation + container.scrollTop = order === 'newest-first' ? container.scrollHeight : 0; + } + return; + } + + // Anchor mode — find the element and adjust scroll + const el = container.querySelector(`[data-log-anchor="${viewport.anchorId}"]`); + if (!el) return; + + const containerRect = container.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const currentOffset = elRect.top - containerRect.top; + // eslint-disable-next-line no-param-reassign -- DOM scroll positioning requires direct mutation + container.scrollTop += currentOffset - viewport.offsetTop; +} + +// ============================================================================= +// Sub-components +// ============================================================================= + /** * Single-item group rendered flat (no collapsible wrapper). */ @@ -70,13 +153,16 @@ const FlatGroupItem = ({ ); return ( - +
+ +
); }; @@ -109,7 +195,10 @@ const StreamGroup = ({ ); return ( -
+
)} @@ -177,7 +267,10 @@ const SubagentSectionBlock = ({ const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`; return ( -
+
+ ); + } return ( s.teams); + const openTeamTab = useStore((s) => s.openTeamTab); const teamNames = useMemo( () => teams.filter((t) => !t.deletedAt).map((t) => t.teamName), [teams] @@ -526,7 +554,9 @@ export const ActivityItem = ({ ) : null} {/* Sender avatar + name badge */} - {crossTeamOrigin ? : null} + {crossTeamOrigin ? ( + + ) : null} - {crossTeamTarget ? : null} + {crossTeamTarget ? ( + + ) : null} {crossTeamSentMemberName || !crossTeamTarget ? ( +
{events.map((event, idx) => { const isLast = idx === events.length - 1; const time = formatTime(event.timestamp); @@ -35,14 +35,14 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
{/* Timeline line + dot */}
-
- {!isLast &&
} +
+ {!isLast &&
}
{/* Content */} -
+
{time} diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index fea7e9f8..4e8d99ff 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -102,7 +102,7 @@ const TruncatedTitle = ({ const checkTruncation = useCallback(() => { const el = ref.current; if (el) { - setIsTruncated(el.scrollWidth > el.clientWidth); + setIsTruncated(el.scrollHeight > el.clientHeight); } }, []); @@ -111,7 +111,7 @@ const TruncatedTitle = ({
{text} @@ -141,11 +141,11 @@ const CancelTaskButton = ({ @@ -201,7 +201,7 @@ const TaskActionIconButton = ({ + + + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + + + + + + + {sidebarSearchVisible ? 'Hide search' : 'Search messages'} + +
- {/* Search & filter bar */} -
- {searchAndFilterBar} -
+ {/* Search & filter bar (toggleable) */} + {sidebarSearchVisible && ( +
+ {searchAndFilterControls} +
+ )} {/* Scrollable content */}
{messagesContent} diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index b0f09a6d..a03ecb8f 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -16,6 +16,7 @@ import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder'; import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; import { ChevronDown, Clock, X } from 'lucide-react'; +import { ChangesLoadingAnimation } from './ChangesLoadingAnimation'; import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils'; import { ContinuousScrollView } from './ContinuousScrollView'; import { FileEditTimeline } from './FileEditTimeline'; @@ -1151,11 +1152,7 @@ export const ChangeReviewDialog = ({ {/* Content */}
- {changeSetLoading && ( -
- Loading changes... -
- )} + {changeSetLoading && } {changeSetError && (
diff --git a/src/renderer/components/team/review/ChangesLoadingAnimation.tsx b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx new file mode 100644 index 00000000..748bfa65 --- /dev/null +++ b/src/renderer/components/team/review/ChangesLoadingAnimation.tsx @@ -0,0 +1,85 @@ +import { FileCode, FileDiff, FileText, GitCommit, RefreshCw } from 'lucide-react'; + +const floatingIcons = [ + { Icon: FileText, delay: '0s', x: -40, y: -30 }, + { Icon: FileDiff, delay: '0.4s', x: 35, y: -25 }, + { Icon: FileCode, delay: '0.8s', x: -30, y: 20 }, + { Icon: GitCommit, delay: '1.2s', x: 40, y: 15 }, + { Icon: RefreshCw, delay: '1.6s', x: 0, y: -40 }, +]; + +export const ChangesLoadingAnimation = (): React.JSX.Element => { + return ( +
+ {/* Animated icons cluster */} +
+ {/* Central pulsing ring */} +
+
+ + {/* Floating file icons */} + {floatingIcons.map(({ Icon, delay, x, y }, i) => ( +
+ +
+ ))} + + {/* Center icon */} +
+ +
+
+ + {/* Progress bar */} +
+
+
+ + {/* Text */} +

+ Loading changes... +

+ + +
+ ); +}; diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts new file mode 100644 index 00000000..6f190a88 --- /dev/null +++ b/src/renderer/components/team/useClaudeLogsController.ts @@ -0,0 +1,655 @@ +/** + * useClaudeLogsController + * + * Single controller hook that owns all Claude logs data-fetching, polling, + * pending-buffering, pagination, search, filter, and viewer state. + * + * Used by ClaudeLogsSection to share one source of truth between the + * compact sidebar panel and the fullscreen dialog. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { useStore } from '@renderer/store'; + +import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; + +import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; +import type { ClaudeLogsViewerState } from './CliLogsRichView'; +import type { TeamClaudeLogsResponse } from '@shared/types'; + +// ============================================================================= +// Constants +// ============================================================================= + +const PAGE_SIZE = 100; +const POLL_MS = 2000; +const ONLINE_WINDOW_MS = 10_000; +const LOAD_MORE_THRESHOLD_PX = 48; + +// ============================================================================= +// Types +// ============================================================================= + +type StreamType = 'stdout' | 'stderr'; + +/** Info about the most recent log item for the header preview. */ +export interface LastLogPreview { + type: 'output' | 'thinking' | 'tool'; + label: string; + summary: string; +} + +export interface ClaudeLogsController { + // Data state + data: TeamClaudeLogsResponse; + loading: boolean; + loadingMore: boolean; + error: string | null; + pendingNewCount: number; + isAlive: boolean; + + // Computed + filteredText: string; + online: boolean; + badge: number | undefined; + showMoreVisible: boolean; + lastLogPreview: LastLogPreview | null; + + // Search & filter + searchQuery: string; + setSearchQuery: (q: string) => void; + filter: ClaudeLogsFilterState; + setFilter: (f: ClaudeLogsFilterState) => void; + filterOpen: boolean; + setFilterOpen: (open: boolean) => void; + + // Viewer state (expansion + viewport) + viewerState: ClaudeLogsViewerState; + onViewerStateChange: (state: ClaudeLogsViewerState) => void; + + // Actions + applyPending: () => Promise; + loadOlderLogs: () => Promise; + + // Scroll integration + containerRefCallback: (el: HTMLDivElement | null) => void; + handleScroll: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; +} + +// ============================================================================= +// Internal helpers +// ============================================================================= + +/** + * System JSON subtypes that carry no user-facing value in the logs UI. + */ +const SYSTEM_NOISE_SUBTYPES = new Set(['hook_started', 'hook_response', 'init']); + +function isSystemNoiseLine(jsonStr: string): boolean { + try { + const parsed: unknown = JSON.parse(jsonStr); + if (!parsed || typeof parsed !== 'object') return false; + const obj = parsed as Record; + if (obj.type !== 'system') return false; + if (typeof obj.subtype === 'string') { + return SYSTEM_NOISE_SUBTYPES.has(obj.subtype); + } + return true; + } catch { + return false; + } +} + +function isRecent(updatedAt: string | undefined): boolean { + if (!updatedAt) return false; + const t = Date.parse(updatedAt); + if (Number.isNaN(t)) return false; + return Date.now() - t <= ONLINE_WINDOW_MS; +} + +function extractLastLogPreview(linesNewestFirst: string[]): LastLogPreview | null { + for (const rawLine of linesNewestFirst) { + const line = rawLine?.trim(); + if (!line) continue; + if (line === '[stdout]' || line === '[stderr]') continue; + + let content = line; + if (line.startsWith('[stdout] ')) content = line.slice('[stdout] '.length); + else if (line.startsWith('[stderr] ')) content = line.slice('[stderr] '.length); + + if (content.trimStart().startsWith('{') && isSystemNoiseLine(content)) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + continue; + } + + if (!parsed || typeof parsed !== 'object') continue; + const obj = parsed as Record; + if (obj.type !== 'assistant') continue; + + interface ContentBlock { + type: string; + text?: string; + thinking?: string; + name?: string; + } + let blocks: ContentBlock[] | null = null; + if (Array.isArray(obj.content)) { + blocks = obj.content as ContentBlock[]; + } else if (obj.message && typeof obj.message === 'object') { + const msg = obj.message as Record; + if (Array.isArray(msg.content)) blocks = msg.content as ContentBlock[]; + } + + if (!blocks || blocks.length === 0) continue; + + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { + return { type: 'output', label: 'Output', summary: b.text.trim().replace(/\n+/g, ' ') }; + } + if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) { + return { + type: 'thinking', + label: 'Thinking', + summary: b.thinking.trim().replace(/\n+/g, ' '), + }; + } + if (b.type === 'tool_use' && typeof b.name === 'string') { + return { type: 'tool', label: b.name, summary: '' }; + } + } + } + return null; +} + +function normalizeToStreamJsonText(linesNewestFirst: string[]): string { + const chronological = [...linesNewestFirst].reverse(); + const out: string[] = []; + let lastStream: StreamType | null = null; + + const pushMarker = (stream: StreamType): void => { + if (lastStream === stream) return; + lastStream = stream; + out.push(stream === 'stdout' ? '[stdout]' : '[stderr]'); + }; + + for (const rawLine of chronological) { + const line = rawLine ?? ''; + if (line === '[stdout]' || line === '[stderr]') { + lastStream = line === '[stdout]' ? 'stdout' : 'stderr'; + out.push(line); + continue; + } + + let content = line; + if (line.startsWith('[stdout] ')) { + pushMarker('stdout'); + content = line.slice('[stdout] '.length); + } else if (line.startsWith('[stderr] ')) { + pushMarker('stderr'); + content = line.slice('[stderr] '.length); + } + + if (content.trimStart().startsWith('{') && isSystemNoiseLine(content)) { + continue; + } + + if (content !== line) { + out.push(content); + } else { + out.push(line); + } + } + + return out.join('\n'); +} + +function getOverlapSize( + existingLinesNewestFirst: string[], + olderLinesNewestFirst: string[] +): number { + const maxOverlap = Math.min(existingLinesNewestFirst.length, olderLinesNewestFirst.length); + + for (let size = maxOverlap; size > 0; size -= 1) { + let matches = true; + for (let i = 0; i < size; i += 1) { + if ( + existingLinesNewestFirst[existingLinesNewestFirst.length - size + i] !== + olderLinesNewestFirst[i] + ) { + matches = false; + break; + } + } + if (matches) return size; + } + + return 0; +} + +function appendOlderLines( + existingLinesNewestFirst: string[], + olderLinesNewestFirst: string[] +): string[] { + if (existingLinesNewestFirst.length === 0) return olderLinesNewestFirst; + if (olderLinesNewestFirst.length === 0) return existingLinesNewestFirst; + + const overlapSize = getOverlapSize(existingLinesNewestFirst, olderLinesNewestFirst); + return existingLinesNewestFirst.concat(olderLinesNewestFirst.slice(overlapSize)); +} + +type AssistantContentBlock = + | { type: 'text'; text?: string } + | { type: 'thinking'; thinking?: string } + | { type: 'tool_use'; id?: string; name?: string; input?: Record } + | { type: string; [key: string]: unknown }; + +function filterStreamJsonText( + linesNewestFirst: string[], + queryRaw: string, + filter: ClaudeLogsFilterState +): string { + const q = queryRaw.trim().toLowerCase(); + const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); + + let currentStream: StreamType | null = null; + let lastEmittedStream: StreamType | null = null; + const out: string[] = []; + + const emitMarker = (): void => { + if (!currentStream) return; + if (lastEmittedStream === currentStream) return; + out.push(currentStream === 'stdout' ? '[stdout]' : '[stderr]'); + lastEmittedStream = currentStream; + }; + + const extractBlocks = (parsed: Record): AssistantContentBlock[] | null => { + if (parsed.type !== 'assistant') return null; + if (Array.isArray(parsed.content)) { + return parsed.content as AssistantContentBlock[]; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + const inner = msg as Record; + if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[]; + } + return null; + }; + + const writeBlocks = ( + parsed: Record, + blocks: AssistantContentBlock[] + ): Record => { + if (Array.isArray(parsed.content)) { + return { ...parsed, content: blocks }; + } + const msg = parsed.message; + if (msg && typeof msg === 'object') { + return { ...parsed, message: { ...(msg as Record), content: blocks } }; + } + return parsed; + }; + + for (const rawLine of chronological) { + const line = rawLine.trimEnd(); + if (!line) continue; + + if (line === '[stdout]' || line === '[stderr]') { + currentStream = line === '[stdout]' ? 'stdout' : 'stderr'; + continue; + } + + if (currentStream && !filter.streams.has(currentStream)) { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + + if (!parsed || typeof parsed !== 'object') continue; + const obj = parsed as Record; + + const blocks = extractBlocks(obj); + if (!blocks) continue; + + const filteredBlocks = blocks.filter((b) => { + if (!b || typeof b !== 'object') return false; + if (b.type === 'text') return filter.kinds.has('output'); + if (b.type === 'thinking') return filter.kinds.has('thinking'); + if (b.type === 'tool_use') return filter.kinds.has('tool'); + return true; + }); + if (filteredBlocks.length === 0) continue; + + const searchTextParts: string[] = []; + for (const b of filteredBlocks) { + if (b.type === 'text' && typeof b.text === 'string') searchTextParts.push(b.text); + if (b.type === 'thinking' && typeof b.thinking === 'string') searchTextParts.push(b.thinking); + if (b.type === 'tool_use') { + if (typeof b.name === 'string') searchTextParts.push(b.name); + if (b.input && typeof b.input === 'object') { + try { + searchTextParts.push(JSON.stringify(b.input)); + } catch { + // ignore + } + } + } + } + const haystack = searchTextParts.join('\n').toLowerCase(); + if (q && !haystack.includes(q)) { + continue; + } + + emitMarker(); + const nextObj = writeBlocks(obj, filteredBlocks); + out.push(JSON.stringify(nextObj)); + } + + return out.join('\n'); +} + +// ============================================================================= +// Default viewer state +// ============================================================================= + +function createDefaultViewerState(): ClaudeLogsViewerState { + return { + collapsedGroupIds: new Set(), + expandedItemIds: new Set(), + expandedSubagentIds: new Set(), + viewport: { mode: 'edge', edge: 'newest' }, + }; +} + +// ============================================================================= +// Hook +// ============================================================================= + +export function useClaudeLogsController(teamName: string): ClaudeLogsController { + const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); + + // ── Data state ──────────────────────────────────────────────────────── + const [loadedCount, setLoadedCount] = useState(PAGE_SIZE); + const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); + const [pending, setPending] = useState(null); + const [pendingNewCount, setPendingNewCount] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + // ── Search & filter state ───────────────────────────────────────────── + const [searchQuery, setSearchQuery] = useState(''); + const [filter, setFilter] = useState(() => ({ + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + })); + const [filterOpen, setFilterOpen] = useState(false); + + // ── Viewer state (expansion + viewport) ─────────────────────────────── + const [viewerState, setViewerState] = useState(createDefaultViewerState); + + const onViewerStateChange = useCallback((state: ClaudeLogsViewerState) => { + setViewerState(state); + }, []); + + // ── Internal refs ───────────────────────────────────────────────────── + const inFlightRef = useRef(false); + const loadingMoreRef = useRef(false); + const applyingPendingRef = useRef(false); + const atTopRef = useRef(true); + const latestRef = useRef(null); + const logContainerRef = useRef(null); + const committedRef = useRef({ lines: [], total: 0, hasMore: false }); + const pendingCountRef = useRef(0); + + // ── Reset on team change ────────────────────────────────────────────── + useEffect(() => { + setLoadedCount(PAGE_SIZE); + setData({ lines: [], total: 0, hasMore: false }); + setPending(null); + setPendingNewCount(0); + latestRef.current = null; + atTopRef.current = true; + setError(null); + setSearchQuery(''); + setFilter({ + streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), + kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), + }); + setViewerState(createDefaultViewerState()); + }, [teamName]); + + // ── Sync refs ───────────────────────────────────────────────────────── + useEffect(() => { + committedRef.current = data; + }, [data]); + + useEffect(() => { + pendingCountRef.current = pendingNewCount; + }, [pendingNewCount]); + + // ── Polling ─────────────────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + + const computeNewCount = ( + committed: TeamClaudeLogsResponse, + latest: TeamClaudeLogsResponse + ): number => { + if (committed.lines.length === 0) return latest.lines.length; + const marker = committed.lines[0]; + const idx = latest.lines.indexOf(marker); + if (idx >= 0) return idx; + const diff = + (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); + return Math.max(0, diff); + }; + + const fetchLogs = async (): Promise => { + if (inFlightRef.current) return; + inFlightRef.current = true; + try { + setLoading(true); + const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); + if (cancelled) return; + latestRef.current = next; + if (atTopRef.current) { + setData(next); + setPending(null); + setPendingNewCount(0); + } else { + setPending(next); + const base = computeNewCount(committedRef.current, next); + setPendingNewCount((prev) => Math.max(prev, base)); + } + setError(null); + } catch (e) { + if (cancelled) return; + setError(e instanceof Error ? e.message : String(e)); + } finally { + inFlightRef.current = false; + if (!cancelled) setLoading(false); + } + }; + + void fetchLogs(); + const id = window.setInterval(() => void fetchLogs(), POLL_MS); + return () => { + cancelled = true; + window.clearInterval(id); + }; + }, [teamName, loadedCount]); + + // ── Load older logs ─────────────────────────────────────────────────── + const loadOlderLogs = useCallback(async (): Promise => { + if (loadingMoreRef.current || inFlightRef.current) return; + + const current = committedRef.current; + if (!current.hasMore) return; + + loadingMoreRef.current = true; + setLoadingMore(true); + + try { + const older = await api.teams.getClaudeLogs(teamName, { + offset: current.lines.length + pendingCountRef.current, + limit: PAGE_SIZE, + }); + + setData((prev) => ({ + ...prev, + lines: appendOlderLines(prev.lines, older.lines), + total: older.total, + hasMore: older.hasMore, + updatedAt: older.updatedAt ?? prev.updatedAt, + })); + setLoadedCount((count) => count + PAGE_SIZE); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + loadingMoreRef.current = false; + setLoadingMore(false); + } + }, [teamName]); + + // ── Auto-load when content fits in container ────────────────────────── + const isNearBottom = useCallback( + (scrollTop: number, scrollHeight: number, clientHeight: number) => { + return scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD_PX; + }, + [] + ); + + useEffect(() => { + const el = logContainerRef.current; + if (!el || loading || loadingMore || !data.hasMore || data.lines.length === 0) return; + + if ( + el.scrollHeight <= el.clientHeight || + isNearBottom(el.scrollTop, el.scrollHeight, el.clientHeight) + ) { + void loadOlderLogs(); + } + }, [data.hasMore, data.lines.length, isNearBottom, loadOlderLogs, loading, loadingMore]); + + // ── Apply pending logs ──────────────────────────────────────────────── + const applyPending = useCallback(async (): Promise => { + if (applyingPendingRef.current) return; + + applyingPendingRef.current = true; + try { + let latest = latestRef.current ?? pending; + const expectedVisibleCount = latest ? Math.min(loadedCount, latest.total) : loadedCount; + + if (!latest || latest.lines.length < expectedVisibleCount) { + latest = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); + latestRef.current = latest; + } + + setData(latest); + setPending(null); + setPendingNewCount(0); + setError(null); + + if (logContainerRef.current) { + logContainerRef.current.scrollTop = 0; + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + applyingPendingRef.current = false; + } + }, [loadedCount, pending, teamName]); + + // ── Computed values ─────────────────────────────────────────────────── + const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); + const badge = data.total > 0 ? data.total : undefined; + const showMoreVisible = data.hasMore || loadingMore; + + const lastLogPreview = useMemo( + () => (data.lines.length > 0 ? extractLastLogPreview(data.lines) : null), + [data.lines] + ); + + const filteredText = useMemo(() => { + if (data.lines.length === 0) return ''; + const isDefault = + filter.streams.size === DEFAULT_CLAUDE_LOGS_FILTER.streams.size && + filter.kinds.size === DEFAULT_CLAUDE_LOGS_FILTER.kinds.size && + [...DEFAULT_CLAUDE_LOGS_FILTER.streams].every((s) => filter.streams.has(s)) && + [...DEFAULT_CLAUDE_LOGS_FILTER.kinds].every((k) => filter.kinds.has(k)); + + if (!searchQuery.trim() && isDefault) { + return normalizeToStreamJsonText(data.lines); + } + return filterStreamJsonText(data.lines, searchQuery, filter); + }, [data.lines, searchQuery, filter]); + + // ── Container ref callback ──────────────────────────────────────────── + const containerRefCallback = useCallback((el: HTMLDivElement | null) => { + logContainerRef.current = el; + }, []); + + // ── Scroll handler ──────────────────────────────────────────────────── + const handleScroll = useCallback( + ({ + scrollTop, + scrollHeight, + clientHeight, + }: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + }) => { + const atTop = scrollTop <= 8; + atTopRef.current = atTop; + if (atTop && pendingCountRef.current > 0) { + void applyPending(); + return; + } + + if (isNearBottom(scrollTop, scrollHeight, clientHeight)) { + void loadOlderLogs(); + } + }, + [applyPending, isNearBottom, loadOlderLogs] + ); + + return { + data, + loading, + loadingMore, + error, + pendingNewCount, + isAlive, + filteredText, + online, + badge, + showMoreVisible, + lastLogPreview, + searchQuery, + setSearchQuery, + filter, + setFilter, + filterOpen, + setFilterOpen, + viewerState, + onViewerStateChange, + applyPending, + loadOlderLogs, + containerRefCallback, + handleScroll, + }; +} diff --git a/src/renderer/components/ui/MentionInteractionLayer.tsx b/src/renderer/components/ui/MentionInteractionLayer.tsx new file mode 100644 index 00000000..f109cc13 --- /dev/null +++ b/src/renderer/components/ui/MentionInteractionLayer.tsx @@ -0,0 +1,121 @@ +/** + * Interactive overlay layer for inline @mention badges. + * + * Positioned above the textarea (z-20), provides hover tooltips + * showing the member/team name and role on mention spans. + * + * Uses the same mirror div technique (calculateMentionPositions) + * as ChipInteractionLayer to position elements exactly over + * the corresponding @mention tokens in the textarea. + */ + +import * as React from 'react'; + +import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; +import { calculateMentionPositions } from '@renderer/utils/chipUtils'; +import { nameColorSet } from '@renderer/utils/projectColor'; +import { UsersRound } from 'lucide-react'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { MentionPosition } from '@renderer/utils/chipUtils'; + +interface MentionInteractionLayerProps { + suggestions: MentionSuggestion[]; + value: string; + textareaRef: React.RefObject; + scrollTop: number; +} + +export const MentionInteractionLayer = ({ + suggestions, + value, + textareaRef, + scrollTop, +}: MentionInteractionLayerProps): React.JSX.Element | null => { + const [positions, setPositions] = React.useState([]); + const { isLight } = useTheme(); + + React.useLayoutEffect(() => { + const filtered = suggestions.filter( + (s) => s.type !== 'task' && s.type !== 'file' && s.type !== 'folder' + ); + if (filtered.length === 0) { + setPositions([]); + return; + } + const textarea = textareaRef.current; + if (!textarea) return; + setPositions(calculateMentionPositions(textarea, value, filtered)); + }, [suggestions, value, textareaRef]); + + if (positions.length === 0) return null; + + return ( +
+
+ {positions.map((pos, idx) => { + const isTeam = pos.suggestion.type === 'team'; + const colorSet = pos.suggestion.color + ? getTeamColorSet(pos.suggestion.color) + : isTeam + ? nameColorSet(pos.suggestion.name, isLight) + : null; + + return ( + + +
+ + +
+ {isTeam ? ( + + ) : ( + + )} +
+
+ {pos.suggestion.name} +
+ {pos.suggestion.subtitle ? ( +
+ {pos.suggestion.subtitle} +
+ ) : null} +
+
+
+ + ); + })} +
+
+ ); +}; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 39c44cbd..8ca88169 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -28,6 +28,7 @@ import { import { AutoResizeTextarea } from './auto-resize-textarea'; import { ChipInteractionLayer } from './ChipInteractionLayer'; import { CodeChipBadge } from './CodeChipBadge'; +import { MentionInteractionLayer } from './MentionInteractionLayer'; import { MentionSuggestionList } from './MentionSuggestionList'; import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer'; import { UrlInteractionLayer } from './UrlInteractionLayer'; @@ -1081,6 +1082,15 @@ export const MentionableTextarea = React.forwardRef ) : null} + {mentionOverlaySuggestions.length > 0 ? ( + + ) : null} + *, diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts index ffc11395..3247f51f 100644 --- a/src/renderer/utils/chipUtils.ts +++ b/src/renderer/utils/chipUtils.ts @@ -3,6 +3,9 @@ */ import { chipToken } from '@renderer/types/inlineChip'; +import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; + +import type { MentionSuggestion } from '@renderer/types/mention'; import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction'; import { getBasename } from '@shared/utils/platformPath'; @@ -260,6 +263,81 @@ export function calculateInlineMatchPositions( return positions; } +/** + * Calculates screen positions of @mention tokens in textarea using the mirror div technique. + */ +export interface MentionPosition { + suggestion: MentionSuggestion; + top: number; + left: number; + width: number; + height: number; +} + +export function calculateMentionPositions( + textarea: HTMLTextAreaElement, + text: string, + suggestions: MentionSuggestion[] +): MentionPosition[] { + if (suggestions.length === 0 || !text) return []; + + // Filter to member/team suggestions only (not tasks/files) + const mentionSuggestions = suggestions.filter( + (s) => s.type !== 'task' && s.type !== 'file' && s.type !== 'folder' + ); + if (mentionSuggestions.length === 0) return []; + + // Sort by name length descending for greedy matching + const sorted = [...mentionSuggestions].sort((a, b) => { + const aText = getSuggestionInsertionText(a); + const bText = getSuggestionInsertionText(b); + return bText.length - aText.length; + }); + + const matches: InlineMatch[] = []; + let i = 0; + while (i < text.length) { + if (text[i] !== '@') { + i++; + continue; + } + // @ must be at start or after whitespace + if (i > 0) { + const ch = text[i - 1]; + if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') { + i++; + continue; + } + } + let matched = false; + for (const suggestion of sorted) { + const insertionText = getSuggestionInsertionText(suggestion); + const end = i + 1 + insertionText.length; + if (end > text.length) continue; + if (text.slice(i + 1, end).toLowerCase() !== insertionText.toLowerCase()) continue; + // Character after name must be boundary + if (end < text.length) { + const after = text[end]; + // eslint-disable-next-line no-useless-escape + if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue; + } + matches.push({ item: suggestion, start: i, end, token: text.slice(i, end) }); + i = end; + matched = true; + break; + } + if (!matched) i++; + } + + return calculateInlineMatchPositions(textarea, text, matches).map((pos) => ({ + suggestion: pos.item, + top: pos.top, + left: pos.left, + width: pos.width, + height: pos.height, + })); +} + /** * Calculates screen positions of chip tokens in textarea using the mirror div technique. * Creates a temporary mirror div that replicates textarea layout and measures chip spans. diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index f5ec6d44..8d4c7d2a 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -50,6 +50,20 @@ interface ContentBlock { input?: Record; } +/** + * Content-based hash for deterministic fallback IDs that survive + * line reordering and pagination changes. + * Caps input length to avoid hashing huge payloads. + */ +function stableHash(s: string): string { + let h = 0; + const len = Math.min(s.length, 500); + for (let i = 0; i < len; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(36); +} + /** * Attempts to extract the content array from a parsed stream-json line. * Handles both `{ type: "assistant", content: [...] }` (direct) and @@ -201,8 +215,9 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] let currentTimestamp: Date | null = null; let currentGroupId: string | null = null; let currentAgentId: string | undefined = undefined; - // Track how many times each messageId has been seen to disambiguate duplicates + // Track how many times each messageId / hash has been seen to disambiguate duplicates const msgIdOccurrences = new Map(); + const hashOccurrences = new Map(); const flushGroup = (): void => { if (currentItems.length > 0 && currentTimestamp) { @@ -277,7 +292,11 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] currentGroupId = occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`; } else { - currentGroupId = `stream-group-L${lineIndex}`; + // Content-hash fallback: deterministic and survives line reordering + const h = stableHash(trimmed); + const occ = hashOccurrences.get(h) ?? 0; + hashOccurrences.set(h, occ + 1); + currentGroupId = occ === 0 ? `stream-group-H${h}` : `stream-group-H${h}-${occ}`; } } @@ -300,7 +319,6 @@ export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] { const result: StreamJsonEntry[] = []; const pendingDescriptions: string[] = []; const agentDescMap = new Map(); - const sectionCountByAgent = new Map(); let currentRun: { agentId: string; groups: StreamJsonGroup[] } | null = null; const flushRun = (): void => { @@ -312,13 +330,13 @@ export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] { if (item.type === 'tool') toolCount++; } } - const count = sectionCountByAgent.get(currentRun.agentId) ?? 0; - sectionCountByAgent.set(currentRun.agentId, count + 1); - const idSuffix = count > 0 ? `-${count}` : ''; + // Anchor section ID on first group's stable ID instead of occurrence count + const firstGroupId = currentRun.groups[0]?.id ?? ''; + const sectionId = `subagent-${currentRun.agentId}-${stableHash(firstGroupId)}`; result.push({ type: 'subagent-section', section: { - id: `subagent-section-${currentRun.agentId}${idSuffix}`, + id: sectionId, agentId: currentRun.agentId, description: desc, groups: currentRun.groups, diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index 72ee83be..779ce3a5 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -19,14 +19,17 @@ export function buildToolSummary(content: Record[]): string | u } const total = Array.from(counts.values()).reduce((a, b) => a + b, 0); if (total === 0) return undefined; - const parts = Array.from(counts.entries()) - .map(([name, count]) => (count === 1 ? name : `${count} ${name}`)) - .join(', '); - return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`; + return `${total} ${total === 1 ? 'tool' : 'tools'}`; } export function parseToolSummary(summary: string | undefined): ToolSummaryData | null { if (!summary) return null; + // Support new format: "3 tools" + const simpleMatch = /^(\d+)\s+tools?$/.exec(summary); + if (simpleMatch) { + return { total: parseInt(simpleMatch[1], 10), byName: {} }; + } + // Support legacy format: "3 tools (Read, 2 Edit)" const match = /^(\d+)\s+tools?\s+\(([^)]+)\)$/.exec(summary); if (!match) return null; const byName: Record = {}; @@ -42,20 +45,14 @@ export function parseToolSummary(summary: string | undefined): ToolSummaryData | } export function formatToolSummary(data: ToolSummaryData): string { - const parts = Object.entries(data.byName) - .map(([name, count]) => (count === 1 ? name : `${count} ${name}`)) - .join(', '); - return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`; + return `${data.total} ${data.total === 1 ? 'tool' : 'tools'}`; } /** Format tool summary directly from a Map. */ export function formatToolSummaryFromMap(counts: Map): string | undefined { const total = Array.from(counts.values()).reduce((a, b) => a + b, 0); if (total === 0) return undefined; - const parts = Array.from(counts.entries()) - .map(([name, count]) => (count === 1 ? name : `${count} ${name}`)) - .join(', '); - return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`; + return `${total} ${total === 1 ? 'tool' : 'tools'}`; } /** Format tool summary from an array of ToolCallMeta. */