diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index bd7838ba..418185e9 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -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, }); } diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 4cdfd752..c129b381 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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(); + 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 { + 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, diff --git a/src/preload/index.ts b/src/preload/index.ts index 442d39c1..7c2e87ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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) => { diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index fc435550..aad0e986 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -108,7 +108,7 @@ export const TeamProvisioningBanner = ({ if (isReady) { return (
-
+

Team launched — teammates may still be starting diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 3fb92a23..266e7782 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -413,7 +413,11 @@ export const ActivityItem = ({ diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 4bcfc997..980f8c17 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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), diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index ebe6d36b..2c9b226e 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -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 ( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index eeeaed6e..92bbd388 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -369,7 +369,13 @@ export const MessageComposer = ({ > {isCrossTeam ? ( <> - {selectedTargetColor ? ( + {selectedTarget?.leadName ? ( + + ) : selectedTargetColor ? ( - {target.color ? ( + {target.leadName ? ( + + ) : target.color ? ( { - 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('.cm-chunkButtons'); if (btnContainer) { - pinToViewportRight(btnContainer, chunkEl.getBoundingClientRect(), view.scrollDOM); + pinToViewportRight(btnContainer, view.scrollDOM); } } } diff --git a/src/renderer/components/team/review/portionCollapse.ts b/src/renderer/components/team/review/portionCollapse.ts index e27d40cb..63ea4a66 100644 --- a/src/renderer/components/team/review/portionCollapse.ts +++ b/src/renderer/components/team/review/portionCollapse.ts @@ -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({ }, }); +// ─── 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('.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, ]; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 144f19e4..91fdcd5b 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -305,6 +305,8 @@ export interface TeamSlice { displayName: string; description?: string; color?: string; + leadName?: string; + leadColor?: string; }[]; crossTeamTargetsLoading: boolean; fetchCrossTeamTargets: () => Promise; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9845bd34..8b811615 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -544,7 +544,16 @@ export interface CrossTeamAPI { send: (request: CrossTeamSendRequest) => Promise; 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; }