From d92eb9b72c2868a30932c779644ba6f13e0ab7c9 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 20:58:14 +0200 Subject: [PATCH] refactor: update package.json and enhance ClaudeLogsSection functionality - Renamed package from `@claude-team/mcp-server` to `agent-teams-mcp` and updated description for clarity. - Added `files` and `keywords` fields in package.json for better package management. - Introduced new loading logic in ClaudeLogsSection to handle older log retrieval and improved state management for loading indicators. - Implemented utility functions for appending older log lines and determining overlap, enhancing log display efficiency. - Updated ActivityItem and ActivityTimeline components to support message collapsing functionality, improving user experience in message handling. --- mcp-server/package.json | 26 ++- resources/pricing.json | 2 +- .../components/team/ClaudeLogsSection.tsx | 155 +++++++++++++++--- .../components/team/TeamDetailView.tsx | 26 +++ .../components/team/activity/ActivityItem.tsx | 29 +++- .../team/activity/ActivityTimeline.tsx | 7 + 6 files changed, 212 insertions(+), 33 deletions(-) diff --git a/mcp-server/package.json b/mcp-server/package.json index ac6f5a7d..b16663fa 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,18 +1,36 @@ { - "name": "@claude-team/mcp-server", + "name": "agent-teams-mcp", "version": "1.0.0", - "description": "MCP server for managing Claude Agent Teams kanban board and tasks", + "description": "MCP server for managing Claude Agent Teams kanban board and tasks via teamctl CLI", "type": "module", "main": "dist/index.js", "bin": { - "team-mcp-server": "dist/index.js" + "agent-teams-mcp": "dist/index.js" + }, + "files": [ + "dist" + ], + "keywords": [ + "mcp", + "mcp-server", + "claude", + "agent-teams", + "kanban", + "task-management", + "model-context-protocol" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/nickchernyy/agent-teams-mcp" }, "scripts": { "build": "tsup", "dev": "tsx src/index.ts", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm build" }, "dependencies": { "fastmcp": "^3.34.0", diff --git a/resources/pricing.json b/resources/pricing.json index 78856312..3f65c218 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -553,7 +553,7 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, - "apac.anthropic.claude-sonnet-4-6": { + "au.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, "cache_read_input_token_cost": 3.3e-7, diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index aa1c6ae8..10dd49d8 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -16,6 +16,7 @@ import type { TeamClaudeLogsResponse } from '@shared/types'; const PAGE_SIZE = 100; const POLL_MS = 2000; const ONLINE_WINDOW_MS = 10_000; +const LOAD_MORE_THRESHOLD_PX = 48; type StreamType = 'stdout' | 'stderr'; @@ -70,6 +71,40 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { 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 } @@ -192,13 +227,16 @@ function filterStreamJsonText( export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [loadedCount, setLoadedCount] = useState(PAGE_SIZE); const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); const [pending, setPending] = useState(null); const [pendingNewCount, setPendingNewCount] = useState(0); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const inFlightRef = useRef(false); + const loadingMoreRef = useRef(false); + const applyingPendingRef = useRef(false); const atTopRef = useRef(true); const latestRef = useRef(null); const logContainerRef = useRef(null); @@ -210,9 +248,15 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), })); const [filterOpen, setFilterOpen] = useState(false); + const isNearBottom = useCallback( + (scrollTop: number, scrollHeight: number, clientHeight: number) => { + return scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD_PX; + }, + [] + ); useEffect(() => { - setVisibleCount(PAGE_SIZE); + setLoadedCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); setPending(null); setPendingNewCount(0); @@ -255,7 +299,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J inFlightRef.current = true; try { setLoading(true); - const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); + const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); if (cancelled) return; latestRef.current = next; if (atTopRef.current) { @@ -283,11 +327,55 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J cancelled = true; window.clearInterval(id); }; - }, [teamName, visibleCount]); + }, [teamName, loadedCount]); + + const loadOlderLogs = useCallback(async (): Promise => { + if (loadingMoreRef.current || inFlightRef.current) return; + + const current = committedRef.current; + if (!current.hasMore) return; + + loadingMoreRef.current = true; + setLoadingMore(true); + + try { + const older = await api.teams.getClaudeLogs(teamName, { + offset: current.lines.length + pendingCountRef.current, + limit: PAGE_SIZE, + }); + + setData((prev) => ({ + ...prev, + lines: appendOlderLines(prev.lines, older.lines), + total: older.total, + hasMore: older.hasMore, + updatedAt: older.updatedAt ?? prev.updatedAt, + })); + setLoadedCount((count) => count + PAGE_SIZE); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + loadingMoreRef.current = false; + setLoadingMore(false); + } + }, [teamName]); + + useEffect(() => { + const el = logContainerRef.current; + if (!el || loading || loadingMore || !data.hasMore || data.lines.length === 0) return; + + if ( + el.scrollHeight <= el.clientHeight || + isNearBottom(el.scrollTop, el.scrollHeight, el.clientHeight) + ) { + void loadOlderLogs(); + } + }, [data.hasMore, data.lines.length, isNearBottom, loadOlderLogs, loading, loadingMore]); const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); const badge = data.total > 0 ? data.total : undefined; - const showMoreVisible = data.hasMore; + const showMoreVisible = data.hasMore || loadingMore; const headerExtra = online ? ( @@ -310,17 +398,34 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J return filterStreamJsonText(data.lines, searchQuery, filter); }, [data.lines, searchQuery, filter]); - const applyPending = (): void => { - const latest = latestRef.current ?? pending; - if (!latest) return; - setData(latest); - setPending(null); - setPendingNewCount(0); - // Jump to newest - if (logContainerRef.current) { - logContainerRef.current.scrollTop = 0; + const applyPending = useCallback(async (): Promise => { + if (applyingPendingRef.current) return; + + applyingPendingRef.current = true; + try { + let latest = latestRef.current ?? pending; + const expectedVisibleCount = latest ? Math.min(loadedCount, latest.total) : loadedCount; + + if (!latest || latest.lines.length < expectedVisibleCount) { + latest = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount }); + latestRef.current = latest; + } + + setData(latest); + setPending(null); + setPendingNewCount(0); + setError(null); + + // Jump to newest + if (logContainerRef.current) { + logContainerRef.current.scrollTop = 0; + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + applyingPendingRef.current = false; } - }; + }, [loadedCount, pending, teamName]); return ( {data.total > 0 ? ( <> - Showing {Math.min(data.total, visibleCount)} of{' '} - {data.total} + Showing {Math.min(data.total, data.lines.length)}{' '} + of {data.total} ) : isAlive ? ( 'No logs yet.' @@ -389,9 +494,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J variant="ghost" size="sm" className="h-7 px-2 text-xs" - onClick={() => setVisibleCount((c) => c + PAGE_SIZE)} + onClick={() => void loadOlderLogs()} + disabled={loadingMore} > - Show more + {loadingMore ? 'Loading…' : 'Show more'} )} @@ -409,11 +515,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J containerRefCallback={(el) => { logContainerRef.current = el; }} - onScroll={({ scrollTop }) => { + onScroll={({ scrollTop, scrollHeight, clientHeight }) => { const atTop = scrollTop <= 8; atTopRef.current = atTop; if (atTop && pendingCountRef.current > 0) { - applyPending(); + void applyPending(); + return; + } + + if (isNearBottom(scrollTop, scrollHeight, clientHeight)) { + void loadOlderLogs(); } }} /> diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f80056e7..4ba59754 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -32,6 +32,8 @@ import { AlertTriangle, Bell, CheckCheck, + ChevronsDownUp, + ChevronsUpDown, Code, Columns3, FolderOpen, @@ -306,6 +308,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); + const [messagesCollapsed, setMessagesCollapsed] = useState(false); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); @@ -1498,6 +1501,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onOpenChange={setMessagesFilterOpen} onApply={setMessagesFilter} /> + + + + + + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + + } > @@ -1536,6 +1561,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele teamName={teamName} members={data.members} readState={{ readSet, getMessageKey: toMessageKey }} + allCollapsed={messagesCollapsed} onMemberClick={setSelectedMember} onCreateTaskFromMessage={(subject, description) => { openCreateTaskDialog(subject, description); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 0b472ef5..cce545a8 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; @@ -52,6 +52,8 @@ interface ActivityItemProps { onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; + /** When true, collapse message body — show only header with expand chevron. */ + forceCollapsed?: boolean; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -213,6 +215,7 @@ export const ActivityItem = ({ onTaskIdClick, onRestartTeam, zebraShade, + forceCollapsed, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -233,7 +236,17 @@ export const ActivityItem = ({ // System/automated messages start collapsed (but not rate limits) const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; - const [isExpanded, setIsExpanded] = useState(!systemLabel); + const [isExpanded, setIsExpanded] = useState(!systemLabel && !forceCollapsed); + + // Sync expand/collapse when the global collapse mode toggles (skip initial mount) + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + setIsExpanded(forceCollapsed ? false : !systemLabel); + }, [forceCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps -- systemLabel is stable (derived from message.text) // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -282,7 +295,8 @@ export const ActivityItem = ({ onCreateTask?.(subject, description); }; - const isHeaderClickable = Boolean(systemLabel); + const isHeaderClickable = Boolean(systemLabel) || forceCollapsed === true; + const showChevron = Boolean(systemLabel) || forceCollapsed === true; const isUserSent = message.source === 'user_sent'; const isSystemMessage = message.from === 'system'; @@ -337,8 +351,8 @@ export const ActivityItem = ({ {isUnread ? ( ) : null} - {/* Chevron for collapsible system messages */} - {systemLabel ? ( + {/* Chevron for collapsible messages */} + {showChevron ? ( ) : parsedReply ? ( - + ) : displayText ? ( void; /** Called when the user clicks "Restart team" on an auth error message. */ onRestartTeam?: () => void; + /** When true, collapse all message bodies — show only headers with expand chevrons. */ + allCollapsed?: boolean; } const VIEWPORT_THRESHOLD = 0.15; @@ -47,6 +49,7 @@ const MessageRowWithObserver = ({ onVisible, onTaskIdClick, onRestartTeam, + forceCollapsed, }: { message: InboxMessage; teamName: string; @@ -63,6 +66,7 @@ const MessageRowWithObserver = ({ onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; + forceCollapsed?: boolean; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -110,6 +114,7 @@ const MessageRowWithObserver = ({ onReply={onReply} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} + forceCollapsed={forceCollapsed} /> ); @@ -126,6 +131,7 @@ export const ActivityTimeline = ({ onMessageVisible, onTaskIdClick, onRestartTeam, + allCollapsed, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); @@ -384,6 +390,7 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} + forceCollapsed={allCollapsed} /> );