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

View file

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

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 { 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<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);
@ -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<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;
const showMoreVisible = data.hasMore || loadingMore;
const headerExtra = online ? (
<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);
}, [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<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
@ -337,8 +442,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
<span className="text-[11px] text-[var(--color-text-muted)]">
{data.total > 0 ? (
<>
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
<span className="font-mono">{data.total}</span>
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.'
@ -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'}
</Button>
)}
</div>
@ -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();
}
}}
/>

View file

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

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 { 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 ? (
<span className="size-2 shrink-0 rounded-full bg-blue-500" title="Unread" aria-hidden />
) : null}
{/* Chevron for collapsible system messages */}
{systemLabel ? (
{/* Chevron for collapsible messages */}
{showChevron ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
@ -483,7 +497,10 @@ export const ActivityItem = ({
</details>
</div>
) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} memberColor={memberColorMap?.get(parsedReply.agentName)} />
<ReplyQuoteBlock
reply={parsedReply}
memberColor={memberColorMap?.get(parsedReply.agentName)}
/>
) : displayText ? (
<ExpandableContent>
<span

View file

@ -26,6 +26,8 @@ interface ActivityTimelineProps {
onTaskIdClick?: (taskId: string) => 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<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -110,6 +114,7 @@ const MessageRowWithObserver = ({
onReply={onReply}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
forceCollapsed={forceCollapsed}
/>
</div>
);
@ -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}
/>
</React.Fragment>
);