refactor(session): improve session label formatting and enhance session item display
- Replaced direct access to session.firstMessage with formatSessionLabel for consistent label formatting across components. - Updated SessionItem, TeamSessionsSection, and KanbanFilterPopover to utilize the new formatting function. - Enhanced display logic in SessionItem to differentiate between regular and team sessions, improving user experience. - Added new icons for team sessions and adjusted metadata display for better clarity.
This commit is contained in:
parent
304a2a7f79
commit
46355d87df
11 changed files with 533 additions and 59 deletions
|
|
@ -8,9 +8,10 @@ import { useCallback, useRef, useState } from 'react';
|
|||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser';
|
||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { EyeOff, MessageSquare, Pin } from 'lucide-react';
|
||||
import { EyeOff, MessageSquare, Pin, Play, RotateCw, Users } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { OngoingIndicator } from '../common/OngoingIndicator';
|
||||
|
|
@ -178,7 +179,7 @@ export const SessionItem = ({
|
|||
type: 'session',
|
||||
sessionId: session.id,
|
||||
projectId: activeProjectId,
|
||||
label: session.firstMessage?.slice(0, 50) ?? 'Session',
|
||||
label: formatSessionLabel(session.firstMessage),
|
||||
},
|
||||
forceNewTab ? { forceNewTab } : { replaceActiveTab: true }
|
||||
);
|
||||
|
|
@ -191,7 +192,7 @@ export const SessionItem = ({
|
|||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const sessionLabel = session.firstMessage?.slice(0, 50) ?? 'Session';
|
||||
const sessionLabel = formatSessionLabel(session.firstMessage);
|
||||
|
||||
const handleOpenInCurrentPane = useCallback(() => {
|
||||
if (!activeProjectId) return;
|
||||
|
|
@ -253,49 +254,86 @@ export const SessionItem = ({
|
|||
...(isHidden ? { opacity: 0.5 } : {}),
|
||||
}}
|
||||
>
|
||||
{/* First line: title + ongoing indicator + pin/hidden icons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{multiSelectActive && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected ?? false}
|
||||
onChange={() => onToggleSelect?.()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3.5 shrink-0 accent-blue-500"
|
||||
/>
|
||||
)}
|
||||
{session.isOngoing && <OngoingIndicator />}
|
||||
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
|
||||
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
|
||||
<span
|
||||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
|
||||
>
|
||||
{session.firstMessage ?? 'Untitled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second line: message count + time + context consumption */}
|
||||
<div
|
||||
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MessageSquare className="size-2.5" />
|
||||
{session.messageCount}
|
||||
</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
|
||||
{session.contextConsumption != null && session.contextConsumption > 0 && (
|
||||
{(() => {
|
||||
const parsed = parseSessionTitle(session.firstMessage);
|
||||
const isTeam = parsed.kind !== 'regular';
|
||||
return (
|
||||
<>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<ConsumptionBadge
|
||||
contextConsumption={session.contextConsumption}
|
||||
phaseBreakdown={session.phaseBreakdown}
|
||||
/>
|
||||
{/* First line: title + ongoing indicator + pin/hidden icons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{multiSelectActive && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected ?? false}
|
||||
onChange={() => onToggleSelect?.()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3.5 shrink-0 accent-blue-500"
|
||||
/>
|
||||
)}
|
||||
{session.isOngoing && <OngoingIndicator />}
|
||||
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
|
||||
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
|
||||
{isTeam ? (
|
||||
<span
|
||||
className="flex items-center gap-1.5 truncate text-[13px] font-medium leading-tight"
|
||||
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
|
||||
>
|
||||
<Users className="size-3 shrink-0 text-blue-400" />
|
||||
<span className="truncate">{parsed.displayText}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
|
||||
>
|
||||
{parsed.displayText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Second line: metadata */}
|
||||
<div
|
||||
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{isTeam && parsed.projectName && (
|
||||
<>
|
||||
<span className="truncate">{parsed.projectName}</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
</>
|
||||
)}
|
||||
{isTeam && (
|
||||
<>
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
{parsed.kind === 'team-resume' ? (
|
||||
<RotateCw className="size-2.5" />
|
||||
) : (
|
||||
<Play className="size-2.5" />
|
||||
)}
|
||||
{parsed.kind === 'team-resume' ? 'resume' : 'new'}
|
||||
</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<MessageSquare className="size-2.5" />
|
||||
{session.messageCount}
|
||||
</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
|
||||
{session.contextConsumption != null && session.contextConsumption > 0 && (
|
||||
<>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<ConsumptionBadge
|
||||
contextConsumption={session.contextConsumption}
|
||||
phaseBreakdown={session.phaseBreakdown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
|
||||
{contextMenu &&
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||
import { formatSessionLabel } from '@renderer/utils/sessionTitleParser';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -69,7 +70,7 @@ export const TeamSessionsSection = ({
|
|||
type: 'session',
|
||||
sessionId: session.id,
|
||||
projectId,
|
||||
label: session.firstMessage?.slice(0, 50) ?? 'Session',
|
||||
label: formatSessionLabel(session.firstMessage),
|
||||
},
|
||||
{ forceNewTab: true }
|
||||
);
|
||||
|
|
@ -173,7 +174,7 @@ const SessionRow = ({
|
|||
onToggleFilter,
|
||||
}: SessionRowProps): React.JSX.Element => {
|
||||
const timeAgo = formatShortTime(new Date(session.createdAt));
|
||||
const label = session.firstMessage ?? 'Untitled session';
|
||||
const label = formatSessionLabel(session.firstMessage);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest
|
|||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import {
|
||||
hasSelectedTargetTeamData,
|
||||
shouldKeepGlobalTaskDialogLoading,
|
||||
} from './globalTaskDetailDialogLoading';
|
||||
import { TaskDetailDialog } from './TaskDetailDialog';
|
||||
|
||||
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -21,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
selectedTeamLoading,
|
||||
selectedTeamError,
|
||||
selectTeam,
|
||||
openTeamTab,
|
||||
setPendingReviewRequest,
|
||||
|
|
@ -32,6 +37,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamLoading: s.selectedTeamLoading,
|
||||
selectedTeamError: s.selectedTeamError,
|
||||
selectTeam: s.selectTeam,
|
||||
openTeamTab: s.openTeamTab,
|
||||
setPendingReviewRequest: s.setPendingReviewRequest,
|
||||
|
|
@ -41,6 +47,11 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
|
||||
const teamName = globalTaskDetail?.teamName ?? '';
|
||||
const taskId = globalTaskDetail?.taskId ?? '';
|
||||
const hasTargetTeamData = hasSelectedTargetTeamData(
|
||||
teamName,
|
||||
selectedTeamName,
|
||||
selectedTeamData?.teamName
|
||||
);
|
||||
|
||||
// Load full team data in the background to enable "as before" details (logs/changes/members).
|
||||
useEffect(() => {
|
||||
|
|
@ -65,13 +76,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
teamName,
|
||||
]);
|
||||
|
||||
const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData;
|
||||
// Team data is still loading when:
|
||||
// - selectTeam() hasn't updated selectedTeamName yet (team switch pending)
|
||||
// - selectedTeamName matches but IPC fetch is still in flight
|
||||
const isThisTeamLoading =
|
||||
selectedTeamName !== teamName ||
|
||||
(selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData);
|
||||
const isFullTeamLoaded = hasTargetTeamData;
|
||||
|
||||
const taskMap = useMemo(() => {
|
||||
const map = new Map<string, TeamTaskWithKanban>();
|
||||
|
|
@ -119,12 +124,21 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
const kanbanTaskState = isFullTeamLoaded
|
||||
? selectedTeamData?.kanbanState.tasks[taskId]
|
||||
: undefined;
|
||||
const loading = shouldKeepGlobalTaskDialogLoading({
|
||||
teamName,
|
||||
taskId,
|
||||
selectedTeamName,
|
||||
selectedTeamDataPresent: hasTargetTeamData,
|
||||
selectedTeamLoading,
|
||||
selectedTeamError,
|
||||
hasTaskInMap: taskMap.has(taskId),
|
||||
});
|
||||
|
||||
return (
|
||||
<TaskDetailDialog
|
||||
open
|
||||
variant={isFullTeamLoaded ? 'team' : 'global'}
|
||||
loading={!isFullTeamLoaded && isThisTeamLoading}
|
||||
loading={!isFullTeamLoaded && loading}
|
||||
task={task}
|
||||
teamName={teamName}
|
||||
kanbanTaskState={kanbanTaskState}
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ export const TaskDetailDialog = ({
|
|||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v && lightboxOpenRef.current) return;
|
||||
if (!v) onClose();
|
||||
if (!v) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
interface GlobalTaskDialogLoadingParams {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
selectedTeamName: string | null;
|
||||
selectedTeamDataPresent: boolean;
|
||||
selectedTeamLoading: boolean;
|
||||
selectedTeamError: string | null;
|
||||
hasTaskInMap: boolean;
|
||||
}
|
||||
|
||||
export function hasSelectedTargetTeamData(
|
||||
targetTeamName: string,
|
||||
selectedTeamName: string | null,
|
||||
selectedDataTeamName: string | null | undefined
|
||||
): boolean {
|
||||
return selectedTeamName === targetTeamName && selectedDataTeamName === targetTeamName;
|
||||
}
|
||||
|
||||
export function shouldKeepGlobalTaskDialogLoading({
|
||||
teamName,
|
||||
taskId,
|
||||
selectedTeamName,
|
||||
selectedTeamDataPresent,
|
||||
selectedTeamLoading,
|
||||
selectedTeamError,
|
||||
hasTaskInMap,
|
||||
}: GlobalTaskDialogLoadingParams): boolean {
|
||||
if (!teamName || !taskId) return false;
|
||||
if (selectedTeamName !== teamName) return true;
|
||||
if (selectedTeamLoading && !selectedTeamDataPresent) return true;
|
||||
if (selectedTeamDataPresent) return false;
|
||||
if (selectedTeamError) return false;
|
||||
return !hasTaskInMap;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useMemo } from 'react';
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { formatSessionLabel } from '@renderer/utils/sessionTitleParser';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { displayMemberName } from '@renderer/utils/memberHelpers';
|
||||
import { Crown, Filter } from 'lucide-react';
|
||||
|
|
@ -122,7 +123,7 @@ export const KanbanFilterPopover = ({
|
|||
{sessions.map((session) => {
|
||||
const isLead = session.id === leadSessionId;
|
||||
const isSelected = filter.sessionId === session.id;
|
||||
const label = session.firstMessage?.slice(0, 50) ?? session.id.slice(0, 8);
|
||||
const label = formatSessionLabel(session.firstMessage) || session.id.slice(0, 8);
|
||||
return (
|
||||
<button
|
||||
key={session.id}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,33 @@ interface UseViewportCommentReadOptions {
|
|||
scrollContainer: HTMLElement | null;
|
||||
}
|
||||
|
||||
const VISIBILITY_THRESHOLD = 0.1;
|
||||
|
||||
export function getVisibleCommentIdsFallback(
|
||||
scrollContainer: HTMLElement | null,
|
||||
elementsById: ReadonlyMap<string, HTMLElement>
|
||||
): string[] {
|
||||
if (!scrollContainer || elementsById.size === 0) return [];
|
||||
|
||||
const rootRect = scrollContainer.getBoundingClientRect();
|
||||
const visibleIds: string[] = [];
|
||||
|
||||
for (const [commentId, element] of elementsById) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||
|
||||
const visibleWidth = Math.min(rect.right, rootRect.right) - Math.max(rect.left, rootRect.left);
|
||||
const visibleHeight = Math.min(rect.bottom, rootRect.bottom) - Math.max(rect.top, rootRect.top);
|
||||
|
||||
if (visibleWidth <= 0 || visibleHeight <= 0) continue;
|
||||
if (visibleHeight / rect.height < VISIBILITY_THRESHOLD) continue;
|
||||
|
||||
visibleIds.push(commentId);
|
||||
}
|
||||
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks task comments as read based on viewport visibility.
|
||||
*
|
||||
|
|
@ -45,6 +72,7 @@ export function useViewportCommentRead({
|
|||
flush: () => void;
|
||||
} {
|
||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||
const commentElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const teamNameRef = useRef(teamName);
|
||||
const taskIdRef = useRef(taskId);
|
||||
|
||||
|
|
@ -56,6 +84,7 @@ export function useViewportCommentRead({
|
|||
// Reset tracked state when team/task changes
|
||||
useEffect(() => {
|
||||
seenIdsRef.current = new Set();
|
||||
commentElementsRef.current.clear();
|
||||
}, [teamName, taskId]);
|
||||
|
||||
const persistSeen = useCallback(() => {
|
||||
|
|
@ -82,18 +111,37 @@ export function useViewportCommentRead({
|
|||
|
||||
const { registerElement } = useViewportObserver({
|
||||
root: scrollContainer,
|
||||
threshold: 0.1,
|
||||
threshold: VISIBILITY_THRESHOLD,
|
||||
onVisibleChange: handleVisibleChange,
|
||||
});
|
||||
|
||||
const registerComment = useCallback(
|
||||
(commentId: string) => registerElement(commentId),
|
||||
(commentId: string) => {
|
||||
const registerObservedElement = registerElement(commentId);
|
||||
|
||||
return (el: HTMLElement | null) => {
|
||||
if (el) {
|
||||
commentElementsRef.current.set(commentId, el);
|
||||
} else {
|
||||
commentElementsRef.current.delete(commentId);
|
||||
}
|
||||
|
||||
registerObservedElement(el);
|
||||
};
|
||||
},
|
||||
[registerElement]
|
||||
);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
const fallbackVisibleIds = getVisibleCommentIdsFallback(
|
||||
scrollContainer,
|
||||
commentElementsRef.current
|
||||
);
|
||||
for (const commentId of fallbackVisibleIds) {
|
||||
seenIdsRef.current.add(commentId);
|
||||
}
|
||||
persistSeen();
|
||||
}, [persistSeen]);
|
||||
}, [persistSeen, scrollContainer]);
|
||||
|
||||
return { registerComment, flush };
|
||||
}
|
||||
|
|
|
|||
68
src/renderer/utils/sessionTitleParser.ts
Normal file
68
src/renderer/utils/sessionTitleParser.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Parses session `firstMessage` into a structured title for sidebar display.
|
||||
*
|
||||
* Source formats (generated in src/main/services/team/TeamProvisioningService.ts):
|
||||
* New team (line ~944): agent_teams_ui [Agent Team: "name" | Project: "proj" | Lead: "lead"] ...
|
||||
* Resume (line ~1046): Team Start [Agent Team: "name" | Project: "proj" | Lead: "lead"] ...
|
||||
* (line ~1044): Team Start (resume) [Agent Team: ...] ...
|
||||
*/
|
||||
|
||||
export interface ParsedSessionTitle {
|
||||
kind: 'team-new' | 'team-resume' | 'regular';
|
||||
/** Cleaned display text — team name for team sessions, cleaned prompt for regular */
|
||||
displayText: string;
|
||||
teamName?: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
// Matches: agent_teams_ui [Agent Team: "name" | Project: "proj" | Lead: "lead"]
|
||||
// Handles both straight quotes ("") and smart quotes (\u201C\u201D)
|
||||
const PROVISION_RE =
|
||||
/^agent_teams_ui\s+\[Agent Team:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Project:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Lead:\s*["\u201C]([^"\u201D]+)["\u201D]\]/;
|
||||
|
||||
// Matches: Team Start [Agent Team: ...] or Team Start (resume) [Agent Team: ...]
|
||||
const LAUNCH_RE =
|
||||
/^Team Start(?:\s*\(resume\))?\s+\[Agent Team:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Project:\s*["\u201C]([^"\u201D]+)["\u201D]\s*\|\s*Lead:\s*["\u201C]([^"\u201D]+)["\u201D]\]/;
|
||||
|
||||
// Matches one or more [Image #N] prefixes
|
||||
const IMAGE_PREFIX_RE = /^(?:\[Image\s+#\d+\]\s*)+/;
|
||||
|
||||
export function parseSessionTitle(firstMessage: string | undefined): ParsedSessionTitle {
|
||||
if (!firstMessage) {
|
||||
return { kind: 'regular', displayText: 'Untitled' };
|
||||
}
|
||||
|
||||
// New team provisioning: agent_teams_ui [Agent Team: ...]
|
||||
const provisionMatch = firstMessage.match(PROVISION_RE);
|
||||
if (provisionMatch) {
|
||||
return {
|
||||
kind: 'team-new',
|
||||
displayText: provisionMatch[1],
|
||||
teamName: provisionMatch[1],
|
||||
projectName: provisionMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Team resume/launch: Team Start [Agent Team: ...]
|
||||
const launchMatch = firstMessage.match(LAUNCH_RE);
|
||||
if (launchMatch) {
|
||||
return {
|
||||
kind: 'team-resume',
|
||||
displayText: launchMatch[1],
|
||||
teamName: launchMatch[1],
|
||||
projectName: launchMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Regular session — strip [Image #N] prefixes
|
||||
const cleaned = firstMessage.replace(IMAGE_PREFIX_RE, '').trim();
|
||||
return {
|
||||
kind: 'regular',
|
||||
displayText: cleaned || 'Untitled',
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: returns just the display label string. */
|
||||
export function formatSessionLabel(firstMessage: string | undefined): string {
|
||||
return parseSessionTitle(firstMessage).displayText;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasSelectedTargetTeamData,
|
||||
shouldKeepGlobalTaskDialogLoading,
|
||||
} from '../../../../../src/renderer/components/team/dialogs/globalTaskDetailDialogLoading';
|
||||
|
||||
describe('shouldKeepGlobalTaskDialogLoading', () => {
|
||||
it('treats stale selectedTeamData from another team as not loaded', () => {
|
||||
expect(hasSelectedTargetTeamData('alpha', 'alpha', 'beta')).toBe(false);
|
||||
expect(hasSelectedTargetTeamData('alpha', 'alpha', 'alpha')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps loading while team switch has not reached the target team yet', () => {
|
||||
expect(
|
||||
shouldKeepGlobalTaskDialogLoading({
|
||||
teamName: 'alpha',
|
||||
taskId: 'task-1',
|
||||
selectedTeamName: 'beta',
|
||||
selectedTeamDataPresent: false,
|
||||
selectedTeamLoading: false,
|
||||
selectedTeamError: null,
|
||||
hasTaskInMap: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps loading when team data is not ready yet and the task is still absent', () => {
|
||||
expect(
|
||||
shouldKeepGlobalTaskDialogLoading({
|
||||
teamName: 'alpha',
|
||||
taskId: 'task-1',
|
||||
selectedTeamName: 'alpha',
|
||||
selectedTeamDataPresent: false,
|
||||
selectedTeamLoading: false,
|
||||
selectedTeamError: null,
|
||||
hasTaskInMap: false,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('stops loading once a fallback task snapshot is already available', () => {
|
||||
expect(
|
||||
shouldKeepGlobalTaskDialogLoading({
|
||||
teamName: 'alpha',
|
||||
taskId: 'task-1',
|
||||
selectedTeamName: 'alpha',
|
||||
selectedTeamDataPresent: false,
|
||||
selectedTeamLoading: false,
|
||||
selectedTeamError: null,
|
||||
hasTaskInMap: true,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('stops loading after a real load error', () => {
|
||||
expect(
|
||||
shouldKeepGlobalTaskDialogLoading({
|
||||
teamName: 'alpha',
|
||||
taskId: 'task-1',
|
||||
selectedTeamName: 'alpha',
|
||||
selectedTeamDataPresent: false,
|
||||
selectedTeamLoading: false,
|
||||
selectedTeamError: 'boom',
|
||||
hasTaskInMap: false,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
77
test/renderer/hooks/useViewportCommentRead.test.ts
Normal file
77
test/renderer/hooks/useViewportCommentRead.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getVisibleCommentIdsFallback } from '../../../src/renderer/hooks/useViewportCommentRead';
|
||||
|
||||
function makeRect({
|
||||
top,
|
||||
bottom,
|
||||
left = 0,
|
||||
right = 100,
|
||||
}: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
describe('getVisibleCommentIdsFallback', () => {
|
||||
it('returns comment IDs that are visibly inside the scroll container', () => {
|
||||
const container = document.createElement('div');
|
||||
const visible = document.createElement('div');
|
||||
const hidden = document.createElement('div');
|
||||
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue(
|
||||
makeRect({ top: 100, bottom: 300 })
|
||||
);
|
||||
vi.spyOn(visible, 'getBoundingClientRect').mockReturnValue(makeRect({ top: 120, bottom: 180 }));
|
||||
vi.spyOn(hidden, 'getBoundingClientRect').mockReturnValue(makeRect({ top: 320, bottom: 380 }));
|
||||
|
||||
const result = getVisibleCommentIdsFallback(
|
||||
container,
|
||||
new Map([
|
||||
['visible-comment', visible],
|
||||
['hidden-comment', hidden],
|
||||
])
|
||||
);
|
||||
|
||||
expect(result).toEqual(['visible-comment']);
|
||||
});
|
||||
|
||||
it('requires at least 10% of the comment height to be visible', () => {
|
||||
const container = document.createElement('div');
|
||||
const barelyVisible = document.createElement('div');
|
||||
const enoughVisible = document.createElement('div');
|
||||
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue(
|
||||
makeRect({ top: 100, bottom: 300 })
|
||||
);
|
||||
vi.spyOn(barelyVisible, 'getBoundingClientRect').mockReturnValue(
|
||||
makeRect({ top: 295, bottom: 405 })
|
||||
);
|
||||
vi.spyOn(enoughVisible, 'getBoundingClientRect').mockReturnValue(
|
||||
makeRect({ top: 290, bottom: 390 })
|
||||
);
|
||||
|
||||
const result = getVisibleCommentIdsFallback(
|
||||
container,
|
||||
new Map([
|
||||
['barely-visible', barelyVisible],
|
||||
['enough-visible', enoughVisible],
|
||||
])
|
||||
);
|
||||
|
||||
expect(result).toEqual(['enough-visible']);
|
||||
});
|
||||
});
|
||||
124
test/renderer/utils/sessionTitleParser.test.ts
Normal file
124
test/renderer/utils/sessionTitleParser.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser';
|
||||
|
||||
describe('parseSessionTitle', () => {
|
||||
it('returns regular/Untitled for undefined', () => {
|
||||
expect(parseSessionTitle(undefined)).toEqual({
|
||||
kind: 'regular',
|
||||
displayText: 'Untitled',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns regular/Untitled for empty string', () => {
|
||||
expect(parseSessionTitle('')).toEqual({
|
||||
kind: 'regular',
|
||||
displayText: 'Untitled',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses new team provisioning with straight quotes', () => {
|
||||
const msg =
|
||||
'agent_teams_ui [Agent Team: "summit-ops" | Project: "sol_team_proj" | Lead: "team-lead"] — team does NOT exist yet.';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'team-new',
|
||||
displayText: 'summit-ops',
|
||||
teamName: 'summit-ops',
|
||||
projectName: 'sol_team_proj',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses new team provisioning with smart quotes', () => {
|
||||
const msg =
|
||||
'agent_teams_ui [Agent Team: \u201Csummit-ops\u201D | Project: \u201Csol_team_proj\u201D | Lead: \u201Cteam-lead\u201D] \u2014 team does NOT exist yet.';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'team-new',
|
||||
displayText: 'summit-ops',
|
||||
teamName: 'summit-ops',
|
||||
projectName: 'sol_team_proj',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Team Start as resume', () => {
|
||||
const msg =
|
||||
'Team Start [Agent Team: "atlas-hq-2" | Project: "sol_team_proj" | Lead: "team-lead"] You are running in a non-interactive CLI session.';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'team-resume',
|
||||
displayText: 'atlas-hq-2',
|
||||
teamName: 'atlas-hq-2',
|
||||
projectName: 'sol_team_proj',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses Team Start (resume) as resume', () => {
|
||||
const msg =
|
||||
'Team Start (resume) [Agent Team: "atlas-hq-2" | Project: "sol_team_proj" | Lead: "team-lead"] You are running...';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'team-resume',
|
||||
displayText: 'atlas-hq-2',
|
||||
teamName: 'atlas-hq-2',
|
||||
projectName: 'sol_team_proj',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through regular text as-is', () => {
|
||||
const msg = 'Fix the login bug in auth module';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'regular',
|
||||
displayText: 'Fix the login bug in auth module',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips single [Image #N] prefix', () => {
|
||||
const msg = '[Image #1] Сделай чтобы было без иконки';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'regular',
|
||||
displayText: 'Сделай чтобы было без иконки',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips multiple [Image #N] prefixes', () => {
|
||||
const msg = '[Image #1] [Image #2] Something with two images';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'regular',
|
||||
displayText: 'Something with two images',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Untitled when only image prefixes remain', () => {
|
||||
const msg = '[Image #1] ';
|
||||
const result = parseSessionTitle(msg);
|
||||
expect(result).toEqual({
|
||||
kind: 'regular',
|
||||
displayText: 'Untitled',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSessionLabel', () => {
|
||||
it('returns team name for new team session', () => {
|
||||
const msg =
|
||||
'agent_teams_ui [Agent Team: "my-team" | Project: "my-proj" | Lead: "lead"] — team does NOT exist yet.';
|
||||
expect(formatSessionLabel(msg)).toBe('my-team');
|
||||
});
|
||||
|
||||
it('returns team name for resume session', () => {
|
||||
const msg = 'Team Start [Agent Team: "my-team" | Project: "proj" | Lead: "lead"] ...';
|
||||
expect(formatSessionLabel(msg)).toBe('my-team');
|
||||
});
|
||||
|
||||
it('returns cleaned text for regular session', () => {
|
||||
expect(formatSessionLabel('Hello world')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns Untitled for undefined', () => {
|
||||
expect(formatSessionLabel(undefined)).toBe('Untitled');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue