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:
iliya 2026-03-13 23:29:24 +02:00
parent daae1ba82b
commit a6f9515966
25 changed files with 1794 additions and 1082 deletions

View file

@ -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,

View file

@ -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)}

View file

@ -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 }}
/>
)}

View file

@ -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;

View file

@ -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;

View file

@ -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

View 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>
);
};

View 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>
);
};

View file

@ -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} />
</>
);
};

View file

@ -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' ? (

View file

@ -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)}>

View file

@ -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>
)}
{(() => {

View file

@ -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]">
&rarr;
</span>
{crossTeamTarget ? <CrossTeamTeamBadge teamName={crossTeamTarget} /> : null}
{crossTeamTarget ? (
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={openTeamTab} />
) : null}
{crossTeamSentMemberName || !crossTeamTarget ? (
<MemberBadge
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}

View file

@ -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>

View file

@ -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();

View file

@ -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}

View file

@ -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">

View file

@ -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>
);
};

View 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,
};
}

View 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>
);
};

View file

@ -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}

View file

@ -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 > *,

View file

@ -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.

View file

@ -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,

View file

@ -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. */