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:
parent
ae2f7d9570
commit
b346fd319d
5 changed files with 206 additions and 90 deletions
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue