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.
This commit is contained in:
parent
daae1ba82b
commit
a6f9515966
25 changed files with 1794 additions and 1082 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ interface DisplayItemListProps {
|
|||
notificationColorMap?: Map<string, TriggerColor>;
|
||||
/** 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<DisplayItemListProps>): React.JSX.Element => {
|
||||
// Reply-link highlight: when hovering a reply badge, dim everything except the linked pair
|
||||
const [replyLinkToolId, setReplyLinkToolId] = useState<string | null>(null);
|
||||
|
|
@ -127,7 +130,7 @@ export const DisplayItemList = ({
|
|||
element = (
|
||||
<ThinkingItem
|
||||
step={thinkingStep}
|
||||
preview={truncateText(item.content, 150)}
|
||||
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
timestamp={item.timestamp}
|
||||
|
|
@ -153,7 +156,7 @@ export const DisplayItemList = ({
|
|||
element = (
|
||||
<TextItem
|
||||
step={textStep}
|
||||
preview={truncateText(item.content, 150)}
|
||||
preview={truncateText(item.content, previewMaxLength ?? 150)}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
timestamp={item.timestamp}
|
||||
|
|
@ -249,7 +252,7 @@ export const DisplayItemList = ({
|
|||
<BaseItem
|
||||
icon={<MailOpen className="size-4" />}
|
||||
label="Input"
|
||||
summary={truncateText(inputContent, 80)}
|
||||
summary={truncateText(inputContent, previewMaxLength ?? 80)}
|
||||
tokenCount={inputTokenCount}
|
||||
timestamp={item.timestamp}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,10 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
|
||||
{/* Timestamp — rightmost info element */}
|
||||
{timestamp && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums" style={{ color: TOOL_ITEM_MUTED }}>
|
||||
<span
|
||||
className="base-item-timestamp shrink-0 text-[11px] tabular-nums"
|
||||
style={{ color: TOOL_ITEM_MUTED }}
|
||||
>
|
||||
{format(timestamp, 'HH:mm:ss')}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -183,7 +186,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
|||
{/* Expand/collapse chevron */}
|
||||
{hasExpandableContent && (
|
||||
<ChevronRight
|
||||
className={`size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
className={`base-item-chevron size-3 shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
style={{ color: TOOL_ITEM_MUTED }}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<TextItemProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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<ThinkingItemProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -161,7 +161,8 @@ function hastToText(node: HastNode): string {
|
|||
function createViewerMarkdownComponents(
|
||||
searchCtx: SearchContext | null,
|
||||
isLight = false,
|
||||
teamColorByName: ReadonlyMap<string, string> = new Map()
|
||||
teamColorByName: ReadonlyMap<string, string> = 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 (
|
||||
<button
|
||||
type="button"
|
||||
style={badgeStyle}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onTeamClick(teamLabel);
|
||||
}}
|
||||
>
|
||||
<UsersRound size={11} style={{ flexShrink: 0 }} />
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
color: colorSet.text,
|
||||
borderRadius: '3px',
|
||||
boxShadow: `0 0 0 1.5px ${bg}`,
|
||||
fontSize: 'inherit',
|
||||
cursor: 'default',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<span style={badgeStyle}>
|
||||
<UsersRound size={11} style={{ flexShrink: 0 }} />
|
||||
{children}
|
||||
</span>
|
||||
|
|
@ -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<MarkdownViewerProps> = ({
|
|||
const { isLight } = useTheme();
|
||||
const teams = useStore((s) => s.teams);
|
||||
|
||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||
|
||||
const teamColorByName = React.useMemo(() => {
|
||||
const result = new Map<string, string>();
|
||||
for (const team of teams) {
|
||||
|
|
@ -670,10 +689,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
// 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
|
||||
|
|
|
|||
71
src/renderer/components/team/ClaudeLogsDialog.tsx
Normal file
71
src/renderer/components/team/ClaudeLogsDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[90vh] w-[80vw] max-w-none flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
|
||||
<Terminal size={12} />
|
||||
</span>
|
||||
Claude logs
|
||||
{ctrl.badge != null && (
|
||||
<span className="font-mono text-[11px] text-[var(--color-text-muted)]">
|
||||
({ctrl.badge})
|
||||
</span>
|
||||
)}
|
||||
{ctrl.online && (
|
||||
<span className="pointer-events-none relative inline-flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ClaudeLogsPanel
|
||||
ctrl={ctrl}
|
||||
viewerClassName="max-h-full h-full"
|
||||
className="flex h-full flex-col [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
165
src/renderer/components/team/ClaudeLogsPanel.tsx
Normal file
165
src/renderer/components/team/ClaudeLogsPanel.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<span className="font-mono">{Math.min(data.total, data.lines.length)}</span> of{' '}
|
||||
<span className="font-mono">{data.total}</span>
|
||||
</>
|
||||
) : isAlive ? (
|
||||
'No logs yet.'
|
||||
) : (
|
||||
'Team is not running.'
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ClaudeLogsFilterPopover
|
||||
filter={filter}
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
onApply={setFilter}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{pendingNewCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 border-blue-500/30 bg-blue-600 px-2 text-xs text-white hover:bg-blue-500"
|
||||
onClick={applyPending}
|
||||
>
|
||||
+{pendingNewCount} new
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log viewer */}
|
||||
<div className={cn('rounded', loading && 'opacity-80')}>
|
||||
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||
{!error && filteredText.trim().length > 0 ? (
|
||||
<CliLogsRichView
|
||||
cliLogsTail={filteredText}
|
||||
order="newest-first"
|
||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||
className={cn('p-2', viewerClassName)}
|
||||
containerRefCallback={containerRefCallback}
|
||||
onScroll={handleScroll}
|
||||
viewerState={viewerState}
|
||||
onViewerStateChange={onViewerStateChange}
|
||||
footer={
|
||||
showMoreVisible ? (
|
||||
<div className="flex justify-center py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!error && data.lines.length === 0 && isAlive ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
||||
{loading ? 'Loading…' : 'No logs captured.'}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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: <MessageSquare size={12} className="shrink-0" />,
|
||||
|
|
@ -134,6 +22,15 @@ const PREVIEW_ICONS = {
|
|||
tool: <Wrench size={12} className="shrink-0" />,
|
||||
} 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<string, unknown> }
|
||||
| { 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<string, unknown>): 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<string, unknown>;
|
||||
if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const writeBlocks = (
|
||||
parsed: Record<string, unknown>,
|
||||
blocks: AssistantContentBlock[]
|
||||
): Record<string, unknown> => {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
return { ...parsed, content: blocks };
|
||||
}
|
||||
const msg = parsed.message;
|
||||
if (msg && typeof msg === 'object') {
|
||||
return { ...parsed, message: { ...(msg as Record<string, unknown>), 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<string, unknown>;
|
||||
|
||||
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<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
||||
const [pendingNewCount, setPendingNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const applyingPendingRef = useRef(false);
|
||||
const atTopRef = useRef(true);
|
||||
const latestRef = useRef<TeamClaudeLogsResponse | null>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const committedRef = useRef<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const pendingCountRef = useRef(0);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
|
||||
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(
|
||||
() => (
|
||||
<span className={cn('flex min-w-0 items-center gap-2', isSidebar && 'basis-full pt-0.5')}>
|
||||
{ctrl.online ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
{ctrl.lastLogPreview ? <LogPreviewInline preview={ctrl.lastLogPreview} /> : null}
|
||||
</span>
|
||||
),
|
||||
[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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<CollapsibleTeamSection
|
||||
sectionId="claude-logs"
|
||||
title="Claude logs"
|
||||
icon={
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
|
||||
<Terminal size={12} />
|
||||
</span>
|
||||
}
|
||||
badge={badge}
|
||||
headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1' : undefined}
|
||||
headerExtra={
|
||||
<span className={cn('flex min-w-0 items-center gap-2', isSidebar && 'basis-full pt-0.5')}>
|
||||
{online ? (
|
||||
<span
|
||||
className="pointer-events-none relative inline-flex size-2 shrink-0"
|
||||
title="Updating"
|
||||
>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
{lastLogPreview ? <LogPreviewInline preview={lastLogPreview} /> : null}
|
||||
</span>
|
||||
}
|
||||
defaultOpen={false}
|
||||
// Prevent scroll anchoring from "pulling" the parent container when logs update.
|
||||
contentClassName="pt-0 [overflow-anchor:none]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
Showing <span className="font-mono">{Math.min(data.total, data.lines.length)}</span>{' '}
|
||||
of <span className="font-mono">{data.total}</span>
|
||||
</>
|
||||
) : isAlive ? (
|
||||
'No logs yet.'
|
||||
) : (
|
||||
'Team is not running.'
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ClaudeLogsFilterPopover
|
||||
filter={filter}
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
onApply={setFilter}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{pendingNewCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 border-blue-500/30 bg-blue-600 px-2 text-xs text-white hover:bg-blue-500"
|
||||
onClick={applyPending}
|
||||
title="Show newest logs"
|
||||
>
|
||||
+{pendingNewCount} new
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<CollapsibleTeamSection
|
||||
sectionId="claude-logs"
|
||||
title="Claude logs"
|
||||
icon={
|
||||
<span className="inline-flex size-5 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)] shadow-sm">
|
||||
<Terminal size={12} />
|
||||
</span>
|
||||
}
|
||||
badge={ctrl.badge}
|
||||
afterBadge={
|
||||
ctrl.data.total > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto ml-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
aria-label="Open fullscreen logs"
|
||||
>
|
||||
<Expand size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Fullscreen</TooltipContent>
|
||||
</Tooltip>
|
||||
) : 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 ? (
|
||||
<div className="flex items-center gap-2 p-2 text-xs text-[var(--color-text-muted)]">
|
||||
<Expand size={12} />
|
||||
Viewing in fullscreen mode
|
||||
</div>
|
||||
) : (
|
||||
<ClaudeLogsPanel ctrl={ctrl} viewerClassName="max-h-[213px]" />
|
||||
)}
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<div className={cn('rounded', loading && 'opacity-80')}>
|
||||
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||
{!error && filteredText.trim().length > 0 ? (
|
||||
<CliLogsRichView
|
||||
// Parser expects chronological order; UI shows newest-first.
|
||||
cliLogsTail={filteredText}
|
||||
order="newest-first"
|
||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||
className="max-h-[213px] p-2"
|
||||
containerRefCallback={(el) => {
|
||||
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 ? (
|
||||
<div className="flex justify-center py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!error && data.lines.length === 0 && isAlive ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
||||
{loading ? 'Loading…' : 'No logs captured.'}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
<ClaudeLogsDialog open={dialogOpen} onOpenChange={setDialogOpen} ctrl={ctrl} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
expandedItemIds: Set<string>;
|
||||
expandedSubagentIds: Set<string>;
|
||||
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<string>, groupId: string): Set<string> {
|
|||
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<HTMLElement>('[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<HTMLElement>(`[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 (
|
||||
<DisplayItemList
|
||||
items={group.items}
|
||||
onItemClick={handleItemClick}
|
||||
expandedItemIds={groupItemIds}
|
||||
aiGroupId={group.id}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
<div data-log-anchor={group.id}>
|
||||
<DisplayItemList
|
||||
items={group.items}
|
||||
onItemClick={handleItemClick}
|
||||
expandedItemIds={groupItemIds}
|
||||
aiGroupId={group.id}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
previewMaxLength={500}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -109,7 +195,10 @@ const StreamGroup = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div
|
||||
data-log-anchor={group.id}
|
||||
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
|
|
@ -144,6 +233,7 @@ const StreamGroup = ({
|
|||
expandedItemIds={groupItemIds}
|
||||
aiGroupId={group.id}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
previewMaxLength={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -177,7 +267,10 @@ const SubagentSectionBlock = ({
|
|||
const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-l-2 border-amber-500/30 bg-[var(--color-surface)]">
|
||||
<div
|
||||
data-log-anchor={section.id}
|
||||
className="rounded border border-l-2 border-amber-500/30 bg-[var(--color-surface)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
|
|
@ -228,6 +321,21 @@ const SubagentSectionBlock = ({
|
|||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Hook: useToggleSet — shared toggle logic for Set<string> state
|
||||
// =============================================================================
|
||||
|
||||
function toggleInSet(prev: Set<string>, id: string): Set<string> {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main component
|
||||
// =============================================================================
|
||||
|
||||
export const CliLogsRichView = ({
|
||||
cliLogsTail,
|
||||
order = 'oldest-first',
|
||||
|
|
@ -236,15 +344,24 @@ export const CliLogsRichView = ({
|
|||
searchQueryOverride,
|
||||
className,
|
||||
footer,
|
||||
viewerState: controlledState,
|
||||
onViewerStateChange,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToEdgeRef = useRef(true);
|
||||
const lastOrderRef = useRef<CliLogsOrder>(order);
|
||||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
|
||||
// Subagent sections are collapsed by default; track which are expanded
|
||||
const [expandedSubagentIds, setExpandedSubagentIds] = useState<Set<string>>(new Set());
|
||||
const hasRestoredRef = useRef(false);
|
||||
|
||||
// ── Internal state (used in uncontrolled mode) ──────────────────────
|
||||
const [internalCollapsed, setInternalCollapsed] = useState<Set<string>>(new Set());
|
||||
const [internalExpanded, setInternalExpanded] = useState<Set<string>>(new Set());
|
||||
const [internalSubagent, setInternalSubagent] = useState<Set<string>>(new Set());
|
||||
|
||||
// ── Resolve controlled vs internal ──────────────────────────────────
|
||||
const isControlled = controlledState !== undefined;
|
||||
const collapsedGroupIds = isControlled ? controlledState.collapsedGroupIds : internalCollapsed;
|
||||
const expandedItemIds = isControlled ? controlledState.expandedItemIds : internalExpanded;
|
||||
const expandedSubagentIds = isControlled ? controlledState.expandedSubagentIds : internalSubagent;
|
||||
|
||||
const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
|
||||
const entries = useMemo(() => groupBySubagent(groups), [groups]);
|
||||
|
|
@ -267,9 +384,43 @@ export const CliLogsRichView = ({
|
|||
return expanded;
|
||||
}, [entries, collapsedGroupIds]);
|
||||
|
||||
// ── Viewport computation ────────────────────────────────────────────
|
||||
|
||||
const computeCurrentViewport = useCallback(
|
||||
(el: HTMLDivElement): ClaudeLogsViewportState => {
|
||||
const thresholdPx = 16;
|
||||
|
||||
// Check if at the "newest" edge
|
||||
if (order === 'newest-first' && el.scrollTop <= thresholdPx) {
|
||||
return { mode: 'edge', edge: 'newest' };
|
||||
}
|
||||
if (
|
||||
order === 'oldest-first' &&
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= thresholdPx
|
||||
) {
|
||||
return { mode: 'edge', edge: 'newest' };
|
||||
}
|
||||
|
||||
// Check if at the "oldest" edge
|
||||
if (order === 'newest-first') {
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distFromBottom <= thresholdPx) {
|
||||
return { mode: 'edge', edge: 'oldest' };
|
||||
}
|
||||
}
|
||||
if (order === 'oldest-first' && el.scrollTop <= thresholdPx) {
|
||||
return { mode: 'edge', edge: 'oldest' };
|
||||
}
|
||||
|
||||
// Anchor mode — find first visible anchor
|
||||
const anchor = computeAnchorViewport(el);
|
||||
return anchor ?? { mode: 'edge', edge: 'newest' };
|
||||
},
|
||||
[order]
|
||||
);
|
||||
|
||||
const computeShouldStickToEdge = useCallback(
|
||||
(el: HTMLDivElement): boolean => {
|
||||
// Small threshold makes it feel "sticky" but still allows reading slightly away from the edge
|
||||
const thresholdPx = 16;
|
||||
if (order === 'newest-first') {
|
||||
return el.scrollTop <= thresholdPx;
|
||||
|
|
@ -280,12 +431,35 @@ export const CliLogsRichView = ({
|
|||
[order]
|
||||
);
|
||||
|
||||
// Auto-scroll only when user is pinned to the edge.
|
||||
// ── Viewport restoration on controlled mount ────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled || hasRestoredRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el || entries.length === 0) return;
|
||||
|
||||
hasRestoredRef.current = true;
|
||||
|
||||
// Use rAF to ensure layout is complete before restoring
|
||||
requestAnimationFrame(() => {
|
||||
restoreViewport(el, controlledState.viewport, order);
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
});
|
||||
}, [isControlled, controlledState?.viewport, entries.length, order, computeShouldStickToEdge]);
|
||||
|
||||
// Reset restore flag when controlled state is first attached
|
||||
useEffect(() => {
|
||||
if (isControlled) {
|
||||
hasRestoredRef.current = false;
|
||||
}
|
||||
}, [isControlled]);
|
||||
|
||||
// ── Auto-scroll when pinned to edge ─────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// If the sort order changes, always snap once (expectation: show the "newest edge").
|
||||
if (lastOrderRef.current !== order) {
|
||||
lastOrderRef.current = order;
|
||||
stickToEdgeRef.current = true;
|
||||
|
|
@ -301,44 +475,80 @@ export const CliLogsRichView = ({
|
|||
el.scrollTop = el.scrollHeight;
|
||||
}, [cliLogsTail, order]);
|
||||
|
||||
const handleGroupToggle = useCallback((groupId: string) => {
|
||||
setCollapsedGroupIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// ── State change handlers ───────────────────────────────────────────
|
||||
|
||||
const handleItemClick = useCallback((itemId: string) => {
|
||||
setExpandedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId);
|
||||
} else {
|
||||
next.add(itemId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const emitStateChange = useCallback(
|
||||
(patch: Partial<ClaudeLogsViewerState>) => {
|
||||
if (!isControlled || !onViewerStateChange) return;
|
||||
onViewerStateChange({ ...controlledState, ...patch });
|
||||
},
|
||||
[isControlled, onViewerStateChange, controlledState]
|
||||
);
|
||||
|
||||
const handleSubagentToggle = useCallback((sectionId: string) => {
|
||||
setExpandedSubagentIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId);
|
||||
const handleGroupToggle = useCallback(
|
||||
(groupId: string) => {
|
||||
if (isControlled) {
|
||||
emitStateChange({ collapsedGroupIds: toggleInSet(collapsedGroupIds, groupId) });
|
||||
} else {
|
||||
next.add(sectionId);
|
||||
setInternalCollapsed((prev) => toggleInSet(prev, groupId));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[isControlled, emitStateChange, collapsedGroupIds]
|
||||
);
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(itemId: string) => {
|
||||
if (isControlled) {
|
||||
emitStateChange({ expandedItemIds: toggleInSet(expandedItemIds, itemId) });
|
||||
} else {
|
||||
setInternalExpanded((prev) => toggleInSet(prev, itemId));
|
||||
}
|
||||
},
|
||||
[isControlled, emitStateChange, expandedItemIds]
|
||||
);
|
||||
|
||||
const handleSubagentToggle = useCallback(
|
||||
(sectionId: string) => {
|
||||
if (isControlled) {
|
||||
emitStateChange({ expandedSubagentIds: toggleInSet(expandedSubagentIds, sectionId) });
|
||||
} else {
|
||||
setInternalSubagent((prev) => toggleInSet(prev, sectionId));
|
||||
}
|
||||
},
|
||||
[isControlled, emitStateChange, expandedSubagentIds]
|
||||
);
|
||||
|
||||
// ── Scroll handler ──────────────────────────────────────────────────
|
||||
|
||||
const handleScrollEvent = useCallback(
|
||||
(el: HTMLDivElement) => {
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
|
||||
// Report viewport state to parent in controlled mode
|
||||
if (isControlled && onViewerStateChange) {
|
||||
const vp = computeCurrentViewport(el);
|
||||
emitStateChange({ viewport: vp });
|
||||
}
|
||||
},
|
||||
[
|
||||
computeShouldStickToEdge,
|
||||
computeCurrentViewport,
|
||||
emitStateChange,
|
||||
isControlled,
|
||||
onScroll,
|
||||
onViewerStateChange,
|
||||
]
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────
|
||||
|
||||
if (entries.length === 0) {
|
||||
// No parseable assistant messages yet — show waiting state
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
|
|
@ -349,15 +559,7 @@ export const CliLogsRichView = ({
|
|||
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||
className
|
||||
)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
}}
|
||||
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<span className="relative flex size-2">
|
||||
|
|
@ -382,15 +584,7 @@ export const CliLogsRichView = ({
|
|||
containerRefCallback?.(el);
|
||||
}}
|
||||
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
}}
|
||||
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
||||
>
|
||||
{visibleEntries.map((entry) =>
|
||||
entry.type === 'subagent-section' ? (
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@ export const CollapsibleTeamSection = ({
|
|||
{afterBadge}
|
||||
{headerExtra}
|
||||
</div>
|
||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||
{action && (
|
||||
<div className="relative z-10 flex shrink-0 items-center self-start">{action}</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
|
||||
|
|
|
|||
|
|
@ -1077,9 +1077,43 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text)]">
|
||||
{data.config.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text)]">
|
||||
{data.config.name}
|
||||
</h2>
|
||||
{data.isAlive && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{!data.isAlive && isTeamProvisioning && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-yellow-400" />
|
||||
Launching...
|
||||
</span>
|
||||
)}
|
||||
{data.isAlive &&
|
||||
launchParams?.model &&
|
||||
(() => {
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
opus: 'Opus 4.6',
|
||||
sonnet: 'Sonnet 4.5',
|
||||
haiku: 'Haiku 4.5',
|
||||
};
|
||||
const modelLabel = MODEL_LABELS[launchParams.model] ?? launchParams.model;
|
||||
const effortLabel = launchParams.effort
|
||||
? launchParams.effort.charAt(0).toUpperCase() + launchParams.effort.slice(1)
|
||||
: '';
|
||||
const extLabel = launchParams.extendedContext ? '1M' : '';
|
||||
const parts = [modelLabel, effortLabel, extLabel].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{data.isAlive && (
|
||||
|
|
@ -1175,38 +1209,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
<span className="max-w-32 truncate">{leadBranch}</span>
|
||||
</span>
|
||||
)}
|
||||
{data.isAlive && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<span className="size-1.5 rounded-full bg-emerald-400" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{data.isAlive &&
|
||||
launchParams?.model &&
|
||||
(() => {
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
opus: 'Opus 4.6',
|
||||
sonnet: 'Sonnet 4.5',
|
||||
haiku: 'Haiku 4.5',
|
||||
};
|
||||
const modelLabel = MODEL_LABELS[launchParams.model] ?? launchParams.model;
|
||||
const effortLabel = launchParams.effort
|
||||
? launchParams.effort.charAt(0).toUpperCase() + launchParams.effort.slice(1)
|
||||
: '';
|
||||
const extLabel = launchParams.extendedContext ? '1M' : '';
|
||||
const parts = [modelLabel, effortLabel, extLabel].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{!data.isAlive && isTeamProvisioning && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-yellow-400" />
|
||||
Launching...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,34 @@ export function getCrossTeamSentMemberName(value: string | undefined): string |
|
|||
return parseQualifiedRecipient(value)?.memberName ?? null;
|
||||
}
|
||||
|
||||
function CrossTeamTeamBadge({ teamName }: { teamName: string }): React.JSX.Element {
|
||||
function CrossTeamTeamBadge({
|
||||
teamName,
|
||||
onClick,
|
||||
}: {
|
||||
teamName: string;
|
||||
onClick?: (teamName: string) => void;
|
||||
}): React.JSX.Element {
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: 'rgba(168, 85, 247, 0.15)',
|
||||
color: '#c084fc',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(teamName);
|
||||
}}
|
||||
>
|
||||
{teamName}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
|
|
@ -309,6 +336,7 @@ export const ActivityItem = ({
|
|||
memberRole && memberRole !== message.from ? formatAgentRole(memberRole) : null;
|
||||
|
||||
const teams = useStore((s) => 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 ? <CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} /> : null}
|
||||
{crossTeamOrigin ? (
|
||||
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={openTeamTab} />
|
||||
) : null}
|
||||
<MemberBadge
|
||||
name={senderName}
|
||||
color={senderColor}
|
||||
|
|
@ -586,7 +616,9 @@ export const ActivityItem = ({
|
|||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||
→
|
||||
</span>
|
||||
{crossTeamTarget ? <CrossTeamTeamBadge teamName={crossTeamTarget} /> : null}
|
||||
{crossTeamTarget ? (
|
||||
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={openTeamTab} />
|
||||
) : null}
|
||||
{crossTeamSentMemberName || !crossTeamTarget ? (
|
||||
<MemberBadge
|
||||
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const WorkflowTimeline = ({ events, memberColorMap }: WorkflowTimelinePro
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0 px-3 py-2">
|
||||
<div className="px-3 py-2">
|
||||
{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
|
|||
<div key={event.id} className="flex">
|
||||
{/* Timeline line + dot */}
|
||||
<div className="flex w-5 shrink-0 flex-col items-center">
|
||||
<div className={cn('mt-1.5 size-2 shrink-0 rounded-full', dotColor(event))} />
|
||||
{!isLast && <div className="w-px flex-1 bg-zinc-700" />}
|
||||
<div className={cn('mt-2 size-2 shrink-0 rounded-full', dotColor(event))} />
|
||||
{!isLast && <div className="mt-1 w-px flex-1 bg-zinc-700" />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mb-1.5 flex w-full items-center gap-2 rounded px-1.5 py-1 text-xs text-[var(--color-text-secondary)]">
|
||||
<div className="flex w-full items-center gap-2 rounded px-1.5 py-1.5 text-xs text-[var(--color-text-secondary)]">
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
{time}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<TooltipTrigger asChild>
|
||||
<h5
|
||||
ref={ref}
|
||||
className={`truncate text-sm font-medium text-[var(--color-text)] ${className ?? ''}`}
|
||||
className={`line-clamp-2 text-xs font-medium text-[var(--color-text)] ${className ?? ''}`}
|
||||
onMouseEnter={checkTruncation}
|
||||
>
|
||||
{text}
|
||||
|
|
@ -141,11 +141,11 @@ const CancelTaskButton = ({
|
|||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="size-8 rounded-full shadow-sm"
|
||||
className="size-6 rounded-full shadow-sm"
|
||||
aria-label={`Cancel task ${taskId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<XCircle size={14} />
|
||||
<XCircle size={11} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -201,7 +201,7 @@ const TaskActionIconButton = ({
|
|||
<Button
|
||||
variant={variant}
|
||||
size="icon"
|
||||
className={`size-8 shrink-0 rounded-full shadow-sm ${className}`}
|
||||
className={`size-6 shrink-0 rounded-full shadow-sm ${className}`}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
|
@ -275,7 +275,7 @@ export const KanbanTaskCard = ({
|
|||
{showChangesColumn && taskHasChanges === true ? (
|
||||
<TaskActionIconButton
|
||||
label="Changes"
|
||||
icon={<FileCode className="size-3.5" />}
|
||||
icon={<FileCode className="size-2.5" />}
|
||||
variant="ghost"
|
||||
className="text-sky-400 hover:bg-sky-500/10 hover:text-sky-300"
|
||||
onClick={(e) => {
|
||||
|
|
@ -288,7 +288,7 @@ export const KanbanTaskCard = ({
|
|||
{onDeleteTask ? (
|
||||
<TaskActionIconButton
|
||||
label="Delete task"
|
||||
icon={<Trash2 size={14} />}
|
||||
icon={<Trash2 size={11} />}
|
||||
variant="ghost"
|
||||
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={(e) => {
|
||||
|
|
@ -303,7 +303,7 @@ export const KanbanTaskCard = ({
|
|||
return (
|
||||
<div
|
||||
data-task-id={task.id}
|
||||
className={`relative cursor-pointer rounded-md border p-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
className={`relative cursor-pointer rounded-md border px-1.5 py-3 transition-colors hover:border-[var(--color-border-emphasis)] ${
|
||||
hasBlockedBy
|
||||
? 'border-yellow-500/30 bg-[var(--color-surface-raised)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-raised)]'
|
||||
|
|
@ -321,11 +321,13 @@ export const KanbanTaskCard = ({
|
|||
<span className="absolute left-[3px] top-[2px] text-[9px] leading-none text-[var(--color-text-muted)]">
|
||||
{formatTaskDisplayLabel(task)}
|
||||
</span>
|
||||
<div className="mb-2 pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
</div>
|
||||
{task.owner ? (
|
||||
<span className="absolute right-[6px] top-[2px]">
|
||||
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} size="xs" />
|
||||
</span>
|
||||
) : null}
|
||||
<div className="mb-2 pt-[11px]">
|
||||
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||
{task.needsClarification ? (
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
|
|
@ -388,7 +390,7 @@ export const KanbanTaskCard = ({
|
|||
<>
|
||||
<TaskActionIconButton
|
||||
label="Start"
|
||||
icon={<Play size={14} />}
|
||||
icon={<Play size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -397,7 +399,7 @@ export const KanbanTaskCard = ({
|
|||
/>
|
||||
<TaskActionIconButton
|
||||
label="Complete"
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -411,7 +413,7 @@ export const KanbanTaskCard = ({
|
|||
<>
|
||||
<TaskActionIconButton
|
||||
label="Complete"
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -426,7 +428,7 @@ export const KanbanTaskCard = ({
|
|||
<>
|
||||
<TaskActionIconButton
|
||||
label="Approve"
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -435,7 +437,7 @@ export const KanbanTaskCard = ({
|
|||
/>
|
||||
<TaskActionIconButton
|
||||
label="Request review"
|
||||
icon={<Eye size={14} />}
|
||||
icon={<Eye size={11} />}
|
||||
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -446,16 +448,16 @@ export const KanbanTaskCard = ({
|
|||
) : null}
|
||||
|
||||
{columnId === 'review' ? (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
{isReviewManual ? (
|
||||
<div className="min-w-0 shrink text-[11px] text-[var(--color-text-muted)]">
|
||||
<div className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||
Manual review
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0 flex-nowrap gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TaskActionIconButton
|
||||
label="Approve"
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
icon={<CheckCircle2 size={11} />}
|
||||
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -464,7 +466,7 @@ export const KanbanTaskCard = ({
|
|||
/>
|
||||
<TaskActionIconButton
|
||||
label="Request changes"
|
||||
icon={<FilePenLine size={14} />}
|
||||
icon={<FilePenLine size={11} />}
|
||||
variant="destructive"
|
||||
className="bg-red-500/90 text-white hover:bg-red-500"
|
||||
onClick={(e) => {
|
||||
|
|
@ -472,17 +474,15 @@ export const KanbanTaskCard = ({
|
|||
onRequestChanges(task.id);
|
||||
}}
|
||||
/>
|
||||
{isReviewManual ? metaActions : null}
|
||||
</div>
|
||||
{isReviewManual ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">{metaActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'approved' ? (
|
||||
<TaskActionIconButton
|
||||
label="Disapprove"
|
||||
icon={<RotateCcw size={14} />}
|
||||
icon={<RotateCcw size={11} />}
|
||||
className="border-amber-500/40 text-amber-400 hover:bg-amber-500/10 hover:text-amber-300"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export const MessagesPanel = ({
|
|||
});
|
||||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
||||
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
|
||||
const [sidebarSearchVisible, setSidebarSearchVisible] = useState(false);
|
||||
const [statusBlockCollapsed, setStatusBlockCollapsed] = useState(false);
|
||||
const [pendingRepliesNowMs, setPendingRepliesNowMs] = useState(() => Date.now());
|
||||
|
||||
|
|
@ -242,7 +243,7 @@ export const MessagesPanel = ({
|
|||
);
|
||||
|
||||
// ---- Shared content (used in both modes) ----
|
||||
const searchAndFilterBar = (
|
||||
const searchAndFilterControls = (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
|
|
@ -272,6 +273,12 @@ export const MessagesPanel = ({
|
|||
onOpenChange={setMessagesFilterOpen}
|
||||
onApply={setMessagesFilter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchAndFilterBar = (
|
||||
<div className="flex items-center gap-2">
|
||||
{searchAndFilterControls}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -421,7 +428,37 @@ export const MessagesPanel = ({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setMessagesCollapsed((v) => !v)}
|
||||
>
|
||||
{messagesCollapsed ? <ChevronsUpDown size={14} /> : <ChevronsDownUp size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setSidebarSearchVisible((v) => !v)}
|
||||
>
|
||||
{sidebarSearchVisible ? <X size={14} /> : <Search size={14} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{sidebarSearchVisible ? 'Hide search' : 'Search messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -437,10 +474,12 @@ export const MessagesPanel = ({
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search & filter bar */}
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-1.5">
|
||||
{searchAndFilterBar}
|
||||
</div>
|
||||
{/* Search & filter bar (toggleable) */}
|
||||
{sidebarSearchVisible && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-1.5">
|
||||
{searchAndFilterControls}
|
||||
</div>
|
||||
)}
|
||||
{/* Scrollable content */}
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-2">
|
||||
{messagesContent}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{changeSetLoading && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-text-muted">
|
||||
Loading changes...
|
||||
</div>
|
||||
)}
|
||||
{changeSetLoading && <ChangesLoadingAnimation />}
|
||||
|
||||
{changeSetError && (
|
||||
<div className="flex w-full items-center justify-center text-sm text-red-400">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-6">
|
||||
{/* Animated icons cluster */}
|
||||
<div className="relative flex size-28 items-center justify-center">
|
||||
{/* Central pulsing ring */}
|
||||
<div className="absolute size-16 animate-ping rounded-full bg-[var(--color-text-muted)] opacity-[0.04]" />
|
||||
<div
|
||||
className="absolute size-20 rounded-full bg-[var(--color-text-muted)] opacity-[0.03]"
|
||||
style={{ animation: 'ping 2s cubic-bezier(0, 0, 0.2, 1) infinite 0.5s' }}
|
||||
/>
|
||||
|
||||
{/* Floating file icons */}
|
||||
{floatingIcons.map(({ Icon, delay, x, y }, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute text-[var(--color-text-muted)]"
|
||||
style={{
|
||||
animation: `changesFloat 2.5s ease-in-out infinite ${delay}`,
|
||||
left: `calc(50% + ${x}px)`,
|
||||
top: `calc(50% + ${y}px)`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.5} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Center icon */}
|
||||
<div
|
||||
className="relative z-10 flex size-10 items-center justify-center rounded-xl border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)]"
|
||||
style={{ animation: 'changesBreath 2s ease-in-out infinite' }}
|
||||
>
|
||||
<FileDiff size={20} className="text-[var(--color-text-secondary)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-48 overflow-hidden rounded-full bg-[var(--color-surface-raised)]">
|
||||
<div
|
||||
className="h-0.5 rounded-full bg-[var(--color-text-muted)]"
|
||||
style={{ animation: 'changesProgress 2s ease-in-out infinite' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<p
|
||||
className="text-xs font-medium tracking-wide text-[var(--color-text-muted)]"
|
||||
style={{ animation: 'changesFade 2s ease-in-out infinite' }}
|
||||
>
|
||||
Loading changes...
|
||||
</p>
|
||||
|
||||
<style>{`
|
||||
@keyframes changesFloat {
|
||||
0%, 100% { opacity: 0.3; transform: translate(-50%, -50%) scale(0.85); }
|
||||
50% { opacity: 0.7; transform: translate(-50%, calc(-50% - 6px)) scale(1); }
|
||||
}
|
||||
@keyframes changesBreath {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.06); }
|
||||
}
|
||||
@keyframes changesProgress {
|
||||
0% { width: 0%; margin-left: 0; }
|
||||
50% { width: 60%; margin-left: 20%; }
|
||||
100% { width: 0%; margin-left: 100%; }
|
||||
}
|
||||
@keyframes changesFade {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
655
src/renderer/components/team/useClaudeLogsController.ts
Normal file
655
src/renderer/components/team/useClaudeLogsController.ts
Normal file
|
|
@ -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<void>;
|
||||
loadOlderLogs: () => Promise<void>;
|
||||
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown> }
|
||||
| { 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<string, unknown>): 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<string, unknown>;
|
||||
if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const writeBlocks = (
|
||||
parsed: Record<string, unknown>,
|
||||
blocks: AssistantContentBlock[]
|
||||
): Record<string, unknown> => {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
return { ...parsed, content: blocks };
|
||||
}
|
||||
const msg = parsed.message;
|
||||
if (msg && typeof msg === 'object') {
|
||||
return { ...parsed, message: { ...(msg as Record<string, unknown>), 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<string, unknown>;
|
||||
|
||||
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<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
||||
const [pendingNewCount, setPendingNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Search & filter state ─────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
|
||||
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<ClaudeLogsViewerState>(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<TeamClaudeLogsResponse | null>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const committedRef = useRef<TeamClaudeLogsResponse>({ 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
121
src/renderer/components/ui/MentionInteractionLayer.tsx
Normal file
121
src/renderer/components/ui/MentionInteractionLayer.tsx
Normal file
|
|
@ -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<HTMLTextAreaElement | null>;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export const MentionInteractionLayer = ({
|
||||
suggestions,
|
||||
value,
|
||||
textareaRef,
|
||||
scrollTop,
|
||||
}: MentionInteractionLayerProps): React.JSX.Element | null => {
|
||||
const [positions, setPositions] = React.useState<MentionPosition[]>([]);
|
||||
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 (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
|
||||
<div style={{ transform: `translateY(-${scrollTop}px)` }}>
|
||||
{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 (
|
||||
<Tooltip key={`${pos.suggestion.id}-${idx}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="pointer-events-auto absolute cursor-default"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
height: pos.height,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{isTeam ? (
|
||||
<UsersRound
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
color: colorSet
|
||||
? getThemedText(colorSet, isLight)
|
||||
: 'var(--color-text-muted)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MemberBadge
|
||||
name={pos.suggestion.name}
|
||||
color={pos.suggestion.color}
|
||||
size="xs"
|
||||
disableHoverCard
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium"
|
||||
style={colorSet ? { color: getThemedText(colorSet, isLight) } : undefined}
|
||||
>
|
||||
{pos.suggestion.name}
|
||||
</div>
|
||||
{pos.suggestion.subtitle ? (
|
||||
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{pos.suggestion.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<HTMLTextAreaElement, Mention
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{mentionOverlaySuggestions.length > 0 ? (
|
||||
<MentionInteractionLayer
|
||||
suggestions={mentionOverlaySuggestions}
|
||||
value={value}
|
||||
textareaRef={internalRef}
|
||||
scrollTop={scrollTop}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AutoResizeTextarea
|
||||
ref={setRefs}
|
||||
value={value}
|
||||
|
|
|
|||
|
|
@ -971,9 +971,16 @@ body {
|
|||
|
||||
/* CLI Logs compact density — reduces item height inside CliLogsRichView */
|
||||
|
||||
.cli-logs-compact {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.cli-logs-compact [role='button'] {
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.2rem;
|
||||
padding-left: 0.375rem;
|
||||
padding-right: 0.375rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
|
|
@ -987,6 +994,12 @@ body {
|
|||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Hide per-item timestamps and chevrons in compact CLI logs */
|
||||
.cli-logs-compact .base-item-timestamp,
|
||||
.cli-logs-compact .base-item-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Extension list zebra rows */
|
||||
|
||||
.mcp-servers-grid > *,
|
||||
|
|
|
|||
|
|
@ -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<T>(
|
|||
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<MentionSuggestion>[] = [];
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -50,6 +50,20 @@ interface ContentBlock {
|
|||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, number>();
|
||||
const hashOccurrences = new Map<string, number>();
|
||||
|
||||
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<string, string>();
|
||||
const sectionCountByAgent = new Map<string, number>();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -19,14 +19,17 @@ export function buildToolSummary(content: Record<string, unknown>[]): 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<string, number> = {};
|
||||
|
|
@ -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<toolName, count>. */
|
||||
export function formatToolSummaryFromMap(counts: Map<string, number>): 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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue