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:
iliya 2026-03-10 00:25:01 +02:00
parent 4a2b8baaf5
commit e2afcbd3b7
12 changed files with 186 additions and 24 deletions

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
];
}

View file

@ -305,6 +305,8 @@ export interface TeamSlice {
displayName: string;
description?: string;
color?: string;
leadName?: string;
leadColor?: string;
}[];
crossTeamTargetsLoading: boolean;
fetchCrossTeamTargets: () => Promise<void>;

View file

@ -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[]>;
}