feat: enhance team and member logs with lead information and caching
- Added leadName and leadColor properties to CrossTeamTarget and related interfaces for better team representation. - Implemented caching for file mentions in TeamMemberLogsFinder to improve performance and reduce redundant file checks. - Introduced deriveSinceMs method to calculate log search boundaries based on task creation times. - Updated UI components to display lead information where applicable, enhancing user experience in messaging and logs.
This commit is contained in:
parent
4a2b8baaf5
commit
e2afcbd3b7
12 changed files with 186 additions and 24 deletions
|
|
@ -27,6 +27,8 @@ export interface CrossTeamTarget {
|
|||
displayName: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
}
|
||||
|
||||
export class CrossTeamService {
|
||||
|
|
@ -174,11 +176,15 @@ export class CrossTeamService {
|
|||
}
|
||||
if (!config || config.deletedAt) continue;
|
||||
|
||||
const lead = config.members?.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
displayName: config.name || entry,
|
||||
description: config.description,
|
||||
color: config.color,
|
||||
leadName: lead?.name,
|
||||
leadColor: lead?.color,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ const logger = createLogger('Service:TeamMemberLogsFinder');
|
|||
*/
|
||||
const ATTRIBUTION_SCAN_LINES = 50;
|
||||
|
||||
/** Grace before task creation — logs cannot reference a task before it exists. */
|
||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||
const FILE_MENTIONS_CACHE_MAX = 200;
|
||||
|
||||
interface StreamedMetadata {
|
||||
firstTimestamp: string | null;
|
||||
lastTimestamp: string | null;
|
||||
|
|
@ -42,6 +46,8 @@ function trimTrailingSlashes(value: string): string {
|
|||
}
|
||||
|
||||
export class TeamMemberLogsFinder {
|
||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||
|
|
@ -120,6 +126,7 @@ export class TeamMemberLogsFinder {
|
|||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return [];
|
||||
|
||||
const sinceMs = this.deriveSinceMs(options);
|
||||
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
||||
const results: MemberLogSummary[] = [];
|
||||
const leadMemberName =
|
||||
|
|
@ -129,7 +136,7 @@ export class TeamMemberLogsFinder {
|
|||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||
try {
|
||||
await fs.access(leadJsonl);
|
||||
if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId, true)) {
|
||||
if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) {
|
||||
const leadSummary = await this.parseLeadSessionSummary(
|
||||
leadJsonl,
|
||||
projectId,
|
||||
|
|
@ -155,7 +162,8 @@ export class TeamMemberLogsFinder {
|
|||
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
|
||||
if (file.startsWith('agent-acompact')) continue;
|
||||
const filePath = path.join(subagentsDir, file);
|
||||
if (!(await this.fileMentionsTaskId(filePath, teamName, taskId))) continue;
|
||||
if (!(await this.fileMentionsTaskIdCached(filePath, teamName, taskId, false, sinceMs)))
|
||||
continue;
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
if (!attribution) continue;
|
||||
const summary = await this.parseSubagentSummary(
|
||||
|
|
@ -478,6 +486,59 @@ export class TeamMemberLogsFinder {
|
|||
return { ...discovery, isLeadMember };
|
||||
}
|
||||
|
||||
private deriveSinceMs(options?: {
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}): number | null {
|
||||
const sinceRaw = typeof options?.since === 'string' ? options.since : null;
|
||||
if (sinceRaw) {
|
||||
const ms = Date.parse(sinceRaw);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
const intervals = options?.intervals;
|
||||
if (!Array.isArray(intervals) || intervals.length === 0) return null;
|
||||
let earliest = Number.POSITIVE_INFINITY;
|
||||
for (const i of intervals) {
|
||||
if (typeof i.startedAt === 'string') {
|
||||
const ms = Date.parse(i.startedAt);
|
||||
if (Number.isFinite(ms) && ms < earliest) earliest = ms;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(earliest) || earliest === Number.POSITIVE_INFINITY) return null;
|
||||
return earliest - TASK_SINCE_GRACE_MS;
|
||||
}
|
||||
|
||||
private async fileMentionsTaskIdCached(
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
assumeTeam: boolean,
|
||||
sinceMs: number | null
|
||||
): Promise<boolean> {
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (sinceMs != null && mtimeMs < sinceMs - TASK_SINCE_GRACE_MS) {
|
||||
return false;
|
||||
}
|
||||
const cacheKey = `${filePath}:${mtimeMs}:${taskId}:${teamName}:${assumeTeam}`;
|
||||
const cached = this.fileMentionsCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = await this.fileMentionsTaskId(filePath, teamName, taskId, assumeTeam);
|
||||
this.fileMentionsCache.set(cacheKey, result);
|
||||
if (this.fileMentionsCache.size > FILE_MENTIONS_CACHE_MAX) {
|
||||
const keys = [...this.fileMentionsCache.keys()];
|
||||
for (let i = 0; i < Math.min(keys.length / 2, 50); i++) {
|
||||
this.fileMentionsCache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fileMentionsTaskId(
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
|
|
|
|||
|
|
@ -1066,7 +1066,14 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
listTargets: async (excludeTeam?: string) => {
|
||||
return invokeIpcWithResult<
|
||||
{ teamName: string; displayName: string; description?: string; color?: string }[]
|
||||
{
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
}[]
|
||||
>(CROSS_TEAM_LIST_TARGETS, excludeTeam);
|
||||
},
|
||||
getOutbox: async (teamName: string) => {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export const TeamProvisioningBanner = ({
|
|||
if (isReady) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-[var(--step-done-border)] bg-[var(--step-done-bg)] px-3 py-2">
|
||||
<div className="flex items-center gap-2 rounded-md border border-[var(--step-done-border)] bg-[var(--step-done-bg)] px-3 py-2">
|
||||
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
||||
<p className="flex-1 text-xs text-[var(--step-success-text)]">
|
||||
Team launched — teammates may still be starting
|
||||
|
|
|
|||
|
|
@ -413,7 +413,11 @@ export const ActivityItem = ({
|
|||
<MemberBadge
|
||||
name={crossTeamOrigin ? crossTeamOrigin.memberName : message.from}
|
||||
color={isCrossTeamAny ? 'purple' : (memberColor ?? message.color)}
|
||||
hideAvatar={message.from === 'user' || message.from === 'system'}
|
||||
hideAvatar={
|
||||
message.from === 'user' ||
|
||||
message.from === 'system' ||
|
||||
crossTeamOrigin?.memberName === 'user'
|
||||
}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,29 @@ import type {
|
|||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||
|
||||
function deriveTaskSince(task: TeamTaskWithKanban | null): string | undefined {
|
||||
if (!task) return undefined;
|
||||
const sources: string[] = [];
|
||||
if (task.createdAt) sources.push(task.createdAt);
|
||||
if (Array.isArray(task.workIntervals)) {
|
||||
for (const i of task.workIntervals) {
|
||||
if (i.startedAt) sources.push(i.startedAt);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(task.historyEvents)) {
|
||||
for (const e of task.historyEvents) {
|
||||
if (e.timestamp) sources.push(e.timestamp);
|
||||
}
|
||||
}
|
||||
if (sources.length === 0) return undefined;
|
||||
const earliest = sources.reduce((a, b) => (a < b ? a : b));
|
||||
const d = new Date(earliest);
|
||||
d.setTime(d.getTime() - TASK_SINCE_GRACE_MS);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
|
|
@ -774,6 +797,7 @@ export const TaskDetailDialog = ({
|
|||
taskOwner={currentTask.owner}
|
||||
taskStatus={currentTask.status}
|
||||
taskWorkIntervals={currentTask.workIntervals}
|
||||
taskSince={deriveTaskSince(currentTask)}
|
||||
onRefreshingChange={setLogsRefreshing}
|
||||
// Only show a "latest messages" preview when this task is owned by a subagent.
|
||||
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ interface MemberLogsTabProps {
|
|||
taskStatus?: string;
|
||||
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
|
||||
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
||||
/** Lower bound for log search (skip files modified before this). Derived from task creation. */
|
||||
taskSince?: string;
|
||||
/** Notifies parent when a background refresh starts/ends. */
|
||||
onRefreshingChange?: (isRefreshing: boolean) => void;
|
||||
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
||||
|
|
@ -55,6 +57,7 @@ export const MemberLogsTab = ({
|
|||
taskOwner,
|
||||
taskStatus,
|
||||
taskWorkIntervals,
|
||||
taskSince,
|
||||
onRefreshingChange,
|
||||
showSubagentPreview = false,
|
||||
showLeadPreview = false,
|
||||
|
|
@ -269,6 +272,7 @@ export const MemberLogsTab = ({
|
|||
owner: taskOwner,
|
||||
status: taskStatus,
|
||||
intervals: taskWorkIntervals,
|
||||
since: taskSince,
|
||||
})
|
||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||
const nextLogs = Array.isArray(result) ? [...result] : [];
|
||||
|
|
@ -297,8 +301,8 @@ export const MemberLogsTab = ({
|
|||
cancelled = true;
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince]);
|
||||
|
||||
const fetchDetailForLog = useCallback(
|
||||
async (
|
||||
|
|
|
|||
|
|
@ -369,7 +369,13 @@ export const MessageComposer = ({
|
|||
>
|
||||
{isCrossTeam ? (
|
||||
<>
|
||||
{selectedTargetColor ? (
|
||||
{selectedTarget?.leadName ? (
|
||||
<MemberBadge
|
||||
name={selectedTarget.leadName}
|
||||
color={selectedTarget.leadColor}
|
||||
size="sm"
|
||||
/>
|
||||
) : selectedTargetColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: selectedTargetColor }}
|
||||
|
|
@ -442,7 +448,9 @@ export const MessageComposer = ({
|
|||
setTeamSelectorOpen(false);
|
||||
}}
|
||||
>
|
||||
{target.color ? (
|
||||
{target.leadName ? (
|
||||
<MemberBadge name={target.leadName} color={target.leadColor} size="sm" />
|
||||
) : target.color ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: target.color }}
|
||||
|
|
|
|||
|
|
@ -90,11 +90,10 @@ const diffSpecificTheme = EditorView.theme({
|
|||
},
|
||||
'.cm-insertedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' },
|
||||
'.cm-deletedLine': { backgroundColor: 'var(--diff-cm-deleted-bg) !important' },
|
||||
// Merge toolbar — absolute, Y and right set dynamically by mousemove handler
|
||||
// Merge toolbar — absolute, Y and left set dynamically by JS handlers
|
||||
'.cm-deletedChunk .cm-chunkButtons': {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
right: '8px',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
|
|
@ -468,14 +467,12 @@ export const CodeMirrorDiffView = ({
|
|||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||
if (showMergeControls) {
|
||||
// Helper: pin chunkButtons to right edge of visible viewport, accounting for horizontal scroll
|
||||
const pinToViewportRight = (
|
||||
btnContainer: HTMLElement,
|
||||
parentRect: DOMRect,
|
||||
scroller: Element
|
||||
): void => {
|
||||
const scrollLeft = scroller.scrollLeft;
|
||||
// When scrolled right, shift the button left so it stays visible
|
||||
btnContainer.style.right = `${-scrollLeft + 8}px`;
|
||||
const pinToViewportRight = (btnContainer: HTMLElement, scroller: Element): void => {
|
||||
const scrollerEl = scroller as HTMLElement;
|
||||
const btnWidth = btnContainer.offsetWidth || 200;
|
||||
// Position at: scrollLeft + visible width - button width - margin
|
||||
btnContainer.style.left = `${scrollerEl.scrollLeft + scrollerEl.clientWidth - btnWidth - 8}px`;
|
||||
btnContainer.style.right = 'auto';
|
||||
};
|
||||
|
||||
// Helper: position a chunkButtons container so it's below the change block,
|
||||
|
|
@ -493,7 +490,7 @@ export const CodeMirrorDiffView = ({
|
|||
targetY = scrollerRect.bottom - tbHeight;
|
||||
}
|
||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||
pinToViewportRight(btnContainer, parentRect, scroller);
|
||||
pinToViewportRight(btnContainer, scroller);
|
||||
};
|
||||
|
||||
const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => {
|
||||
|
|
@ -511,7 +508,7 @@ export const CodeMirrorDiffView = ({
|
|||
targetY = scrollerRect.top;
|
||||
}
|
||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||
pinToViewportRight(btnContainer, parentRect, scroller);
|
||||
pinToViewportRight(btnContainer, scroller);
|
||||
};
|
||||
|
||||
// Find which chunk index the mouse is directly over (deleted or inserted area)
|
||||
|
|
@ -602,7 +599,7 @@ export const CodeMirrorDiffView = ({
|
|||
if (chunkEl) {
|
||||
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
||||
if (btnContainer) {
|
||||
pinToViewportRight(btnContainer, chunkEl.getBoundingClientRect(), view.scrollDOM);
|
||||
pinToViewportRight(btnContainer, view.scrollDOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { updateOriginalDoc } from '@codemirror/merge';
|
||||
import { type Extension, Facet, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
||||
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { getChunks } from './CodeMirrorDiffUtils';
|
||||
|
||||
|
|
@ -221,6 +227,7 @@ const portionCollapseTheme = EditorView.theme({
|
|||
userSelect: 'none',
|
||||
position: 'sticky',
|
||||
left: '0',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
|
||||
'.cm-portion-collapse-text': {
|
||||
|
|
@ -381,6 +388,37 @@ const portionCollapseField = StateField.define<PortionCollapseState>({
|
|||
},
|
||||
});
|
||||
|
||||
// ─── Viewport-pinning plugin ───
|
||||
// Block widgets span the full content width (can be thousands of px for wide files).
|
||||
// This plugin sets an explicit width on .cm-portion-collapse elements so they match
|
||||
// the visible viewport width, making `position: sticky; left: 0` actually constrain them.
|
||||
|
||||
function syncCollapseWidths(view: EditorView): void {
|
||||
const w = view.scrollDOM.clientWidth;
|
||||
if (!w) return;
|
||||
const els = view.dom.querySelectorAll<HTMLElement>('.cm-portion-collapse');
|
||||
for (const el of els) {
|
||||
el.style.width = `${w}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const portionCollapsePinPlugin = ViewPlugin.define((view) => {
|
||||
// Initial sync after first render
|
||||
requestAnimationFrame(() => syncCollapseWidths(view));
|
||||
return {
|
||||
update() {
|
||||
requestAnimationFrame(() => syncCollapseWidths(view));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const portionCollapseScrollHandler = EditorView.domEventHandlers({
|
||||
scroll(_event, view) {
|
||||
syncCollapseWidths(view);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Extension ───
|
||||
|
||||
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
||||
|
|
@ -395,5 +433,7 @@ export function portionCollapseExtension(config?: PortionCollapseConfig): Extens
|
|||
portionCollapseConfigFacet.of({ margin, minSize, portionSize }),
|
||||
portionCollapseField,
|
||||
portionCollapseTheme,
|
||||
portionCollapsePinPlugin,
|
||||
portionCollapseScrollHandler,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,6 +305,8 @@ export interface TeamSlice {
|
|||
displayName: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
}[];
|
||||
crossTeamTargetsLoading: boolean;
|
||||
fetchCrossTeamTargets: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -544,7 +544,16 @@ export interface CrossTeamAPI {
|
|||
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
|
||||
listTargets: (
|
||||
excludeTeam?: string
|
||||
) => Promise<{ teamName: string; displayName: string; description?: string; color?: string }[]>;
|
||||
) => Promise<
|
||||
{
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
}[]
|
||||
>;
|
||||
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue