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:
parent
4a2b8baaf5
commit
e2afcbd3b7
12 changed files with 186 additions and 24 deletions
|
|
@ -27,6 +27,8 @@ export interface CrossTeamTarget {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
leadName?: string;
|
||||||
|
leadColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CrossTeamService {
|
export class CrossTeamService {
|
||||||
|
|
@ -174,11 +176,15 @@ export class CrossTeamService {
|
||||||
}
|
}
|
||||||
if (!config || config.deletedAt) continue;
|
if (!config || config.deletedAt) continue;
|
||||||
|
|
||||||
|
const lead = config.members?.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||||
|
|
||||||
targets.push({
|
targets.push({
|
||||||
teamName: entry,
|
teamName: entry,
|
||||||
displayName: config.name || entry,
|
displayName: config.name || entry,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
color: config.color,
|
color: config.color,
|
||||||
|
leadName: lead?.name,
|
||||||
|
leadColor: lead?.color,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ const logger = createLogger('Service:TeamMemberLogsFinder');
|
||||||
*/
|
*/
|
||||||
const ATTRIBUTION_SCAN_LINES = 50;
|
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 {
|
interface StreamedMetadata {
|
||||||
firstTimestamp: string | null;
|
firstTimestamp: string | null;
|
||||||
lastTimestamp: string | null;
|
lastTimestamp: string | null;
|
||||||
|
|
@ -42,6 +46,8 @@ function trimTrailingSlashes(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TeamMemberLogsFinder {
|
export class TeamMemberLogsFinder {
|
||||||
|
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||||
|
|
@ -120,6 +126,7 @@ export class TeamMemberLogsFinder {
|
||||||
const discovery = await this.discoverProjectSessions(teamName);
|
const discovery = await this.discoverProjectSessions(teamName);
|
||||||
if (!discovery) return [];
|
if (!discovery) return [];
|
||||||
|
|
||||||
|
const sinceMs = this.deriveSinceMs(options);
|
||||||
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
||||||
const results: MemberLogSummary[] = [];
|
const results: MemberLogSummary[] = [];
|
||||||
const leadMemberName =
|
const leadMemberName =
|
||||||
|
|
@ -129,7 +136,7 @@ export class TeamMemberLogsFinder {
|
||||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||||
try {
|
try {
|
||||||
await fs.access(leadJsonl);
|
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(
|
const leadSummary = await this.parseLeadSessionSummary(
|
||||||
leadJsonl,
|
leadJsonl,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -155,7 +162,8 @@ export class TeamMemberLogsFinder {
|
||||||
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
|
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
|
||||||
if (file.startsWith('agent-acompact')) continue;
|
if (file.startsWith('agent-acompact')) continue;
|
||||||
const filePath = path.join(subagentsDir, file);
|
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);
|
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||||
if (!attribution) continue;
|
if (!attribution) continue;
|
||||||
const summary = await this.parseSubagentSummary(
|
const summary = await this.parseSubagentSummary(
|
||||||
|
|
@ -478,6 +486,59 @@ export class TeamMemberLogsFinder {
|
||||||
return { ...discovery, isLeadMember };
|
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(
|
private async fileMentionsTaskId(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
|
||||||
|
|
@ -1066,7 +1066,14 @@ const electronAPI: ElectronAPI = {
|
||||||
},
|
},
|
||||||
listTargets: async (excludeTeam?: string) => {
|
listTargets: async (excludeTeam?: string) => {
|
||||||
return invokeIpcWithResult<
|
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);
|
>(CROSS_TEAM_LIST_TARGETS, excludeTeam);
|
||||||
},
|
},
|
||||||
getOutbox: async (teamName: string) => {
|
getOutbox: async (teamName: string) => {
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ export const TeamProvisioningBanner = ({
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<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)]" />
|
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
||||||
<p className="flex-1 text-xs text-[var(--step-success-text)]">
|
<p className="flex-1 text-xs text-[var(--step-success-text)]">
|
||||||
Team launched — teammates may still be starting
|
Team launched — teammates may still be starting
|
||||||
|
|
|
||||||
|
|
@ -413,7 +413,11 @@ export const ActivityItem = ({
|
||||||
<MemberBadge
|
<MemberBadge
|
||||||
name={crossTeamOrigin ? crossTeamOrigin.memberName : message.from}
|
name={crossTeamOrigin ? crossTeamOrigin.memberName : message.from}
|
||||||
color={isCrossTeamAny ? 'purple' : (memberColor ?? message.color)}
|
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}
|
onClick={onMemberNameClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,29 @@ import type {
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} 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 {
|
interface TaskDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
@ -774,6 +797,7 @@ export const TaskDetailDialog = ({
|
||||||
taskOwner={currentTask.owner}
|
taskOwner={currentTask.owner}
|
||||||
taskStatus={currentTask.status}
|
taskStatus={currentTask.status}
|
||||||
taskWorkIntervals={currentTask.workIntervals}
|
taskWorkIntervals={currentTask.workIntervals}
|
||||||
|
taskSince={deriveTaskSince(currentTask)}
|
||||||
onRefreshingChange={setLogsRefreshing}
|
onRefreshingChange={setLogsRefreshing}
|
||||||
// Only show a "latest messages" preview when this task is owned by a subagent.
|
// 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),
|
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ interface MemberLogsTabProps {
|
||||||
taskStatus?: string;
|
taskStatus?: string;
|
||||||
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
|
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
|
||||||
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
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. */
|
/** Notifies parent when a background refresh starts/ends. */
|
||||||
onRefreshingChange?: (isRefreshing: boolean) => void;
|
onRefreshingChange?: (isRefreshing: boolean) => void;
|
||||||
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
|
||||||
|
|
@ -55,6 +57,7 @@ export const MemberLogsTab = ({
|
||||||
taskOwner,
|
taskOwner,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
taskWorkIntervals,
|
taskWorkIntervals,
|
||||||
|
taskSince,
|
||||||
onRefreshingChange,
|
onRefreshingChange,
|
||||||
showSubagentPreview = false,
|
showSubagentPreview = false,
|
||||||
showLeadPreview = false,
|
showLeadPreview = false,
|
||||||
|
|
@ -269,6 +272,7 @@ export const MemberLogsTab = ({
|
||||||
owner: taskOwner,
|
owner: taskOwner,
|
||||||
status: taskStatus,
|
status: taskStatus,
|
||||||
intervals: taskWorkIntervals,
|
intervals: taskWorkIntervals,
|
||||||
|
since: taskSince,
|
||||||
})
|
})
|
||||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||||
const nextLogs = Array.isArray(result) ? [...result] : [];
|
const nextLogs = Array.isArray(result) ? [...result] : [];
|
||||||
|
|
@ -297,8 +301,8 @@ export const MemberLogsTab = ({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
|
// 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]);
|
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince]);
|
||||||
|
|
||||||
const fetchDetailForLog = useCallback(
|
const fetchDetailForLog = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|
|
||||||
|
|
@ -369,7 +369,13 @@ export const MessageComposer = ({
|
||||||
>
|
>
|
||||||
{isCrossTeam ? (
|
{isCrossTeam ? (
|
||||||
<>
|
<>
|
||||||
{selectedTargetColor ? (
|
{selectedTarget?.leadName ? (
|
||||||
|
<MemberBadge
|
||||||
|
name={selectedTarget.leadName}
|
||||||
|
color={selectedTarget.leadColor}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : selectedTargetColor ? (
|
||||||
<span
|
<span
|
||||||
className="inline-block size-2 shrink-0 rounded-full"
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: selectedTargetColor }}
|
style={{ backgroundColor: selectedTargetColor }}
|
||||||
|
|
@ -442,7 +448,9 @@ export const MessageComposer = ({
|
||||||
setTeamSelectorOpen(false);
|
setTeamSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{target.color ? (
|
{target.leadName ? (
|
||||||
|
<MemberBadge name={target.leadName} color={target.leadColor} size="sm" />
|
||||||
|
) : target.color ? (
|
||||||
<span
|
<span
|
||||||
className="inline-block size-2 shrink-0 rounded-full"
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: target.color }}
|
style={{ backgroundColor: target.color }}
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,10 @@ const diffSpecificTheme = EditorView.theme({
|
||||||
},
|
},
|
||||||
'.cm-insertedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' },
|
'.cm-insertedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' },
|
||||||
'.cm-deletedLine': { backgroundColor: 'var(--diff-cm-deleted-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': {
|
'.cm-deletedChunk .cm-chunkButtons': {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '0',
|
top: '0',
|
||||||
right: '8px',
|
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
|
@ -468,14 +467,12 @@ export const CodeMirrorDiffView = ({
|
||||||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||||
if (showMergeControls) {
|
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
|
||||||
const pinToViewportRight = (
|
const pinToViewportRight = (btnContainer: HTMLElement, scroller: Element): void => {
|
||||||
btnContainer: HTMLElement,
|
const scrollerEl = scroller as HTMLElement;
|
||||||
parentRect: DOMRect,
|
const btnWidth = btnContainer.offsetWidth || 200;
|
||||||
scroller: Element
|
// Position at: scrollLeft + visible width - button width - margin
|
||||||
): void => {
|
btnContainer.style.left = `${scrollerEl.scrollLeft + scrollerEl.clientWidth - btnWidth - 8}px`;
|
||||||
const scrollLeft = scroller.scrollLeft;
|
btnContainer.style.right = 'auto';
|
||||||
// When scrolled right, shift the button left so it stays visible
|
|
||||||
btnContainer.style.right = `${-scrollLeft + 8}px`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: position a chunkButtons container so it's below the change block,
|
// Helper: position a chunkButtons container so it's below the change block,
|
||||||
|
|
@ -493,7 +490,7 @@ export const CodeMirrorDiffView = ({
|
||||||
targetY = scrollerRect.bottom - tbHeight;
|
targetY = scrollerRect.bottom - tbHeight;
|
||||||
}
|
}
|
||||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||||
pinToViewportRight(btnContainer, parentRect, scroller);
|
pinToViewportRight(btnContainer, scroller);
|
||||||
};
|
};
|
||||||
|
|
||||||
const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => {
|
const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => {
|
||||||
|
|
@ -511,7 +508,7 @@ export const CodeMirrorDiffView = ({
|
||||||
targetY = scrollerRect.top;
|
targetY = scrollerRect.top;
|
||||||
}
|
}
|
||||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
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)
|
// Find which chunk index the mouse is directly over (deleted or inserted area)
|
||||||
|
|
@ -602,7 +599,7 @@ export const CodeMirrorDiffView = ({
|
||||||
if (chunkEl) {
|
if (chunkEl) {
|
||||||
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
||||||
if (btnContainer) {
|
if (btnContainer) {
|
||||||
pinToViewportRight(btnContainer, chunkEl.getBoundingClientRect(), view.scrollDOM);
|
pinToViewportRight(btnContainer, view.scrollDOM);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { updateOriginalDoc } from '@codemirror/merge';
|
import { updateOriginalDoc } from '@codemirror/merge';
|
||||||
import { type Extension, Facet, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
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';
|
import { getChunks } from './CodeMirrorDiffUtils';
|
||||||
|
|
||||||
|
|
@ -221,6 +227,7 @@ const portionCollapseTheme = EditorView.theme({
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: '0',
|
left: '0',
|
||||||
|
boxSizing: 'border-box',
|
||||||
},
|
},
|
||||||
|
|
||||||
'.cm-portion-collapse-text': {
|
'.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 ───
|
// ─── Extension ───
|
||||||
|
|
||||||
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
||||||
|
|
@ -395,5 +433,7 @@ export function portionCollapseExtension(config?: PortionCollapseConfig): Extens
|
||||||
portionCollapseConfigFacet.of({ margin, minSize, portionSize }),
|
portionCollapseConfigFacet.of({ margin, minSize, portionSize }),
|
||||||
portionCollapseField,
|
portionCollapseField,
|
||||||
portionCollapseTheme,
|
portionCollapseTheme,
|
||||||
|
portionCollapsePinPlugin,
|
||||||
|
portionCollapseScrollHandler,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,8 @@ export interface TeamSlice {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
leadName?: string;
|
||||||
|
leadColor?: string;
|
||||||
}[];
|
}[];
|
||||||
crossTeamTargetsLoading: boolean;
|
crossTeamTargetsLoading: boolean;
|
||||||
fetchCrossTeamTargets: () => Promise<void>;
|
fetchCrossTeamTargets: () => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -544,7 +544,16 @@ export interface CrossTeamAPI {
|
||||||
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
|
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
|
||||||
listTargets: (
|
listTargets: (
|
||||||
excludeTeam?: string
|
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[]>;
|
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue