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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue