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:
parent
0434865077
commit
d92eb9b72c
6 changed files with 212 additions and 33 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue