feat: optimize team display name resolution and enhance file change handling

- Introduced a caching mechanism for team display names to reduce redundant API calls and improve performance.
- Updated file change event handling to debounce cache invalidation, preventing unnecessary rescans during rapid file changes.
- Enhanced GlobalTaskDetailDialog and TaskDetailDialog to manage loading states and improve team data fetching logic.
- Refactored teamSlice to streamline team selection and data loading processes.
This commit is contained in:
iliya 2026-02-28 22:16:18 +02:00
parent ae2f7d9570
commit b346fd319d
5 changed files with 206 additions and 90 deletions

View file

@ -62,6 +62,7 @@ import {
UpdaterService,
} from './services';
import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
@ -76,18 +77,51 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500;
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
const suppressedSources = new Set(['user_sent']);
// --- Team display name cache (avoid listTeams() on every notification) ---
const TEAM_DISPLAY_NAME_TTL_MS = 30_000;
const teamDisplayNameCache = new Map<string, { value: string; expiresAt: number }>();
let teamListInFlight: Promise<Map<string, string>> | null = null;
async function refreshTeamDisplayNameCache(): Promise<Map<string, string>> {
if (teamListInFlight) {
return teamListInFlight;
}
teamListInFlight = (async () => {
const out = new Map<string, string>();
try {
if (!teamDataService) return out;
const summary = await teamDataService.listTeams();
for (const team of summary) {
if (team?.teamName) {
out.set(team.teamName, team.displayName || team.teamName);
}
}
} catch {
// ignore
} finally {
teamListInFlight = null;
}
return out;
})();
return teamListInFlight;
}
/** Resolve human-friendly team display name, falling back to raw teamName. */
async function resolveTeamDisplayName(teamName: string): Promise<string> {
try {
if (teamDataService) {
const summary = await teamDataService.listTeams();
const team = summary.find((t) => t.teamName === teamName);
if (team?.displayName) return team.displayName;
}
} catch {
// fallback
const cached = teamDisplayNameCache.get(teamName);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
return teamName;
const map = await refreshTeamDisplayNameCache();
const resolved = map.get(teamName) ?? teamName;
teamDisplayNameCache.set(teamName, {
value: resolved,
expiresAt: Date.now() + TEAM_DISPLAY_NAME_TTL_MS,
});
return resolved;
}
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
@ -220,9 +254,37 @@ function wireFileWatcherEvents(context: ServiceContext): void {
}
// Wire file-change events to renderer and HTTP SSE
const SCAN_CACHE_INVALIDATE_DEBOUNCE_MS = 250;
let scanCacheInvalidateTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleScanCacheInvalidation = (): void => {
if (scanCacheInvalidateTimer) {
clearTimeout(scanCacheInvalidateTimer);
}
scanCacheInvalidateTimer = setTimeout(() => {
scanCacheInvalidateTimer = null;
context.projectScanner.clearScanCache();
}, SCAN_CACHE_INVALIDATE_DEBOUNCE_MS);
};
const fileChangeHandler = (event: unknown): void => {
// Invalidate the scan cache so the next IPC request sees fresh data
context.projectScanner.clearScanCache();
// Avoid triggering a full project rescan on every session append.
// The ProjectScanner already has a short TTL cache; we only invalidate for
// structural changes (add/unlink), and we debounce bursts of events.
try {
if (event && typeof event === 'object') {
const row = event as Partial<FileChangeEvent>;
const isSubagent = row.isSubagent === true;
const changeType = row.type;
if (!isSubagent && (changeType === 'add' || changeType === 'unlink')) {
scheduleScanCacheInvalidation();
}
} else {
// Fallback: if we can't classify the event, invalidate (debounced).
scheduleScanCacheInvalidation();
}
} catch {
// ignore
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file-change', event);
@ -230,7 +292,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
httpServer?.broadcast('file-change', event);
};
context.fileWatcher.on('file-change', fileChangeHandler);
fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler);
fileChangeCleanup = () => {
context.fileWatcher.off('file-change', fileChangeHandler);
if (scanCacheInvalidateTimer) {
clearTimeout(scanCacheInvalidateTimer);
scanCacheInvalidateTimer = null;
}
};
// Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above)
const todoChangeHandler = (event: unknown): void => {

View file

@ -12,7 +12,6 @@ import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
@ -969,58 +968,76 @@ export class TeamDataService {
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
const texts: InboxMessage[] = [];
const stream = fs.createReadStream(jsonlPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
// Optimization: read from the end of the JSONL file (we only need the last N texts).
// The full file can be huge; scanning from the start causes long stalls on Windows.
const MAX_SCAN_BYTES = 8 * 1024 * 1024; // 8MB tail cap
const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
const textsReversed: InboxMessage[] = [];
const handle = await fs.promises.open(jsonlPath, 'r');
try {
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
const stat = await handle.stat();
const fileSize = stat.size;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize);
while (textsReversed.length < MAX_LEAD_TEXTS && scanBytes <= MAX_SCAN_BYTES) {
const start = Math.max(0, fileSize - scanBytes);
const buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start);
const chunk = buffer.toString('utf8');
const lines = chunk.split(/\r?\n/);
// If we started mid-file, the first line may be partial — drop it.
const fromIndex = start > 0 ? 1 : 0;
for (let i = lines.length - 1; i >= fromIndex; i--) {
const trimmed = lines[i]?.trim();
if (!trimmed) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (msg.type !== 'assistant') continue;
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) continue;
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
for (const block of content as Record<string, unknown>[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
const text = block.text.trim();
if (text.length < MIN_TEXT_LENGTH) continue;
textsReversed.push({
from: leadName,
text,
timestamp,
read: true,
source: 'lead_session',
});
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
}
if (msg.type !== 'assistant') continue;
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) continue;
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
for (const block of content as Record<string, unknown>[]) {
if (block.type !== 'text' || typeof block.text !== 'string') continue;
const text = block.text.trim();
if (text.length < MIN_TEXT_LENGTH) continue;
texts.push({
from: leadName,
text,
timestamp,
read: true,
source: 'lead_session',
});
}
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2);
}
} finally {
rl.close();
stream.destroy();
await handle.close();
}
// Keep only the last N texts
if (texts.length > MAX_LEAD_TEXTS) {
return texts.slice(-MAX_LEAD_TEXTS);
}
return texts;
// Convert back to chronological order (old behavior) and keep the last N texts.
textsReversed.reverse();
const texts = textsReversed;
return texts.length > MAX_LEAD_TEXTS ? texts.slice(-MAX_LEAD_TEXTS) : texts;
}
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { ExternalLink } from 'lucide-react';
@ -6,7 +6,7 @@ import { useShallow } from 'zustand/react/shallow';
import { TaskDetailDialog } from './TaskDetailDialog';
import type { TeamTaskWithKanban } from '@shared/types';
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
/**
* Global wrapper around TaskDetailDialog.
@ -17,36 +17,59 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
const {
globalTaskDetail,
closeGlobalTaskDetail,
selectedTeamName,
selectedTeamData,
selectedTeamLoading,
selectTeam,
openTeamTab,
setPendingReviewRequest,
globalTasks,
} = useStore(
useShallow((s) => ({
globalTaskDetail: s.globalTaskDetail,
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
selectedTeamLoading: s.selectedTeamLoading,
selectTeam: s.selectTeam,
openTeamTab: s.openTeamTab,
setPendingReviewRequest: s.setPendingReviewRequest,
globalTasks: s.globalTasks,
}))
);
const taskMap = useMemo(() => {
const map = new Map<string, TeamTaskWithKanban>();
if (!selectedTeamData) return map;
for (const t of selectedTeamData.tasks) map.set(t.id, t);
return map;
}, [selectedTeamData]);
const activeMembers = useMemo(
() => selectedTeamData?.members.filter((m) => !m.removedAt) ?? [],
[selectedTeamData]
);
const teamName = globalTaskDetail?.teamName ?? '';
const taskId = globalTaskDetail?.taskId ?? '';
// Load full team data in the background to enable "as before" details (logs/changes/members).
useEffect(() => {
if (!globalTaskDetail) return;
if (selectedTeamName === teamName && selectedTeamData) return;
void selectTeam(teamName, { skipProjectAutoSelect: true });
}, [globalTaskDetail, selectTeam, selectedTeamData, selectedTeamName, teamName]);
const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData;
const taskMap = useMemo(() => {
const map = new Map<string, TeamTaskWithKanban>();
if (!globalTaskDetail) return map;
if (isFullTeamLoaded && selectedTeamData) {
for (const t of selectedTeamData.tasks) map.set(t.id, t);
return map;
}
for (const t of globalTasks) {
if (t.teamName === globalTaskDetail.teamName) {
map.set(t.id, t);
}
}
return map;
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
const activeMembers = useMemo(
() => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []),
[isFullTeamLoaded, selectedTeamData]
);
const handleOpenTeam = useCallback((): void => {
closeGlobalTaskDetail();
openTeamTab(teamName, undefined, taskId);
@ -63,13 +86,16 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
if (!globalTaskDetail) return null;
const task = taskMap.get(taskId) ?? null;
const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId];
const task = (taskMap.get(taskId) as GlobalTask | undefined) ?? null;
const kanbanTaskState = isFullTeamLoaded
? selectedTeamData?.kanbanState.tasks[taskId]
: undefined;
return (
<TaskDetailDialog
open
loading={selectedTeamLoading}
variant={isFullTeamLoaded ? 'team' : 'global'}
loading={!isFullTeamLoaded && selectedTeamLoading}
task={task}
teamName={teamName}
kanbanTaskState={kanbanTaskState}
@ -77,7 +103,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
members={activeMembers}
onClose={closeGlobalTaskDetail}
onOwnerChange={undefined}
onViewChanges={handleViewChanges}
onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined}
headerExtra={
<button
type="button"

View file

@ -62,6 +62,7 @@ import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@s
interface TaskDetailDialogProps {
open: boolean;
loading?: boolean;
variant?: 'team' | 'global';
task: TeamTaskWithKanban | null;
teamName: string;
kanbanTaskState?: KanbanTaskState;
@ -79,6 +80,7 @@ interface TaskDetailDialogProps {
export const TaskDetailDialog = ({
open,
loading = false,
variant = 'team',
task,
teamName,
kanbanTaskState,
@ -208,6 +210,7 @@ export const TaskDetailDialog = ({
}, [activeChangeSet, currentTask]);
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
// Only fetch if we don't already have data for this task
if (taskChangesFiles !== null) return;
@ -220,6 +223,7 @@ export const TaskDetailDialog = ({
fetchTaskChanges,
taskChangesFiles,
onViewChanges,
variant,
]);
const handleDependencyClick = (taskId: string): void => {
@ -536,7 +540,7 @@ export const TaskDetailDialog = ({
</CollapsibleTeamSection>
{/* Changes */}
{isTaskCompleted && onViewChanges ? (
{variant === 'team' && isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection
title="Changes"
icon={<FileDiff size={14} />}
@ -582,16 +586,22 @@ export const TaskDetailDialog = ({
) : null}
{/* Execution Logs — sessions that reference this task */}
<CollapsibleTeamSection title="Execution Logs" icon={<ScrollText size={14} />} defaultOpen>
<div className="min-w-0 overflow-hidden">
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
/>
</div>
</CollapsibleTeamSection>
{variant === 'team' ? (
<CollapsibleTeamSection
title="Execution Logs"
icon={<ScrollText size={14} />}
defaultOpen
>
<div className="min-w-0 overflow-hidden">
<MemberLogsTab
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
/>
</div>
</CollapsibleTeamSection>
) : null}
<div className="mb-3 space-y-2">
{/* Dependencies */}

View file

@ -204,11 +204,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
setPendingReviewRequest: (req) => set({ pendingReviewRequest: req }),
openGlobalTaskDetail: (teamName: string, taskId: string) => {
set({ globalTaskDetail: { teamName, taskId } });
// Ensure team data is loaded for the dialog
const state = get();
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
void state.selectTeam(teamName, { skipProjectAutoSelect: true });
}
},
closeGlobalTaskDetail: () => set({ globalTaskDetail: null }),
addingComment: false,