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.
This commit is contained in:
iliya 2026-03-06 20:58:14 +02:00
parent 0434865077
commit d92eb9b72c
6 changed files with 212 additions and 33 deletions

View file

@ -1,18 +1,36 @@
{ {
"name": "@claude-team/mcp-server", "name": "agent-teams-mcp",
"version": "1.0.0", "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", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "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": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsx src/index.ts", "dev": "tsx src/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm build"
}, },
"dependencies": { "dependencies": {
"fastmcp": "^3.34.0", "fastmcp": "^3.34.0",

View file

@ -553,7 +553,7 @@
"supports_vision": true, "supports_vision": true,
"tool_use_system_prompt_tokens": 346 "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": 0.000004125,
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_read_input_token_cost": 3.3e-7, "cache_read_input_token_cost": 3.3e-7,

View file

@ -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 { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -16,6 +16,7 @@ import type { TeamClaudeLogsResponse } from '@shared/types';
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const POLL_MS = 2000; const POLL_MS = 2000;
const ONLINE_WINDOW_MS = 10_000; const ONLINE_WINDOW_MS = 10_000;
const LOAD_MORE_THRESHOLD_PX = 48;
type StreamType = 'stdout' | 'stderr'; type StreamType = 'stdout' | 'stderr';
@ -70,6 +71,40 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
return out.join('\n'); 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 AssistantContentBlock =
| { type: 'text'; text?: string } | { type: 'text'; text?: string }
| { type: 'thinking'; thinking?: string } | { type: 'thinking'; thinking?: string }
@ -192,13 +227,16 @@ function filterStreamJsonText(
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [loadedCount, setLoadedCount] = useState(PAGE_SIZE);
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false }); const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null); const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
const [pendingNewCount, setPendingNewCount] = useState(0); const [pendingNewCount, setPendingNewCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inFlightRef = useRef(false); const inFlightRef = useRef(false);
const loadingMoreRef = useRef(false);
const applyingPendingRef = useRef(false);
const atTopRef = useRef(true); const atTopRef = useRef(true);
const latestRef = useRef<TeamClaudeLogsResponse | null>(null); const latestRef = useRef<TeamClaudeLogsResponse | null>(null);
const logContainerRef = useRef<HTMLDivElement | null>(null); const logContainerRef = useRef<HTMLDivElement | null>(null);
@ -210,9 +248,15 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
})); }));
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const isNearBottom = useCallback(
(scrollTop: number, scrollHeight: number, clientHeight: number) => {
return scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD_PX;
},
[]
);
useEffect(() => { useEffect(() => {
setVisibleCount(PAGE_SIZE); setLoadedCount(PAGE_SIZE);
setData({ lines: [], total: 0, hasMore: false }); setData({ lines: [], total: 0, hasMore: false });
setPending(null); setPending(null);
setPendingNewCount(0); setPendingNewCount(0);
@ -255,7 +299,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
inFlightRef.current = true; inFlightRef.current = true;
try { try {
setLoading(true); 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; if (cancelled) return;
latestRef.current = next; latestRef.current = next;
if (atTopRef.current) { if (atTopRef.current) {
@ -283,11 +327,55 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
cancelled = true; cancelled = true;
window.clearInterval(id); window.clearInterval(id);
}; };
}, [teamName, visibleCount]); }, [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 online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);
const badge = data.total > 0 ? data.total : undefined; const badge = data.total > 0 ? data.total : undefined;
const showMoreVisible = data.hasMore; const showMoreVisible = data.hasMore || loadingMore;
const headerExtra = online ? ( const headerExtra = online ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating"> <span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
@ -310,17 +398,34 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
return filterStreamJsonText(data.lines, searchQuery, filter); return filterStreamJsonText(data.lines, searchQuery, filter);
}, [data.lines, searchQuery, filter]); }, [data.lines, searchQuery, filter]);
const applyPending = (): void => { const applyPending = useCallback(async (): Promise<void> => {
const latest = latestRef.current ?? pending; if (applyingPendingRef.current) return;
if (!latest) return;
setData(latest); applyingPendingRef.current = true;
setPending(null); try {
setPendingNewCount(0); let latest = latestRef.current ?? pending;
// Jump to newest const expectedVisibleCount = latest ? Math.min(loadedCount, latest.total) : loadedCount;
if (logContainerRef.current) {
logContainerRef.current.scrollTop = 0; 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 ( return (
<CollapsibleTeamSection <CollapsibleTeamSection
@ -337,8 +442,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
<span className="text-[11px] text-[var(--color-text-muted)]"> <span className="text-[11px] text-[var(--color-text-muted)]">
{data.total > 0 ? ( {data.total > 0 ? (
<> <>
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '} Showing <span className="font-mono">{Math.min(data.total, data.lines.length)}</span>{' '}
<span className="font-mono">{data.total}</span> of <span className="font-mono">{data.total}</span>
</> </>
) : isAlive ? ( ) : isAlive ? (
'No logs yet.' 'No logs yet.'
@ -389,9 +494,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2 text-xs" className="h-7 px-2 text-xs"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)} onClick={() => void loadOlderLogs()}
disabled={loadingMore}
> >
Show more {loadingMore ? 'Loading…' : 'Show more'}
</Button> </Button>
)} )}
</div> </div>
@ -409,11 +515,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
containerRefCallback={(el) => { containerRefCallback={(el) => {
logContainerRef.current = el; logContainerRef.current = el;
}} }}
onScroll={({ scrollTop }) => { onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
const atTop = scrollTop <= 8; const atTop = scrollTop <= 8;
atTopRef.current = atTop; atTopRef.current = atTop;
if (atTop && pendingCountRef.current > 0) { if (atTop && pendingCountRef.current > 0) {
applyPending(); void applyPending();
return;
}
if (isNearBottom(scrollTop, scrollHeight, clientHeight)) {
void loadOlderLogs();
} }
}} }}
/> />

View file

@ -32,6 +32,8 @@ import {
AlertTriangle, AlertTriangle,
Bell, Bell,
CheckCheck, CheckCheck,
ChevronsDownUp,
ChevronsUpDown,
Code, Code,
Columns3, Columns3,
FolderOpen, FolderOpen,
@ -306,6 +308,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
showNoise: false, showNoise: false,
}); });
const [messagesFilterOpen, setMessagesFilterOpen] = useState(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) // Open editor overlay when a file reveal is requested (e.g. from chip click)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
@ -1498,6 +1501,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onOpenChange={setMessagesFilterOpen} onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter} onApply={setMessagesFilter}
/> />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="pointer-events-auto size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
setMessagesCollapsed((v) => !v);
}}
>
{messagesCollapsed ? (
<ChevronsUpDown size={14} />
) : (
<ChevronsDownUp size={14} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
</TooltipContent>
</Tooltip>
</div> </div>
} }
> >
@ -1536,6 +1561,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
teamName={teamName} teamName={teamName}
members={data.members} members={data.members}
readState={{ readSet, getMessageKey: toMessageKey }} readState={{ readSet, getMessageKey: toMessageKey }}
allCollapsed={messagesCollapsed}
onMemberClick={setSelectedMember} onMemberClick={setSelectedMember}
onCreateTaskFromMessage={(subject, description) => { onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description); openCreateTaskDialog(subject, description);

View file

@ -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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
@ -52,6 +52,8 @@ interface ActivityItemProps {
onRestartTeam?: () => void; onRestartTeam?: () => void;
/** When true, apply a subtle lighter background for zebra-striped lists. */ /** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean; zebraShade?: boolean;
/** When true, collapse message body — show only header with expand chevron. */
forceCollapsed?: boolean;
} }
function getStringField(obj: StructuredMessage, key: string): string | null { function getStringField(obj: StructuredMessage, key: string): string | null {
@ -213,6 +215,7 @@ export const ActivityItem = ({
onTaskIdClick, onTaskIdClick,
onRestartTeam, onRestartTeam,
zebraShade, zebraShade,
forceCollapsed,
}: ActivityItemProps): React.JSX.Element => { }: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const formattedRole = formatAgentRole(memberRole); const formattedRole = formatAgentRole(memberRole);
@ -233,7 +236,17 @@ export const ActivityItem = ({
// System/automated messages start collapsed (but not rate limits) // System/automated messages start collapsed (but not rate limits)
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; 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) // Strip agent-only blocks + normalize escape sequences (before linkification)
const strippedText = useMemo(() => { const strippedText = useMemo(() => {
@ -282,7 +295,8 @@ export const ActivityItem = ({
onCreateTask?.(subject, description); 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 isUserSent = message.source === 'user_sent';
const isSystemMessage = message.from === 'system'; const isSystemMessage = message.from === 'system';
@ -337,8 +351,8 @@ export const ActivityItem = ({
{isUnread ? ( {isUnread ? (
<span className="size-2 shrink-0 rounded-full bg-blue-500" title="Unread" aria-hidden /> <span className="size-2 shrink-0 rounded-full bg-blue-500" title="Unread" aria-hidden />
) : null} ) : null}
{/* Chevron for collapsible system messages */} {/* Chevron for collapsible messages */}
{systemLabel ? ( {showChevron ? (
<ChevronRight <ChevronRight
className="size-3 shrink-0 transition-transform duration-150" className="size-3 shrink-0 transition-transform duration-150"
style={{ style={{
@ -483,7 +497,10 @@ export const ActivityItem = ({
</details> </details>
</div> </div>
) : parsedReply ? ( ) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} memberColor={memberColorMap?.get(parsedReply.agentName)} /> <ReplyQuoteBlock
reply={parsedReply}
memberColor={memberColorMap?.get(parsedReply.agentName)}
/>
) : displayText ? ( ) : displayText ? (
<ExpandableContent> <ExpandableContent>
<span <span

View file

@ -26,6 +26,8 @@ interface ActivityTimelineProps {
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
/** Called when the user clicks "Restart team" on an auth error message. */ /** Called when the user clicks "Restart team" on an auth error message. */
onRestartTeam?: () => void; onRestartTeam?: () => void;
/** When true, collapse all message bodies — show only headers with expand chevrons. */
allCollapsed?: boolean;
} }
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
@ -47,6 +49,7 @@ const MessageRowWithObserver = ({
onVisible, onVisible,
onTaskIdClick, onTaskIdClick,
onRestartTeam, onRestartTeam,
forceCollapsed,
}: { }: {
message: InboxMessage; message: InboxMessage;
teamName: string; teamName: string;
@ -63,6 +66,7 @@ const MessageRowWithObserver = ({
onVisible?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void;
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
onRestartTeam?: () => void; onRestartTeam?: () => void;
forceCollapsed?: boolean;
}): React.JSX.Element => { }): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false); const reportedRef = useRef(false);
@ -110,6 +114,7 @@ const MessageRowWithObserver = ({
onReply={onReply} onReply={onReply}
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam} onRestartTeam={onRestartTeam}
forceCollapsed={forceCollapsed}
/> />
</div> </div>
); );
@ -126,6 +131,7 @@ export const ActivityTimeline = ({
onMessageVisible, onMessageVisible,
onTaskIdClick, onTaskIdClick,
onRestartTeam, onRestartTeam,
allCollapsed,
}: ActivityTimelineProps): React.JSX.Element => { }: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
@ -384,6 +390,7 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible} onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam} onRestartTeam={onRestartTeam}
forceCollapsed={allCollapsed}
/> />
</React.Fragment> </React.Fragment>
); );