diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 4ba12644..f93cbf8e 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -31,6 +31,11 @@ interface CacheEntry { expiresAt: number; } +interface TaskChangeCacheEntry { + data: TaskChangeSetV2; + expiresAt: number; +} + /** Ссылка на JSONL файл с привязкой к memberName */ interface LogFileRef { filePath: string; @@ -39,7 +44,9 @@ interface LogFileRef { export class ChangeExtractorService { private cache = new Map(); + private taskChangeCache = new Map(); private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk + private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes constructor( private readonly logsFinder: TeamMemberLogsFinder, @@ -115,6 +122,12 @@ export class ChangeExtractorService { since?: string; } ): Promise { + const cacheKey = `task:${teamName}:${taskId}`; + const cached = this.taskChangeCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + const taskMeta = await this.readTaskMeta(teamName, taskId); const logs = await this.logsFinder.findLogsForTask(teamName, taskId, { owner: options?.owner ?? taskMeta?.owner, @@ -124,7 +137,12 @@ export class ChangeExtractorService { }); const logRefs = await this.resolveLogFileRefs(teamName, logs); if (logRefs.length === 0) { - return this.emptyTaskChangeSet(teamName, taskId); + const empty = this.emptyTaskChangeSet(teamName, taskId); + this.taskChangeCache.set(cacheKey, { + data: empty, + expiresAt: Date.now() + this.taskChangeCacheTtl, + }); + return empty; } const projectPath = await this.resolveProjectPath(teamName); @@ -162,7 +180,7 @@ export class ChangeExtractorService { }, }; - return { + const intervalResult: TaskChangeSetV2 = { teamName, taskId, files, @@ -177,9 +195,24 @@ export class ChangeExtractorService { ? ['No file edits found within persisted workIntervals.'] : ['Task boundaries missing — scoped by workIntervals timestamps.'], }; + this.taskChangeCache.set(cacheKey, { + data: intervalResult, + expiresAt: Date.now() + this.taskChangeCacheTtl, + }); + return intervalResult; } - return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath); + const fallbackResult = await this.fallbackSingleTaskScope( + teamName, + taskId, + logRefs, + projectPath + ); + this.taskChangeCache.set(cacheKey, { + data: fallbackResult, + expiresAt: Date.now() + this.taskChangeCacheTtl, + }); + return fallbackResult; } // Фильтруем snippets по tool_use IDs из scope @@ -192,7 +225,7 @@ export class ChangeExtractorService { warnings.push('Some task boundaries could not be precisely determined.'); } - return { + const result: TaskChangeSetV2 = { teamName, taskId, files, @@ -204,6 +237,11 @@ export class ChangeExtractorService { scope: allScopes[0], warnings, }; + this.taskChangeCache.set(cacheKey, { + data: result, + expiresAt: Date.now() + this.taskChangeCacheTtl, + }); + return result; } /** Получить краткую статистику */ @@ -684,14 +722,27 @@ export class ChangeExtractorService { return false; } - /** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */ + /** Конвертировать MemberLogSummary[] в LogFileRef[] */ private async resolveLogFileRefs( teamName: string, logs: MemberLogSummary[] ): Promise { const refs: LogFileRef[] = []; - const byMember = new Map(); + const logsNeedingResolve: MemberLogSummary[] = []; + for (const log of logs) { + const memberName = log.memberName ?? 'unknown'; + if (log.filePath) { + refs.push({ filePath: log.filePath, memberName }); + } else { + logsNeedingResolve.push(log); + } + } + + if (logsNeedingResolve.length === 0) return refs; + + const byMember = new Map(); + for (const log of logsNeedingResolve) { const name = log.memberName ?? 'unknown'; if (!byMember.has(name)) byMember.set(name, []); byMember.get(name)!.push(log); diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index d648dc6a..a82f2152 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -12,7 +12,7 @@ import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@share export class TeamInboxWriter { async sendMessage(teamName: string, request: SendMessageRequest): Promise { const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`); - const messageId = randomUUID(); + const messageId = request.messageId?.trim() || randomUUID(); const attachmentMeta = request.attachments?.map((a) => ({ id: a.id, @@ -25,17 +25,20 @@ export class TeamInboxWriter { from: request.from ?? 'user', to: request.to ?? request.member, text: request.text, - timestamp: new Date().toISOString(), + timestamp: request.timestamp ?? new Date().toISOString(), read: false, summary: request.summary, messageId, attachments: attachmentMeta?.length ? attachmentMeta : undefined, ...(request.source && { source: request.source }), ...(request.leadSessionId && { leadSessionId: request.leadSessionId }), + ...(request.color && { color: request.color }), ...(request.conversationId && { conversationId: request.conversationId }), ...(request.replyToConversationId && { replyToConversationId: request.replyToConversationId, }), + ...(request.toolSummary && { toolSummary: request.toolSummary }), + ...(request.toolCalls && { toolCalls: request.toolCalls }), }; await withFileLock(inboxPath, async () => { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index c129b381..45082319 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -767,6 +767,7 @@ export class TeamMemberLogsFinder { durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, + filePath, }; } @@ -990,6 +991,7 @@ export class TeamMemberLogsFinder { durationMs: Math.max(0, durationMs), messageCount: metadata.messageCount, isOngoing, + filePath: jsonlPath, }; } diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 1bdfa32b..408b8c87 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -725,11 +725,6 @@ export const LeadThoughtsGroupRow = ({ {thoughts.length} thoughts - - {formatTime(oldest.timestamp) === formatTime(newest.timestamp) - ? formatTime(oldest.timestamp) - : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} - {!isBodyVisible && headerTextPreview ? ( @@ -764,6 +759,11 @@ export const LeadThoughtsGroupRow = ({ ) : null} + + {formatTime(oldest.timestamp) === formatTime(newest.timestamp) + ? formatTime(oldest.timestamp) + : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} + {/* Scrollable body — live thoughts follow bottom unless user scrolls up */} diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index 497d366f..56e373d0 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -466,12 +466,17 @@ 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 + // Helper: pin chunkButtons to right edge of visible viewport, accounting for horizontal scroll. + // Uses getBoundingClientRect() so the offset from gutters / CM content padding is handled exactly. const pinToViewportRight = (btnContainer: HTMLElement, scroller: Element): void => { - const scrollerEl = scroller as HTMLElement; + const scrollerRect = scroller.getBoundingClientRect(); + const chunkEl = btnContainer.parentElement; + if (!chunkEl) return; + const chunkRect = chunkEl.getBoundingClientRect(); const btnWidth = btnContainer.offsetWidth || 200; - // Position at: scrollLeft + visible width - button width - margin - btnContainer.style.left = `${scrollerEl.scrollLeft + scrollerEl.clientWidth - btnWidth - 8}px`; + const margin = 12; + // left is relative to .cm-deletedChunk — so we compute from scroller's right edge + btnContainer.style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`; btnContainer.style.right = 'auto'; }; diff --git a/src/renderer/components/team/review/portionCollapse.ts b/src/renderer/components/team/review/portionCollapse.ts index 63ea4a66..8bc2f0b9 100644 --- a/src/renderer/components/team/review/portionCollapse.ts +++ b/src/renderer/components/team/review/portionCollapse.ts @@ -394,11 +394,17 @@ const portionCollapseField = StateField.define({ // 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 scrollerRect = view.scrollDOM.getBoundingClientRect(); + if (!scrollerRect.width) return; const els = view.dom.querySelectorAll('.cm-portion-collapse'); for (const el of els) { - el.style.width = `${w}px`; + // The widget lives inside .cm-content which may have a left offset (gutters). + // Compute available width from scroller's right edge minus the element's left position. + const elRect = el.getBoundingClientRect(); + const w = scrollerRect.right - elRect.left; + if (w > 0) { + el.style.width = `${w}px`; + } } } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 4272a407..cf4ba2c2 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -271,14 +271,19 @@ export interface SendMessageRequest { text: string; summary?: string; from?: string; + timestamp?: string; + messageId?: string; /** Override the `to` field in the stored message (defaults to `member`). */ to?: string; + color?: string; attachments?: AttachmentPayload[]; source?: InboxMessage['source']; /** Lead session ID for session boundary detection. */ leadSessionId?: string; conversationId?: string; replyToConversationId?: string; + toolSummary?: string; + toolCalls?: ToolCallMeta[]; } export interface SendMessageResult { @@ -525,6 +530,8 @@ export interface MemberLogSummaryBase { durationMs: number; messageCount: number; isOngoing: boolean; + /** Absolute path to JSONL file when known (avoids redundant findMemberLogPaths scan). */ + filePath?: string; } export interface MemberSubagentLogSummary extends MemberLogSummaryBase { diff --git a/test/main/services/team/TeamInboxWriter.test.ts b/test/main/services/team/TeamInboxWriter.test.ts index fd44cfd4..c61de12a 100644 --- a/test/main/services/team/TeamInboxWriter.test.ts +++ b/test/main/services/team/TeamInboxWriter.test.ts @@ -49,11 +49,16 @@ vi.mock('crypto', async (importOriginal) => { }; }); -vi.mock('fs', () => ({ - promises: { - readFile: hoisted.readFile, - }, -})); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + readFile: hoisted.readFile, + }, + }; +}); vi.mock('../../../../src/main/utils/pathDecoder', () => ({ getTeamsBasePath: () => '/mock/teams', @@ -63,6 +68,14 @@ vi.mock('../../../../src/main/services/team/atomicWrite', () => ({ atomicWriteAsync: hoisted.atomicWrite, })); +vi.mock('../../../../src/main/services/team/fileLock', () => ({ + withFileLock: async (_path: string, fn: () => Promise) => await fn(), +})); + +vi.mock('../../../../src/main/services/team/inboxLock', () => ({ + withInboxLock: async (_path: string, fn: () => Promise) => await fn(), +})); + import { TeamInboxWriter } from '../../../../src/main/services/team/TeamInboxWriter'; describe('TeamInboxWriter', () => { @@ -144,6 +157,37 @@ describe('TeamInboxWriter', () => { expect(persisted[0].source).toBe('system_notification'); }); + it('preserves provided message identity fields for dedup across live and persisted rows', async () => { + const result = await writer.sendMessage('my-team', { + member: 'alice', + from: 'team-lead', + to: 'team-best.user', + text: 'Hello cross-team', + summary: 'Cross-team reply', + messageId: 'lead-sendmsg-run-1-123', + timestamp: '2026-03-10T00:33:55.000Z', + source: 'lead_process', + color: 'purple', + toolSummary: '1 tool', + toolCalls: [{ name: 'SendMessage', preview: 'team-best.user' }], + }); + + const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record[]; + expect(result.messageId).toBe('lead-sendmsg-run-1-123'); + expect(persisted[0]).toMatchObject({ + from: 'team-lead', + to: 'team-best.user', + text: 'Hello cross-team', + summary: 'Cross-team reply', + messageId: 'lead-sendmsg-run-1-123', + timestamp: '2026-03-10T00:33:55.000Z', + source: 'lead_process', + color: 'purple', + toolSummary: '1 tool', + toolCalls: [{ name: 'SendMessage', preview: 'team-best.user' }], + }); + }); + it('omits source field from payload when not provided in request', async () => { await writer.sendMessage('my-team', { member: 'alice',