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 { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser';
|
||||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { OngoingIndicator } from '../common/OngoingIndicator';
|
import { OngoingIndicator } from '../common/OngoingIndicator';
|
||||||
|
|
@ -178,7 +179,7 @@ export const SessionItem = ({
|
||||||
type: 'session',
|
type: 'session',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
projectId: activeProjectId,
|
projectId: activeProjectId,
|
||||||
label: session.firstMessage?.slice(0, 50) ?? 'Session',
|
label: formatSessionLabel(session.firstMessage),
|
||||||
},
|
},
|
||||||
forceNewTab ? { forceNewTab } : { replaceActiveTab: true }
|
forceNewTab ? { forceNewTab } : { replaceActiveTab: true }
|
||||||
);
|
);
|
||||||
|
|
@ -191,7 +192,7 @@ export const SessionItem = ({
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sessionLabel = session.firstMessage?.slice(0, 50) ?? 'Session';
|
const sessionLabel = formatSessionLabel(session.firstMessage);
|
||||||
|
|
||||||
const handleOpenInCurrentPane = useCallback(() => {
|
const handleOpenInCurrentPane = useCallback(() => {
|
||||||
if (!activeProjectId) return;
|
if (!activeProjectId) return;
|
||||||
|
|
@ -253,49 +254,86 @@ export const SessionItem = ({
|
||||||
...(isHidden ? { opacity: 0.5 } : {}),
|
...(isHidden ? { opacity: 0.5 } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* First line: title + ongoing indicator + pin/hidden icons */}
|
{(() => {
|
||||||
<div className="flex items-center gap-1.5">
|
const parsed = parseSessionTitle(session.firstMessage);
|
||||||
{multiSelectActive && (
|
const isTeam = parsed.kind !== 'regular';
|
||||||
<input
|
return (
|
||||||
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 && (
|
|
||||||
<>
|
<>
|
||||||
<span style={{ opacity: 0.5 }}>·</span>
|
{/* First line: title + ongoing indicator + pin/hidden icons */}
|
||||||
<ConsumptionBadge
|
<div className="flex items-center gap-1.5">
|
||||||
contextConsumption={session.contextConsumption}
|
{multiSelectActive && (
|
||||||
phaseBreakdown={session.phaseBreakdown}
|
<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>
|
</button>
|
||||||
|
|
||||||
{contextMenu &&
|
{contextMenu &&
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
|
import { formatSessionLabel } from '@renderer/utils/sessionTitleParser';
|
||||||
import { formatDistanceToNowStrict } from 'date-fns';
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -69,7 +70,7 @@ export const TeamSessionsSection = ({
|
||||||
type: 'session',
|
type: 'session',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
projectId,
|
projectId,
|
||||||
label: session.firstMessage?.slice(0, 50) ?? 'Session',
|
label: formatSessionLabel(session.firstMessage),
|
||||||
},
|
},
|
||||||
{ forceNewTab: true }
|
{ forceNewTab: true }
|
||||||
);
|
);
|
||||||
|
|
@ -173,7 +174,7 @@ const SessionRow = ({
|
||||||
onToggleFilter,
|
onToggleFilter,
|
||||||
}: SessionRowProps): React.JSX.Element => {
|
}: SessionRowProps): React.JSX.Element => {
|
||||||
const timeAgo = formatShortTime(new Date(session.createdAt));
|
const timeAgo = formatShortTime(new Date(session.createdAt));
|
||||||
const label = session.firstMessage ?? 'Untitled session';
|
const label = formatSessionLabel(session.firstMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import {
|
||||||
|
hasSelectedTargetTeamData,
|
||||||
|
shouldKeepGlobalTaskDialogLoading,
|
||||||
|
} from './globalTaskDetailDialogLoading';
|
||||||
import { TaskDetailDialog } from './TaskDetailDialog';
|
import { TaskDetailDialog } from './TaskDetailDialog';
|
||||||
|
|
||||||
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
|
import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -21,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
selectedTeamName,
|
selectedTeamName,
|
||||||
selectedTeamData,
|
selectedTeamData,
|
||||||
selectedTeamLoading,
|
selectedTeamLoading,
|
||||||
|
selectedTeamError,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
openTeamTab,
|
openTeamTab,
|
||||||
setPendingReviewRequest,
|
setPendingReviewRequest,
|
||||||
|
|
@ -32,6 +37,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
selectedTeamName: s.selectedTeamName,
|
selectedTeamName: s.selectedTeamName,
|
||||||
selectedTeamData: s.selectedTeamData,
|
selectedTeamData: s.selectedTeamData,
|
||||||
selectedTeamLoading: s.selectedTeamLoading,
|
selectedTeamLoading: s.selectedTeamLoading,
|
||||||
|
selectedTeamError: s.selectedTeamError,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
openTeamTab: s.openTeamTab,
|
openTeamTab: s.openTeamTab,
|
||||||
setPendingReviewRequest: s.setPendingReviewRequest,
|
setPendingReviewRequest: s.setPendingReviewRequest,
|
||||||
|
|
@ -41,6 +47,11 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const teamName = globalTaskDetail?.teamName ?? '';
|
const teamName = globalTaskDetail?.teamName ?? '';
|
||||||
const taskId = globalTaskDetail?.taskId ?? '';
|
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).
|
// Load full team data in the background to enable "as before" details (logs/changes/members).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -65,13 +76,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
teamName,
|
teamName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData;
|
const isFullTeamLoaded = hasTargetTeamData;
|
||||||
// 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 taskMap = useMemo(() => {
|
const taskMap = useMemo(() => {
|
||||||
const map = new Map<string, TeamTaskWithKanban>();
|
const map = new Map<string, TeamTaskWithKanban>();
|
||||||
|
|
@ -119,12 +124,21 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
const kanbanTaskState = isFullTeamLoaded
|
const kanbanTaskState = isFullTeamLoaded
|
||||||
? selectedTeamData?.kanbanState.tasks[taskId]
|
? selectedTeamData?.kanbanState.tasks[taskId]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const loading = shouldKeepGlobalTaskDialogLoading({
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
selectedTeamName,
|
||||||
|
selectedTeamDataPresent: hasTargetTeamData,
|
||||||
|
selectedTeamLoading,
|
||||||
|
selectedTeamError,
|
||||||
|
hasTaskInMap: taskMap.has(taskId),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TaskDetailDialog
|
<TaskDetailDialog
|
||||||
open
|
open
|
||||||
variant={isFullTeamLoaded ? 'team' : 'global'}
|
variant={isFullTeamLoaded ? 'team' : 'global'}
|
||||||
loading={!isFullTeamLoaded && isThisTeamLoading}
|
loading={!isFullTeamLoaded && loading}
|
||||||
task={task}
|
task={task}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
kanbanTaskState={kanbanTaskState}
|
kanbanTaskState={kanbanTaskState}
|
||||||
|
|
|
||||||
|
|
@ -627,7 +627,7 @@ export const TaskDetailDialog = ({
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (!v && lightboxOpenRef.current) return;
|
if (!v && lightboxOpenRef.current) return;
|
||||||
if (!v) onClose();
|
if (!v) handleClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<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 { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { displayMemberName } from '@renderer/utils/memberHelpers';
|
import { displayMemberName } from '@renderer/utils/memberHelpers';
|
||||||
import { Crown, Filter } from 'lucide-react';
|
import { Crown, Filter } from 'lucide-react';
|
||||||
|
|
@ -122,7 +123,7 @@ export const KanbanFilterPopover = ({
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isLead = session.id === leadSessionId;
|
const isLead = session.id === leadSessionId;
|
||||||
const isSelected = filter.sessionId === session.id;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,33 @@ interface UseViewportCommentReadOptions {
|
||||||
scrollContainer: HTMLElement | null;
|
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.
|
* Marks task comments as read based on viewport visibility.
|
||||||
*
|
*
|
||||||
|
|
@ -45,6 +72,7 @@ export function useViewportCommentRead({
|
||||||
flush: () => void;
|
flush: () => void;
|
||||||
} {
|
} {
|
||||||
const seenIdsRef = useRef<Set<string>>(new Set());
|
const seenIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const commentElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||||
const teamNameRef = useRef(teamName);
|
const teamNameRef = useRef(teamName);
|
||||||
const taskIdRef = useRef(taskId);
|
const taskIdRef = useRef(taskId);
|
||||||
|
|
||||||
|
|
@ -56,6 +84,7 @@ export function useViewportCommentRead({
|
||||||
// Reset tracked state when team/task changes
|
// Reset tracked state when team/task changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
seenIdsRef.current = new Set();
|
seenIdsRef.current = new Set();
|
||||||
|
commentElementsRef.current.clear();
|
||||||
}, [teamName, taskId]);
|
}, [teamName, taskId]);
|
||||||
|
|
||||||
const persistSeen = useCallback(() => {
|
const persistSeen = useCallback(() => {
|
||||||
|
|
@ -82,18 +111,37 @@ export function useViewportCommentRead({
|
||||||
|
|
||||||
const { registerElement } = useViewportObserver({
|
const { registerElement } = useViewportObserver({
|
||||||
root: scrollContainer,
|
root: scrollContainer,
|
||||||
threshold: 0.1,
|
threshold: VISIBILITY_THRESHOLD,
|
||||||
onVisibleChange: handleVisibleChange,
|
onVisibleChange: handleVisibleChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerComment = useCallback(
|
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]
|
[registerElement]
|
||||||
);
|
);
|
||||||
|
|
||||||
const flush = useCallback(() => {
|
const flush = useCallback(() => {
|
||||||
|
const fallbackVisibleIds = getVisibleCommentIdsFallback(
|
||||||
|
scrollContainer,
|
||||||
|
commentElementsRef.current
|
||||||
|
);
|
||||||
|
for (const commentId of fallbackVisibleIds) {
|
||||||
|
seenIdsRef.current.add(commentId);
|
||||||
|
}
|
||||||
persistSeen();
|
persistSeen();
|
||||||
}, [persistSeen]);
|
}, [persistSeen, scrollContainer]);
|
||||||
|
|
||||||
return { registerComment, flush };
|
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