diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index afcdc0bf..307f9181 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -309,7 +309,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele showNoise: false, }); const [messagesFilterOpen, setMessagesFilterOpen] = useState(false); - const [messagesCollapsed, setMessagesCollapsed] = useState(false); + const [messagesCollapsed, setMessagesCollapsed] = useState(true); // Open editor overlay when a file reveal is requested (e.g. from chip click) const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 0cd1d3dc..2d935969 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; @@ -26,8 +26,10 @@ import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; +import { isManagedCollapseState } from './collapseState'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; +import type { ActivityCollapseState } from './collapseState'; import type { TeamColorSet } from '@renderer/constants/teamColors'; import type { InboxMessage } from '@shared/types'; @@ -52,10 +54,8 @@ interface ActivityItemProps { onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; - /** When true, collapse message body — show only header with expand chevron. */ - forceCollapsed?: boolean; - /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ - onCollapseToggle?: () => void; + /** Explicit collapse state for timeline-controlled collapsed mode. */ + collapseState?: ActivityCollapseState; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -217,8 +217,7 @@ export const ActivityItem = ({ onTaskIdClick, onRestartTeam, zebraShade, - forceCollapsed, - onCollapseToggle, + collapseState, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const formattedRole = formatAgentRole(memberRole); @@ -237,19 +236,9 @@ export const ActivityItem = ({ // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; - // System/automated messages start collapsed (but not rate limits) const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null; - const [isExpanded, setIsExpanded] = useState(!systemLabel && !forceCollapsed); - - // Sync expand/collapse when the global collapse mode toggles (skip initial mount) - const isFirstRender = useRef(true); - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false; - return; - } - setIsExpanded(forceCollapsed ? false : !systemLabel); - }, [forceCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps -- systemLabel is stable (derived from message.text) + const isManaged = isManagedCollapseState(collapseState); + const isExpanded = isManaged ? !collapseState.isCollapsed : true; // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -298,11 +287,16 @@ export const ActivityItem = ({ onCreateTask?.(subject, description); }; - const isHeaderClickable = - Boolean(systemLabel) || forceCollapsed === true || onCollapseToggle != null; + const isHeaderClickable = isManaged ? collapseState.canToggle : false; const showChevron = isHeaderClickable; const isUserSent = message.source === 'user_sent'; const isSystemMessage = message.from === 'system'; + const onManagedToggle = isManaged ? collapseState.onToggle : undefined; + const handleHeaderToggle = isHeaderClickable + ? (): void => { + onManagedToggle?.(); + } + : undefined; return (
{ - setIsExpanded((v) => !v); - onCollapseToggle?.(); - } - : undefined - } + onClick={handleHeaderToggle} onKeyDown={ isHeaderClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setIsExpanded((v) => !v); - onCollapseToggle?.(); + handleHeaderToggle?.(); } } : undefined diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index f65c9248..bc8eeff3 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -4,9 +4,11 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { ActivityItem, isNoiseMessage } from './ActivityItem'; +import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState'; import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup'; import type { TimelineItem } from './LeadThoughtsGroup'; +import type { ActivityCollapseState } from './collapseState'; import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; interface ActivityTimelineProps { @@ -54,8 +56,7 @@ const MessageRowWithObserver = ({ onVisible, onTaskIdClick, onRestartTeam, - forceCollapsed, - onCollapseToggle, + collapseState, }: { message: InboxMessage; teamName: string; @@ -72,8 +73,7 @@ const MessageRowWithObserver = ({ onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; - forceCollapsed?: boolean; - onCollapseToggle?: () => void; + collapseState?: ActivityCollapseState; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -121,8 +121,7 @@ const MessageRowWithObserver = ({ onReply={onReply} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - forceCollapsed={forceCollapsed} - onCollapseToggle={onCollapseToggle} + collapseState={collapseState} /> ); @@ -308,46 +307,27 @@ export const ActivityTimeline = ({ const startIndex = pinnedThoughtGroup ? 1 : 0; // Determine the index of the "newest" non-thought timeline item (for auto-expand). - // Pinned thought group is always at index 0 when present, so newest message is the - // first non-thought item in the remaining list. const newestMessageIndex = useMemo(() => { - for (let i = startIndex; i < timelineItems.length; i++) { - if (timelineItems[i].type !== 'lead-thoughts') return i; - } - return -1; - }, [timelineItems, startIndex]); + return findNewestMessageIndex(timelineItems); + }, [timelineItems]); /** - * Compute per-item forceCollapsed + onCollapseToggle based on: - * - allCollapsed mode enabled/disabled - * - Whether this is the newest message (auto-expanded, no chevron) - * - Whether user has manually expanded this item (override in localStorage) - * - * | allCollapsed | isNewest | inOverrides | forceCollapsed | onCollapseToggle | - * |-------------|----------|-------------|----------------|------------------| - * | false | any | any | undefined | undefined | - * | true | yes | any | undefined | undefined | - * | true | no | yes | false | fn | - * | true | no | no | true | fn | + * Compute the externally managed collapse state for an item in the timeline. + * In collapsed mode we always keep the newest real message open, keep the pinned + * thought group open, and let localStorage overrides reopen older items. */ - const getItemCollapseProps = useCallback( - ( - stableKey: string, - itemIndex: number - ): { forceCollapsed?: boolean; onCollapseToggle?: () => void } => { - if (!allCollapsed) return {}; - if (itemIndex === newestMessageIndex) return {}; - // Pinned thought group (index 0) is always the newest thought → expanded - if (itemIndex === 0 && pinnedThoughtGroup) return {}; - - const isOverridden = expandOverrides?.has(stableKey) ?? false; - return { - forceCollapsed: !isOverridden, - onCollapseToggle: onToggleExpandOverride + const getItemCollapseState = useCallback( + (stableKey: string, itemIndex: number): ActivityCollapseState => + resolveTimelineCollapseState({ + allCollapsed, + itemIndex, + newestMessageIndex, + isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null, + isExpandedOverride: expandOverrides?.has(stableKey) ?? false, + onToggleOverride: onToggleExpandOverride ? () => onToggleExpandOverride(stableKey) : undefined, - }; - }, + }), [allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride] ); @@ -361,7 +341,7 @@ export const ActivityTimeline = ({ const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`; const stableKey = toMessageKey(firstThought); - const collapseProps = getItemCollapseProps(stableKey, 0); + const collapseState = getItemCollapseState(stableKey, 0); return ( ); })()} @@ -406,7 +385,7 @@ export const ActivityTimeline = ({ const info = memberInfo.get(firstThought.from); const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`; const stableKey = toMessageKey(firstThought); - const collapseProps = getItemCollapseProps(stableKey, realIndex); + const collapseState = getItemCollapseState(stableKey, realIndex); return ( {sessionSeparator} @@ -417,8 +396,7 @@ export const ActivityTimeline = ({ isNew={newItemKeys.has(itemKey)} onVisible={onMessageVisible} zebraShade={zebraShadeSet.has(realIndex)} - forceCollapsed={collapseProps.forceCollapsed} - onCollapseToggle={collapseProps.onCollapseToggle} + collapseState={collapseState} /> ); @@ -431,7 +409,7 @@ export const ActivityTimeline = ({ recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined); const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`; const stableKey = toMessageKey(message); - const collapseProps = getItemCollapseProps(stableKey, realIndex); + const collapseState = getItemCollapseState(stableKey, realIndex); const isUnread = readState ? !message.read && !readState.readSet.has(readState.getMessageKey(message)) : !message.read; @@ -454,8 +432,7 @@ export const ActivityTimeline = ({ onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} - forceCollapsed={collapseProps.forceCollapsed} - onCollapseToggle={collapseProps.onCollapseToggle} + collapseState={collapseState} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 83690d93..e8c964dd 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -16,6 +16,9 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; +import { isManagedCollapseState } from './collapseState'; + +import type { ActivityCollapseState } from './collapseState'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { @@ -45,6 +48,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { const result: TimelineItem[] = []; let pendingThoughts: InboxMessage[] = []; let pendingIndices: number[] = []; + const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean => + (a.leadSessionId ?? null) === (b.leadSessionId ?? null); const flushThoughts = (): void => { if (pendingThoughts.length === 0) return; @@ -60,6 +65,10 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (isLeadThought(msg)) { + const previousThought = pendingThoughts[pendingThoughts.length - 1]; + if (previousThought && !hasSameLeadSession(previousThought, msg)) { + flushThoughts(); + } pendingThoughts.push(msg); pendingIndices.push(i); } else { @@ -86,10 +95,8 @@ interface LeadThoughtsGroupRowProps { canBeLive?: boolean; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; - /** When true, collapse the thought body — show only the header with expand chevron. */ - forceCollapsed?: boolean; - /** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */ - onCollapseToggle?: () => void; + /** Explicit collapse state for timeline-controlled collapsed mode. */ + collapseState?: ActivityCollapseState; } function formatTime(timestamp: string): string { @@ -347,13 +354,14 @@ export const LeadThoughtsGroupRow = ({ onVisible, canBeLive, zebraShade, - forceCollapsed, - onCollapseToggle, + collapseState, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); const contentRef = useRef(null); const isUserScrolledUpRef = useRef(false); + const distanceFromBottomRef = useRef(0); + const scrollSyncFrameRef = useRef(null); const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const leadActivity = useStore((s) => { const teamName = s.selectedTeamName; @@ -412,17 +420,14 @@ export const LeadThoughtsGroupRow = ({ const [isLive, setIsLive] = useState(computeIsLive); const [expanded, setExpanded] = useState(false); const [needsTruncation, setNeedsTruncation] = useState(false); - const [isBodyVisible, setIsBodyVisible] = useState(!forceCollapsed); - - // Sync body visibility when the global collapse mode toggles (skip initial mount) - const isFirstRenderRef = useRef(false); - useEffect(() => { - if (!isFirstRenderRef.current) { - isFirstRenderRef.current = true; - return; - } - setIsBodyVisible(!forceCollapsed); - }, [forceCollapsed]); + const isManaged = isManagedCollapseState(collapseState); + const isBodyVisible = isManaged ? !collapseState.isCollapsed : true; + const canToggleBodyVisibility = isManaged && collapseState.canToggle; + const handleBodyToggle = canToggleBodyVisibility + ? (): void => { + collapseState.onToggle?.(); + } + : undefined; useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap @@ -454,6 +459,41 @@ export const LeadThoughtsGroupRow = ({ return () => observer.disconnect(); }, [onVisible, thoughts]); + const clearPendingScrollSync = useCallback(() => { + if (scrollSyncFrameRef.current !== null) { + cancelAnimationFrame(scrollSyncFrameRef.current); + scrollSyncFrameRef.current = null; + } + }, []); + + const queueScrollSync = useCallback( + (mode: 'bottom' | 'preserve') => { + clearPendingScrollSync(); + scrollSyncFrameRef.current = requestAnimationFrame(() => { + scrollSyncFrameRef.current = requestAnimationFrame(() => { + const scrollEl = scrollRef.current; + if (!scrollEl || expanded || !isBodyVisible) { + scrollSyncFrameRef.current = null; + return; + } + + const nextScrollTop = + mode === 'bottom' + ? scrollEl.scrollHeight - scrollEl.clientHeight + : scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current; + + scrollEl.scrollTop = Math.max(0, nextScrollTop); + if (mode === 'bottom') { + distanceFromBottomRef.current = 0; + isUserScrolledUpRef.current = false; + } + scrollSyncFrameRef.current = null; + }); + }); + }, + [clearPendingScrollSync, expanded, isBodyVisible] + ); + const syncScrollableBody = useCallback( (forceScrollToBottom = false) => { const scrollEl = scrollRef.current; @@ -463,14 +503,26 @@ export const LeadThoughtsGroupRow = ({ const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1; setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation)); - if (expanded) return; - if (!forceScrollToBottom && isUserScrolledUpRef.current) return; - scrollEl.scrollTop = scrollEl.scrollHeight; + if (expanded || !isBodyVisible) return; + if (!nextNeedsTruncation) { + clearPendingScrollSync(); + distanceFromBottomRef.current = 0; + isUserScrolledUpRef.current = false; + return; + } + + if (forceScrollToBottom || !isUserScrolledUpRef.current) { + queueScrollSync('bottom'); + return; + } + + queueScrollSync('preserve'); }, - [expanded] + [clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync] ); useEffect(() => { + if (!isBodyVisible) return; const contentEl = contentRef.current; if (!contentEl) return; @@ -482,18 +534,32 @@ export const LeadThoughtsGroupRow = ({ observer.observe(contentEl); return () => observer.disconnect(); - }, [syncScrollableBody]); + }, [isBodyVisible, syncScrollableBody]); + + useEffect( + () => () => { + clearPendingScrollSync(); + }, + [clearPendingScrollSync] + ); + + useEffect(() => { + if (isBodyVisible) return; + clearPendingScrollSync(); + }, [clearPendingScrollSync, isBodyVisible]); const handleScroll = useCallback(() => { if (expanded) return; const el = scrollRef.current; if (!el) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight); + distanceFromBottomRef.current = distanceFromBottom; isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD; }, [expanded]); const handleCollapse = useCallback(() => { isUserScrolledUpRef.current = false; + distanceFromBottomRef.current = 0; setExpanded(false); requestAnimationFrame(() => { @@ -524,34 +590,26 @@ export const LeadThoughtsGroupRow = ({ {/* Header */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
{ - setIsBodyVisible((v) => !v); - onCollapseToggle?.(); - } - : undefined - } + onClick={handleBodyToggle} onKeyDown={ - forceCollapsed === true || onCollapseToggle != null + canToggleBodyVisibility ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setIsBodyVisible((v) => !v); - onCollapseToggle?.(); + handleBodyToggle?.(); } } : undefined } > {/* Chevron for collapse mode */} - {forceCollapsed === true || onCollapseToggle != null ? ( + {canToggleBodyVisibility ? ( @@ -626,10 +683,13 @@ export const LeadThoughtsGroupRow = ({ ) : null}
{isBodyVisible && !expanded && needsTruncation ? ( -
+
- - {canCreate && (prepareState === 'idle' || prepareState === 'loading') ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {canCreate && prepareState === 'ready' ? ( -
-
- - - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} - -
- {prepareMessage ? ( -

{prepareMessage}

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} -
- ) : null} -
- ) : null}
) : null} @@ -934,36 +897,79 @@ export const CreateTeamDialog = ({

) : null} - - {canOpenExistingTeam ? ( - + ) : null} + - ) : null} - - + + diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx index e72c4f2b..6ffe6773 100644 --- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; +import { Brain } from 'lucide-react'; const EFFORT_OPTIONS = [ { value: '', label: 'Default' }, @@ -25,23 +26,26 @@ export const EffortLevelSelector: React.FC = ({ -
- {EFFORT_OPTIONS.map((opt) => ( - - ))} +
+ +
+ {EFFORT_OPTIONS.map((opt) => ( + + ))} +

Controls how much reasoning Claude invests before responding. Default uses Claude's diff --git a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx index 7a93ac04..839fad49 100644 --- a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx +++ b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx @@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC = ( disabled = false, }) => ( <> -

+
) : null} - {prepareState === 'idle' || prepareState === 'loading' ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {prepareState === 'ready' ? ( -
-
- - - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} - -
- {prepareMessage ? ( -

{prepareMessage}

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} + +
+ {prepareState === 'idle' || prepareState === 'loading' ? ( +
+ + + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} +
) : null} -
- ) : null} - - - + {prepareState === 'ready' ? ( +
+
+ + + {prepareWarnings.length > 0 + ? 'CLI environment ready (with notes)' + : 'CLI environment ready'} + +
+ {prepareMessage ? ( +

+ {prepareMessage} +

+ ) : null} + {prepareWarnings.length > 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+ ) : null} + + {prepareState === 'failed' ?
: null} +
+ +
+ + +
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 62759c8d..60a49eb9 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -6,7 +6,7 @@ import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; -import { Check } from 'lucide-react'; +import { Check, FolderOpen } from 'lucide-react'; import type { Project } from '@shared/types'; @@ -102,34 +102,40 @@ export const ProjectPathSelector = ({ {cwdMode === 'project' ? (
- ({ - value: project.path, - label: project.name, - description: project.path, - }))} - value={selectedProjectPath} - onValueChange={onSelectedProjectPathChange} - placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} - searchPlaceholder="Search project by name or path" - emptyMessage="Nothing found" - disabled={projectsLoading || projects.length === 0} - renderOption={(option, isSelected, query) => ( - <> - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

-
- - )} - /> +
+ + ({ + value: project.path, + label: project.name, + description: project.path, + }))} + value={selectedProjectPath} + onValueChange={onSelectedProjectPathChange} + placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} + searchPlaceholder="Search project by name or path" + emptyMessage="Nothing found" + disabled={projectsLoading || projects.length === 0} + renderOption={(option, isSelected, query) => ( + <> + +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
+ + )} + /> +
{!selectedProjectPath ? (

Select a project from the list @@ -137,12 +143,15 @@ export const ProjectPathSelector = ({ ) : null} {projectsError ?

{projectsError}

: null} {!projectsLoading && projects.length === 0 ? ( -

No projects found, switch to custom path.

+

+ No projects found, switch to custom path. +

) : null}
) : (
-
+
+ = (

- Autonomous mode — all tools execute without confirmation. Be cautious with untrusted - code. + Unleash Claude's full power — no interruptions asking for permission. Autonomous + mode — all tools execute without confirmation. Be cautious with untrusted code.

diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 27244c0f..568aa085 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { Badge } from '@renderer/components/ui/badge'; @@ -10,7 +11,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; @@ -35,7 +35,6 @@ import { Check, Clock, Eye, - FileCode, FileDiff, GitCompareArrows, HelpCircle, @@ -589,7 +588,10 @@ export const TaskDetailDialog = ({ key={file.filePath} className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]" > - + - ); diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 3701a851..fed6403f 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -46,6 +46,8 @@ interface MemberLogsTabProps { onPreviewOnlineChange?: (isOnline: boolean) => void; } +const PREVIEW_PAGE_SIZE = 4; + export const MemberLogsTab = ({ teamName, memberName, @@ -78,6 +80,7 @@ export const MemberLogsTab = ({ const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); + const [previewVisibleCount, setPreviewVisibleCount] = useState(PREVIEW_PAGE_SIZE); useEffect(() => { return () => { @@ -189,11 +192,17 @@ export const MemberLogsTab = ({ return null; }, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]); - const previewMessages = useMemo((): SubagentPreviewMessage[] => { + const allPreviewMessages = useMemo((): SubagentPreviewMessage[] => { if (!previewChunks || previewChunks.length === 0) return []; - return extractSubagentPreviewMessages(previewChunks, 4); + return extractSubagentPreviewMessages(previewChunks); }, [previewChunks]); + const previewMessages = useMemo((): SubagentPreviewMessage[] => { + return allPreviewMessages.slice(0, previewVisibleCount); + }, [allPreviewMessages, previewVisibleCount]); + + const previewHasMore = allPreviewMessages.length > previewVisibleCount; + const previewOnline = useMemo((): boolean => { const newest = previewMessages[0]; if (!newest) return false; @@ -214,6 +223,20 @@ export const MemberLogsTab = ({ onPreviewOnlineChange?.(previewOnline); }, [onPreviewOnlineChange, previewOnline]); + useEffect(() => { + setPreviewVisibleCount(PREVIEW_PAGE_SIZE); + }, [previewLog?.kind, previewLog?.sessionId]); + + useEffect(() => { + if (allPreviewMessages.length === 0) { + setPreviewVisibleCount(PREVIEW_PAGE_SIZE); + return; + } + setPreviewVisibleCount((prev) => + Math.max(PREVIEW_PAGE_SIZE, Math.min(prev, allPreviewMessages.length)) + ); + }, [allPreviewMessages.length]); + useEffect(() => { return () => onPreviewOnlineChange?.(false); }, [onPreviewOnlineChange]); @@ -493,6 +516,8 @@ export const MemberLogsTab = ({ setPreviewVisibleCount((prev) => prev + PREVIEW_PAGE_SIZE)} /> ) : null} {sortedLogs.map((log) => ( @@ -605,21 +630,18 @@ function formatRelativeTime(isoString: string): string { return date.toLocaleDateString(); } -function extractSubagentPreviewMessages( - chunks: EnhancedChunk[], - limit: number -): SubagentPreviewMessage[] { +function extractSubagentPreviewMessages(chunks: EnhancedChunk[]): SubagentPreviewMessage[] { const conversation = transformChunksToConversation(chunks, [], false); const out: SubagentPreviewMessage[] = []; - // Collect newest-first and stop as soon as we have enough. - for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) { + // Collect newest-first. + for (let i = conversation.items.length - 1; i >= 0; i--) { const item = conversation.items[i]; if (item.type === 'ai') { const enhanced = enhanceAIGroup(item.group); const items = enhanced.displayItems ?? []; - for (let j = items.length - 1; j >= 0 && out.length < limit; j--) { + for (let j = items.length - 1; j >= 0; j--) { const di = items[j]; if (di.type === 'output' && di.content.trim()) { out.push({ diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx index fba5b10a..a49f2d15 100644 --- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -1,4 +1,7 @@ +import { useState } from 'react'; + import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { format } from 'date-fns'; export type SubagentPreviewMessageKind = @@ -23,57 +26,85 @@ export interface SubagentPreviewMessage { interface SubagentRecentMessagesPreviewProps { messages: SubagentPreviewMessage[]; memberName?: string; + hasMore?: boolean; + onLoadMore?: () => void; } export const SubagentRecentMessagesPreview = ({ messages, memberName, + hasMore = false, + onLoadMore, }: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => { + const [expandedAll, setExpandedAll] = useState(false); + if (!messages.length) return null; return (
-
+
Latest messages{memberName ? ` — ${memberName}` : ''}
-
- {format(messages[0].timestamp, 'h:mm:ss a')} -
-
- {messages.map((m) => ( -
-
-
- {m.label ? ( - - {m.label} - - ) : ( - {m.kind} - )} +
+ {messages.map((m, index) => ( +
+
+
+
-
+
{format(m.timestamp, 'h:mm:ss a')}
- {m.kind === 'tool_result' ? ( -
-                {m.content}
-              
- ) : ( -
- -
- )} + {index < messages.length - 1 ? ( +
+ ) : null}
))} + + {hasMore && onLoadMore ? ( +
+ +
+ ) : null} +
+ +
+ {!expandedAll ? ( + + ) : ( + + )}
); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index ec12000b..a624b777 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -6,9 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { useAttachments } from '@renderer/hooks/useAttachments'; -import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; -import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; @@ -33,7 +31,7 @@ interface MessageComposerProps { ) => void; } -const MAX_MESSAGE_LENGTH = 4000; +const MAX_MESSAGE_LENGTH = 50_000; /** Circular progress indicator for lead context usage. */ const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { @@ -119,19 +117,7 @@ export const MessageComposer = ({ }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); - const draft = useDraftPersistence({ key: `compose:${teamName}` }); - const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); - const { - attachments, - error: attachmentError, - canAddMore, - addFiles, - removeAttachment, - clearAttachments, - clearError: clearAttachmentError, - handlePaste, - handleDrop, - } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); + const draft = useComposerDraft(teamName); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -146,7 +132,7 @@ export const MessageComposer = ({ [members, colorMap] ); - const trimmed = draft.value.trim(); + const trimmed = draft.text.trim(); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; @@ -157,8 +143,8 @@ export const MessageComposer = ({ // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // ); const supportsAttachments = isLeadRecipient; - const canAttach = supportsAttachments && canAddMore; - const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; + const canAttach = supportsAttachments && draft.canAddMore; + const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && trimmed.length > 0 && @@ -172,10 +158,15 @@ export const MessageComposer = ({ const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; - const serialized = serializeChipsWithText(trimmed, chipDraft.chips); + const serialized = serializeChipsWithText(trimmed, draft.chips); // Summary should stay compact (no expanded chip markdown) - onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined); - }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]); + onSend( + recipient, + serialized, + trimmed, + draft.attachments.length > 0 ? draft.attachments : undefined + ); + }, [canSend, recipient, trimmed, onSend, draft.attachments, draft.chips]); // Clear draft only after send completes successfully (sending: true → false, no error) useEffect(() => { @@ -183,12 +174,9 @@ export const MessageComposer = ({ pendingSendRef.current = false; if (!sendError) { draft.clearDraft(); - chipDraft.clearChipDraft(); - clearAttachments(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with []) - }, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]); + }, [sending, sendError, draft]); const handleKeyDownCapture = useCallback( (e: React.KeyboardEvent) => { @@ -205,11 +193,11 @@ export const MessageComposer = ({ (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { - void addFiles(input.files); + void draft.addFiles(input.files); } input.value = ''; }, - [addFiles] + [draft.addFiles] ); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -235,16 +223,16 @@ export const MessageComposer = ({ (e: React.DragEvent) => { dragCounterRef.current = 0; setIsDragOver(false); - if (canAttach) handleDrop(e); + if (canAttach) draft.handleDrop(e); }, - [canAttach, handleDrop] + [canAttach, draft.handleDrop] ); const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { - if (canAttach) handlePaste(e); + if (canAttach) draft.handlePaste(e); }, - [canAttach, handlePaste] + [canAttach, draft.handlePaste] ); const remaining = MAX_MESSAGE_LENGTH - trimmed.length; @@ -292,7 +280,7 @@ export const MessageComposer = ({ {!isTeamAlive ? 'Team must be online to attach images' - : !canAddMore + : !draft.canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'} @@ -408,10 +396,10 @@ export const MessageComposer = ({
@@ -419,13 +407,13 @@ export const MessageComposer = ({ `), no TTL. + * - Race-safe: late async load never overwrites fresh user input. + * - Debounced writes with immediate flush on unmount and lifecycle transitions. + * - Legacy migration from three-key format on first load. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + composerDraftStorage, + type ComposerDraftSnapshot, +} from '@renderer/services/composerDraftStorage'; +import { + fileToAttachmentPayload, + MAX_FILES, + MAX_TOTAL_SIZE, + validateAttachment, +} from '@renderer/utils/attachmentUtils'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { AttachmentPayload } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface UseComposerDraftResult { + // Text + text: string; + setText: (v: string) => void; + + // Chips + chips: InlineChip[]; + addChip: (chip: InlineChip) => void; + removeChip: (chipId: string) => void; + + // Attachments + attachments: AttachmentPayload[]; + attachmentError: string | null; + canAddMore: boolean; + addFiles: (files: FileList | File[]) => Promise; + removeAttachment: (id: string) => void; + clearAttachments: () => void; + clearAttachmentError: () => void; + handlePaste: (event: React.ClipboardEvent) => void; + handleDrop: (event: React.DragEvent) => void; + + // Status + isSaved: boolean; + isLoaded: boolean; + + // Clear all + clearDraft: () => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 400; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useComposerDraft(teamName: string): UseComposerDraftResult { + const [text, setTextState] = useState(''); + const [chips, setChipsState] = useState([]); + const [attachments, setAttachmentsState] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isLoaded, setIsLoaded] = useState(false); + + // Refs for latest values — avoids stale closures in callbacks + const textRef = useRef(''); + const chipsRef = useRef([]); + const attachmentsRef = useRef([]); + const teamNameRef = useRef(teamName); + const mountedRef = useRef(true); + + // Track whether user has interacted since last load to prevent race + const userTouchedRef = useRef(false); + + // Debounce timer + const timerRef = useRef | null>(null); + const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | null>(null); + + // Keep teamNameRef in sync + useEffect(() => { + teamNameRef.current = teamName; + }, [teamName]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // --------------------------------------------------------------------------- + // Persist helpers + // --------------------------------------------------------------------------- + + const buildSnapshot = useCallback((): ComposerDraftSnapshot => { + return { + version: 1, + teamName: teamNameRef.current, + text: textRef.current, + chips: chipsRef.current, + attachments: attachmentsRef.current, + updatedAt: Date.now(), + }; + }, []); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const pending = pendingRef.current; + pendingRef.current = null; + const isEmpty = + pending.snapshot.text.length === 0 && + pending.snapshot.chips.length === 0 && + pending.snapshot.attachments.length === 0; + if (isEmpty) { + void composerDraftStorage.deleteSnapshot(pending.teamName); + } else { + void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot); + } + } + }, []); + + const scheduleSave = useCallback(() => { + const snapshot = buildSnapshot(); + pendingRef.current = { teamName: teamNameRef.current, snapshot }; + + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + const pending = pendingRef.current; + pendingRef.current = null; + if (pending == null) return; + + const isEmpty = + pending.snapshot.text.length === 0 && + pending.snapshot.chips.length === 0 && + pending.snapshot.attachments.length === 0; + if (isEmpty) { + void composerDraftStorage.deleteSnapshot(pending.teamName); + if (mountedRef.current) setIsSaved(true); + } else { + void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot).then(() => { + if (mountedRef.current) setIsSaved(true); + }); + } + }, DEBOUNCE_MS); + }, [buildSnapshot]); + + // --------------------------------------------------------------------------- + // Apply snapshot to state + // --------------------------------------------------------------------------- + + const applySnapshot = useCallback((snap: ComposerDraftSnapshot) => { + textRef.current = snap.text; + chipsRef.current = snap.chips; + attachmentsRef.current = snap.attachments; + setTextState(snap.text); + setChipsState(snap.chips); + setAttachmentsState(snap.attachments); + }, []); + + // --------------------------------------------------------------------------- + // Load on mount / teamName change + // --------------------------------------------------------------------------- + + useEffect(() => { + let cancelled = false; + flushPending(); + userTouchedRef.current = false; + + // Reset to empty immediately for the new teamName + const empty = composerDraftStorage.emptySnapshot(teamName); + applySnapshot(empty); + setIsSaved(false); + setIsLoaded(false); + setAttachmentError(null); + + void (async () => { + // Try loading unified snapshot first + let snapshot = await composerDraftStorage.loadSnapshot(teamName); + + // If none found, try legacy migration + if (snapshot == null) { + snapshot = await composerDraftStorage.migrateLegacy(teamName); + } + + if (cancelled) return; + + // Race protection: if user already started typing, don't overwrite + if (userTouchedRef.current) { + if (mountedRef.current) setIsLoaded(true); + return; + } + + if (snapshot != null) { + // Validate attachment limits + const totalSize = snapshot.attachments.reduce((sum, a) => sum + a.size, 0); + if (totalSize > MAX_TOTAL_SIZE || snapshot.attachments.length > MAX_FILES) { + snapshot = { ...snapshot, attachments: [] }; + } + + applySnapshot(snapshot); + setIsSaved(true); + } + + if (mountedRef.current) setIsLoaded(true); + })(); + + return () => { + cancelled = true; + }; + }, [teamName, flushPending, applySnapshot]); + + // Flush on unmount + useEffect(() => { + return () => { + flushPending(); + }; + }, [flushPending]); + + // --------------------------------------------------------------------------- + // Text + // --------------------------------------------------------------------------- + + const setText = useCallback( + (v: string) => { + userTouchedRef.current = true; + textRef.current = v; + setTextState(v); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + // --------------------------------------------------------------------------- + // Chips + // --------------------------------------------------------------------------- + + const addChip = useCallback( + (chip: InlineChip) => { + userTouchedRef.current = true; + const next = [...chipsRef.current, chip]; + chipsRef.current = next; + setChipsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const removeChip = useCallback( + (chipId: string) => { + userTouchedRef.current = true; + const next = chipsRef.current.filter((c) => c.id !== chipId); + chipsRef.current = next; + setChipsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + // --------------------------------------------------------------------------- + // Attachments + // --------------------------------------------------------------------------- + + const totalSize = attachments.reduce((sum, a) => sum + a.size, 0); + const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE; + + const addFiles = useCallback( + async (files: FileList | File[]) => { + userTouchedRef.current = true; + setAttachmentError(null); + const fileArray = Array.from(files); + if (fileArray.length === 0) return; + + let batchSize = 0; + for (const file of fileArray) { + const validation = validateAttachment(file); + if (!validation.valid) { + setAttachmentError(validation.error); + return; + } + batchSize += file.size; + } + + const newPayloads: AttachmentPayload[] = []; + for (const file of fileArray) { + try { + const payload = await fileToAttachmentPayload(file); + newPayloads.push(payload); + } catch { + setAttachmentError(`Failed to read file: ${file.name}`); + return; + } + } + + const prev = attachmentsRef.current; + if (prev.length + newPayloads.length > MAX_FILES) { + setAttachmentError(`Maximum ${MAX_FILES} attachments allowed`); + return; + } + const currentTotal = prev.reduce((sum, a) => sum + a.size, 0); + if (currentTotal + batchSize > MAX_TOTAL_SIZE) { + setAttachmentError('Total attachment size exceeds 20MB limit'); + return; + } + + const next = [...prev, ...newPayloads]; + attachmentsRef.current = next; + setAttachmentsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const removeAttachment = useCallback( + (id: string) => { + userTouchedRef.current = true; + const next = attachmentsRef.current.filter((a) => a.id !== id); + attachmentsRef.current = next; + setAttachmentsState(next); + setAttachmentError(null); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const clearAttachments = useCallback(() => { + userTouchedRef.current = true; + attachmentsRef.current = []; + setAttachmentsState([]); + setAttachmentError(null); + setIsSaved(false); + scheduleSave(); + }, [scheduleSave]); + + const clearAttachmentError = useCallback(() => { + setAttachmentError(null); + }, []); + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + + const imageFiles: File[] = []; + for (const item of Array.from(items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + + if (imageFiles.length > 0) { + event.preventDefault(); + void addFiles(imageFiles); + } + }, + [addFiles] + ); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + if (!files?.length) return; + + const allFiles = Array.from(files); + const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); + if (imageFiles.length > 0) { + void addFiles(imageFiles); + } else if (allFiles.length > 0) { + setAttachmentError('Only image files are supported'); + } + }, + [addFiles] + ); + + // --------------------------------------------------------------------------- + // Clear all + // --------------------------------------------------------------------------- + + const clearDraft = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + pendingRef.current = null; + + textRef.current = ''; + chipsRef.current = []; + attachmentsRef.current = []; + + setTextState(''); + setChipsState([]); + setAttachmentsState([]); + setAttachmentError(null); + setIsSaved(false); + + void composerDraftStorage.deleteSnapshot(teamNameRef.current); + }, []); + + return { + text, + setText, + chips, + addChip, + removeChip, + attachments, + attachmentError, + canAddMore, + addFiles, + removeAttachment, + clearAttachments, + clearAttachmentError, + handlePaste, + handleDrop, + isSaved, + isLoaded, + clearDraft, + }; +} diff --git a/src/renderer/index.css b/src/renderer/index.css index fec49dd6..b7a1c4d0 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -773,3 +773,14 @@ body { linear-gradient(-45deg, transparent 75%, #e2e8f0 75%); background-color: #ffffff; } + +/* Lightbox toolbar buttons — enlarge hit targets and fix SVG dead zones */ +.yarl__toolbar .yarl__button { + min-width: 44px; + min-height: 44px; + position: relative; +} + +.yarl__toolbar .yarl__button > svg { + pointer-events: none; +} diff --git a/src/renderer/services/composerDraftStorage.ts b/src/renderer/services/composerDraftStorage.ts new file mode 100644 index 00000000..4c33d1a4 --- /dev/null +++ b/src/renderer/services/composerDraftStorage.ts @@ -0,0 +1,270 @@ +/** + * Atomic draft storage for MessageComposer snapshots. + * + * Unlike `draftStorage.ts` (text-only with TTL), this stores a unified + * snapshot of text + chips + attachments under a single key — no TTL. + * Drafts persist until explicitly cleared (on send or manual action). + */ + +import { del, get, set } from 'idb-keyval'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { AttachmentPayload } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Current snapshot schema version. Bump when shape changes. */ +const SNAPSHOT_VERSION = 1; + +export interface ComposerDraftSnapshot { + version: number; + teamName: string; + text: string; + chips: InlineChip[]; + attachments: AttachmentPayload[]; + updatedAt: number; +} + +// --------------------------------------------------------------------------- +// Key helpers +// --------------------------------------------------------------------------- + +const KEY_PREFIX = 'composer:'; + +function storageKey(teamName: string): string { + return `${KEY_PREFIX}${teamName}`; +} + +/** Legacy keys used by the old three-key approach. */ +function legacyKeys(teamName: string) { + return { + text: `draft:compose:${teamName}`, + chips: `draft:compose:${teamName}:chips`, + attachments: `draft:compose:${teamName}:attachments`, + } as const; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +function isValidSnapshot(data: unknown): data is ComposerDraftSnapshot { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + return ( + typeof obj.version === 'number' && + typeof obj.teamName === 'string' && + typeof obj.text === 'string' && + Array.isArray(obj.chips) && + Array.isArray(obj.attachments) && + typeof obj.updatedAt === 'number' + ); +} + +// --------------------------------------------------------------------------- +// IDB availability tracking (same pattern as draftStorage.ts) +// --------------------------------------------------------------------------- + +let idbUnavailable = false; +let idbUnavailableLogged = false; +const fallbackStore = new Map(); + +function markIdbUnavailable(): void { + if (!idbUnavailableLogged) { + idbUnavailableLogged = true; + console.warn( + '[composerDraftStorage] IndexedDB unavailable, using in-memory storage for this session.' + ); + } + idbUnavailable = true; +} + +// --------------------------------------------------------------------------- +// Core API +// --------------------------------------------------------------------------- + +async function saveSnapshot(teamName: string, snapshot: ComposerDraftSnapshot): Promise { + const key = storageKey(teamName); + if (idbUnavailable) { + fallbackStore.set(key, snapshot); + return; + } + try { + await set(key, snapshot); + } catch { + markIdbUnavailable(); + fallbackStore.set(key, snapshot); + } +} + +async function loadSnapshot(teamName: string): Promise { + const key = storageKey(teamName); + if (idbUnavailable) { + return fallbackStore.get(key) ?? null; + } + try { + const data = await get(key); + if (data == null) return null; + if (isValidSnapshot(data)) return data; + // Invalid shape — discard silently + void del(key); + return null; + } catch { + markIdbUnavailable(); + return fallbackStore.get(key) ?? null; + } +} + +async function deleteSnapshot(teamName: string): Promise { + const key = storageKey(teamName); + if (idbUnavailable) { + fallbackStore.delete(key); + return; + } + try { + await del(key); + } catch { + markIdbUnavailable(); + fallbackStore.delete(key); + } +} + +// --------------------------------------------------------------------------- +// Legacy migration +// --------------------------------------------------------------------------- + +interface LegacyTextDraft { + value: string; + timestamp: number; +} + +function isLegacyTextDraft(d: unknown): d is LegacyTextDraft { + if (typeof d !== 'object' || d === null) return false; + const obj = d as Record; + return typeof obj.value === 'string' && typeof obj.timestamp === 'number'; +} + +function isValidChipArray(data: unknown): data is InlineChip[] { + if (!Array.isArray(data)) return false; + return data.every((raw) => { + if (typeof raw !== 'object' || raw === null) return false; + const item = raw as Record; + return typeof item.id === 'string' && typeof item.filePath === 'string'; + }); +} + +function isValidAttachmentArray(data: unknown): data is AttachmentPayload[] { + if (!Array.isArray(data)) return false; + return data.every((raw) => { + if (typeof raw !== 'object' || raw === null) return false; + const item = raw as Record; + return ( + typeof item.id === 'string' && + typeof item.filename === 'string' && + typeof item.data === 'string' + ); + }); +} + +/** + * Attempts to migrate legacy three-key drafts into a unified snapshot. + * Returns the migrated snapshot or null if no legacy data found. + * Deletes legacy keys on success. + */ +async function migrateLegacy(teamName: string): Promise { + if (idbUnavailable) return null; + + const keys = legacyKeys(teamName); + + try { + const [rawText, rawChips, rawAttachments] = await Promise.all([ + get(keys.text), + get(keys.chips), + get(keys.attachments), + ]); + + // Nothing to migrate + if (rawText == null && rawChips == null && rawAttachments == null) return null; + + let text = ''; + if (isLegacyTextDraft(rawText)) { + text = rawText.value; + } + + let chips: InlineChip[] = []; + if (rawChips != null) { + const chipsData = typeof rawChips === 'string' ? (JSON.parse(rawChips) as unknown) : rawChips; + // Legacy text draft wraps value in {value, timestamp} + const unwrapped = isLegacyTextDraft(chipsData) ? chipsData.value : chipsData; + const toParse = + typeof unwrapped === 'string' ? (JSON.parse(unwrapped) as unknown) : unwrapped; + if (isValidChipArray(toParse)) chips = toParse; + } + + let attachments: AttachmentPayload[] = []; + if (rawAttachments != null) { + const attData = + typeof rawAttachments === 'string' + ? (JSON.parse(rawAttachments) as unknown) + : rawAttachments; + const unwrapped = isLegacyTextDraft(attData) ? attData.value : attData; + const toParse = + typeof unwrapped === 'string' ? (JSON.parse(unwrapped) as unknown) : unwrapped; + if (isValidAttachmentArray(toParse)) attachments = toParse; + } + + // Only create snapshot if there's actual content + if (text.length === 0 && chips.length === 0 && attachments.length === 0) { + // Clean up empty legacy keys + await Promise.all([del(keys.text), del(keys.chips), del(keys.attachments)]); + return null; + } + + const snapshot: ComposerDraftSnapshot = { + version: SNAPSHOT_VERSION, + teamName, + text, + chips, + attachments, + updatedAt: Date.now(), + }; + + // Save new snapshot and delete legacy keys atomically-ish + await saveSnapshot(teamName, snapshot); + await Promise.all([del(keys.text), del(keys.chips), del(keys.attachments)]); + + return snapshot; + } catch { + // Migration is best-effort — don't block the composer + return null; + } +} + +// --------------------------------------------------------------------------- +// Factory for empty snapshot +// --------------------------------------------------------------------------- + +function emptySnapshot(teamName: string): ComposerDraftSnapshot { + return { + version: SNAPSHOT_VERSION, + teamName, + text: '', + chips: [], + attachments: [], + updatedAt: Date.now(), + }; +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export const composerDraftStorage = { + saveSnapshot, + loadSnapshot, + deleteSnapshot, + migrateLegacy, + emptySnapshot, +}; diff --git a/test/renderer/components/team/activity/collapseState.test.ts b/test/renderer/components/team/activity/collapseState.test.ts new file mode 100644 index 00000000..b1adb07c --- /dev/null +++ b/test/renderer/components/team/activity/collapseState.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + findNewestMessageIndex, + resolveTimelineCollapseState, +} from '@renderer/components/team/activity/collapseState'; + +describe('team activity collapse state', () => { + describe('findNewestMessageIndex', () => { + it('skips a pinned thought group and returns the first real message', () => { + expect( + findNewestMessageIndex([ + { type: 'lead-thoughts' }, + { type: 'message' }, + { type: 'lead-thoughts' }, + { type: 'message' }, + ]) + ).toBe(1); + }); + + it('returns -1 when there are no real messages', () => { + expect(findNewestMessageIndex([{ type: 'lead-thoughts' }, { type: 'lead-thoughts' }])).toBe( + -1 + ); + }); + }); + + describe('resolveTimelineCollapseState', () => { + it('falls back to default mode when global collapsed mode is off', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: false, + itemIndex: 3, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: false, + }) + ).toEqual({ mode: 'default' }); + }); + + it('keeps the newest message open and non-toggleable in collapsed mode', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 1, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: false, + }) + ).toEqual({ + mode: 'managed', + isCollapsed: false, + canToggle: false, + }); + }); + + it('keeps the pinned thought group open and non-toggleable', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 0, + newestMessageIndex: 2, + isPinnedThoughtGroup: true, + isExpandedOverride: false, + }) + ).toEqual({ + mode: 'managed', + isCollapsed: false, + canToggle: false, + }); + }); + + it('collapses an older item when it is no longer the newest message', () => { + const onToggleOverride = vi.fn(); + const state = resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 2, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: false, + onToggleOverride, + }); + + expect(state).toMatchObject({ + mode: 'managed', + isCollapsed: true, + canToggle: true, + }); + + if (state.mode !== 'managed') { + throw new Error('Expected managed collapse state'); + } + + state.onToggle?.(); + expect(onToggleOverride).toHaveBeenCalledTimes(1); + }); + + it('reopens older items that have a persisted expand override', () => { + expect( + resolveTimelineCollapseState({ + allCollapsed: true, + itemIndex: 4, + newestMessageIndex: 1, + isPinnedThoughtGroup: false, + isExpandedOverride: true, + onToggleOverride: () => undefined, + }) + ).toEqual({ + mode: 'managed', + isCollapsed: false, + canToggle: true, + onToggle: expect.any(Function), + }); + }); + }); +}); diff --git a/test/renderer/hooks/useComposerDraft.test.ts b/test/renderer/hooks/useComposerDraft.test.ts new file mode 100644 index 00000000..20592bf6 --- /dev/null +++ b/test/renderer/hooks/useComposerDraft.test.ts @@ -0,0 +1,437 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock idb-keyval before importing composerDraftStorage +const store = new Map(); + +vi.mock('idb-keyval', () => ({ + get: vi.fn((key: string) => Promise.resolve(store.get(key) ?? undefined)), + set: vi.fn((key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(); + }), + del: vi.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + keys: vi.fn(() => Promise.resolve([...store.keys()])), +})); + +import { + composerDraftStorage, + type ComposerDraftSnapshot, +} from '@renderer/services/composerDraftStorage'; + +function makeSnapshot( + teamName: string, + overrides?: Partial +): ComposerDraftSnapshot { + return { + version: 1, + teamName, + text: 'hello', + chips: [], + attachments: [], + updatedAt: Date.now(), + ...overrides, + }; +} + +describe('composerDraftStorage', () => { + beforeEach(() => { + store.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('saveSnapshot / loadSnapshot', () => { + it('should save and load a snapshot', async () => { + const snap = makeSnapshot('team-a'); + await composerDraftStorage.saveSnapshot('team-a', snap); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result).toEqual(snap); + }); + + it('should return null for non-existent snapshot', async () => { + const result = await composerDraftStorage.loadSnapshot('nonexistent'); + expect(result).toBeNull(); + }); + + it('should overwrite existing snapshot', async () => { + const snap1 = makeSnapshot('team-a', { text: 'first' }); + const snap2 = makeSnapshot('team-a', { text: 'second' }); + await composerDraftStorage.saveSnapshot('team-a', snap1); + await composerDraftStorage.saveSnapshot('team-a', snap2); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result?.text).toBe('second'); + }); + + it('should NOT have TTL — drafts persist indefinitely', async () => { + const snap = makeSnapshot('team-a', { + updatedAt: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago + }); + await composerDraftStorage.saveSnapshot('team-a', snap); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result).toEqual(snap); + }); + }); + + describe('deleteSnapshot', () => { + it('should delete a snapshot', async () => { + const snap = makeSnapshot('team-a'); + await composerDraftStorage.saveSnapshot('team-a', snap); + await composerDraftStorage.deleteSnapshot('team-a'); + const result = await composerDraftStorage.loadSnapshot('team-a'); + expect(result).toBeNull(); + }); + + it('should not throw when deleting non-existent snapshot', async () => { + await expect(composerDraftStorage.deleteSnapshot('nonexistent')).resolves.toBeUndefined(); + }); + }); + + describe('team isolation', () => { + it('should isolate drafts by teamName', async () => { + const snapA = makeSnapshot('team-a', { text: 'from team A' }); + const snapB = makeSnapshot('team-b', { text: 'from team B' }); + await composerDraftStorage.saveSnapshot('team-a', snapA); + await composerDraftStorage.saveSnapshot('team-b', snapB); + + const resultA = await composerDraftStorage.loadSnapshot('team-a'); + const resultB = await composerDraftStorage.loadSnapshot('team-b'); + expect(resultA?.text).toBe('from team A'); + expect(resultB?.text).toBe('from team B'); + }); + + it('deleting one team draft should not affect another', async () => { + await composerDraftStorage.saveSnapshot('team-a', makeSnapshot('team-a')); + await composerDraftStorage.saveSnapshot('team-b', makeSnapshot('team-b')); + await composerDraftStorage.deleteSnapshot('team-a'); + + expect(await composerDraftStorage.loadSnapshot('team-a')).toBeNull(); + expect(await composerDraftStorage.loadSnapshot('team-b')).not.toBeNull(); + }); + }); + + describe('legacy migration', () => { + it('should migrate text from old draft:compose: key', async () => { + // Simulate old storage format + store.set('draft:compose:my-team', { value: 'old text', timestamp: Date.now() }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('old text'); + expect(result!.teamName).toBe('my-team'); + + // Legacy keys should be deleted + expect(store.has('draft:compose:my-team')).toBe(false); + + // New snapshot key should exist + const loaded = await composerDraftStorage.loadSnapshot('my-team'); + expect(loaded?.text).toBe('old text'); + }); + + it('should migrate chips from old draft:compose::chips key', async () => { + const chips = [ + { + id: 'c1', + filePath: '/test/file.ts', + fileName: 'file.ts', + fromLine: 1, + toLine: 10, + codeText: 'code', + language: 'typescript', + }, + ]; + store.set('draft:compose:my-team:chips', { + value: JSON.stringify(chips), + timestamp: Date.now(), + }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.chips).toHaveLength(1); + expect(result!.chips[0].id).toBe('c1'); + + // Legacy key should be cleaned up + expect(store.has('draft:compose:my-team:chips')).toBe(false); + }); + + it('should migrate attachments from old draft:compose::attachments key', async () => { + const attachments = [ + { + id: 'a1', + filename: 'test.png', + mimeType: 'image/png', + size: 1024, + data: 'base64data', + }, + ]; + store.set('draft:compose:my-team:attachments', { + value: JSON.stringify(attachments), + timestamp: Date.now(), + }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.attachments).toHaveLength(1); + expect(result!.attachments[0].id).toBe('a1'); + }); + + it('should return null when no legacy data exists', async () => { + const result = await composerDraftStorage.migrateLegacy('nonexistent'); + expect(result).toBeNull(); + }); + + it('should combine all three legacy sources into one snapshot', async () => { + store.set('draft:compose:my-team', { value: 'combined text', timestamp: Date.now() }); + store.set('draft:compose:my-team:chips', { + value: JSON.stringify([ + { + id: 'c1', + filePath: '/f.ts', + fileName: 'f.ts', + fromLine: 1, + toLine: 2, + codeText: 'x', + language: 'ts', + }, + ]), + timestamp: Date.now(), + }); + store.set('draft:compose:my-team:attachments', { + value: JSON.stringify([ + { id: 'a1', filename: 'img.png', mimeType: 'image/png', size: 512, data: 'b64' }, + ]), + timestamp: Date.now(), + }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('combined text'); + expect(result!.chips).toHaveLength(1); + expect(result!.attachments).toHaveLength(1); + + // All legacy keys cleaned up + expect(store.has('draft:compose:my-team')).toBe(false); + expect(store.has('draft:compose:my-team:chips')).toBe(false); + expect(store.has('draft:compose:my-team:attachments')).toBe(false); + }); + + it('should clean up empty legacy keys without creating a snapshot', async () => { + store.set('draft:compose:my-team', { value: '', timestamp: Date.now() }); + + const result = await composerDraftStorage.migrateLegacy('my-team'); + expect(result).toBeNull(); + expect(store.has('draft:compose:my-team')).toBe(false); + }); + }); + + describe('emptySnapshot', () => { + it('should create an empty snapshot for given teamName', () => { + const snap = composerDraftStorage.emptySnapshot('test-team'); + expect(snap.teamName).toBe('test-team'); + expect(snap.text).toBe(''); + expect(snap.chips).toEqual([]); + expect(snap.attachments).toEqual([]); + expect(snap.version).toBe(1); + }); + }); + + describe('invalid data handling', () => { + it('should return null and discard invalid snapshot data', async () => { + store.set('composer:bad-team', { garbage: true }); + const result = await composerDraftStorage.loadSnapshot('bad-team'); + expect(result).toBeNull(); + // Invalid data should be deleted + expect(store.has('composer:bad-team')).toBe(false); + }); + + it('should discard snapshot missing required fields', async () => { + store.set('composer:partial', { version: 1, teamName: 'partial', text: 'hi' }); + const result = await composerDraftStorage.loadSnapshot('partial'); + expect(result).toBeNull(); + expect(store.has('composer:partial')).toBe(false); + }); + }); + + describe('clear-on-send flow', () => { + it('should delete snapshot and return null on next load', async () => { + const snap = makeSnapshot('team-send', { text: 'about to send' }); + await composerDraftStorage.saveSnapshot('team-send', snap); + + // Simulate clear-on-send + await composerDraftStorage.deleteSnapshot('team-send'); + const afterClear = await composerDraftStorage.loadSnapshot('team-send'); + expect(afterClear).toBeNull(); + }); + + it('should allow saving a new draft after clear', async () => { + const snap1 = makeSnapshot('team-send', { text: 'first message' }); + await composerDraftStorage.saveSnapshot('team-send', snap1); + await composerDraftStorage.deleteSnapshot('team-send'); + + // New draft after clear + const snap2 = makeSnapshot('team-send', { text: 'second draft' }); + await composerDraftStorage.saveSnapshot('team-send', snap2); + const result = await composerDraftStorage.loadSnapshot('team-send'); + expect(result?.text).toBe('second draft'); + }); + }); + + describe('concurrent / rapid saves', () => { + it('should resolve to the last written snapshot', async () => { + const snaps = Array.from({ length: 5 }, (_, i) => + makeSnapshot('team-rapid', { text: `iteration-${i}` }) + ); + + // Fire all saves concurrently + await Promise.all(snaps.map((s) => composerDraftStorage.saveSnapshot('team-rapid', s))); + + const result = await composerDraftStorage.loadSnapshot('team-rapid'); + // Last save wins — the mock store is synchronous, so the last set() call wins + expect(result?.text).toBe('iteration-4'); + }); + + it('should handle interleaved save and delete', async () => { + await composerDraftStorage.saveSnapshot('team-x', makeSnapshot('team-x', { text: 'v1' })); + // Delete then immediately save again + await composerDraftStorage.deleteSnapshot('team-x'); + await composerDraftStorage.saveSnapshot('team-x', makeSnapshot('team-x', { text: 'v2' })); + + const result = await composerDraftStorage.loadSnapshot('team-x'); + expect(result?.text).toBe('v2'); + }); + }); + + describe('full data roundtrip', () => { + it('should preserve text, chips, and attachments together', async () => { + const snap = makeSnapshot('team-full', { + text: 'Hello @alice', + chips: [ + { + id: 'chip-1', + filePath: '/src/index.ts', + fileName: 'index.ts', + fromLine: 1, + toLine: 10, + codeText: 'const x = 1;', + language: 'typescript', + }, + ], + attachments: [ + { + id: 'att-1', + filename: 'screenshot.png', + mimeType: 'image/png', + size: 2048, + data: 'iVBORw0KGgo=', + }, + ], + }); + await composerDraftStorage.saveSnapshot('team-full', snap); + const result = await composerDraftStorage.loadSnapshot('team-full'); + + expect(result).not.toBeNull(); + expect(result!.text).toBe('Hello @alice'); + expect(result!.chips).toHaveLength(1); + expect(result!.chips[0].filePath).toBe('/src/index.ts'); + expect(result!.attachments).toHaveLength(1); + expect(result!.attachments[0].filename).toBe('screenshot.png'); + expect(result!.attachments[0].size).toBe(2048); + }); + }); + + describe('recovery after restart', () => { + it('should load draft saved in a previous session (simulated)', async () => { + // Simulate saving in "session 1" + const snap = makeSnapshot('team-persist', { + text: 'Unsent message from last session', + updatedAt: Date.now() - 3600_000, // 1 hour ago + }); + await composerDraftStorage.saveSnapshot('team-persist', snap); + + // Simulate "session 2" — load the same key + const result = await composerDraftStorage.loadSnapshot('team-persist'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('Unsent message from last session'); + }); + + it('should recover draft saved 30 days ago (no TTL)', async () => { + const snap = makeSnapshot('team-old', { + text: 'Ancient draft', + updatedAt: Date.now() - 30 * 24 * 3600_000, + }); + await composerDraftStorage.saveSnapshot('team-old', snap); + const result = await composerDraftStorage.loadSnapshot('team-old'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('Ancient draft'); + }); + }); +}); + +describe('composerDraftStorage — IDB failure fallback', () => { + beforeEach(() => { + vi.resetModules(); + store.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fall back to in-memory store when IDB set throws', async () => { + // Make idb set throw to trigger fallback + const { set: idbSet } = await import('idb-keyval'); + const mockSet = vi.mocked(idbSet); + mockSet.mockRejectedValueOnce(new Error('QuotaExceeded')); + + // Re-import to get a fresh module with idbUnavailable = false + const { composerDraftStorage: freshStorage } = await import( + '@renderer/services/composerDraftStorage' + ); + + const snap: ComposerDraftSnapshot = { + version: 1, + teamName: 'fallback-team', + text: 'saved to memory', + chips: [], + attachments: [], + updatedAt: Date.now(), + }; + + // First save triggers the error → fallback kicks in + await freshStorage.saveSnapshot('fallback-team', snap); + + // Subsequent load uses in-memory fallback + const result = await freshStorage.loadSnapshot('fallback-team'); + expect(result).not.toBeNull(); + expect(result!.text).toBe('saved to memory'); + }); + + it('should allow delete from in-memory fallback', async () => { + const { set: idbSet } = await import('idb-keyval'); + const mockSet = vi.mocked(idbSet); + mockSet.mockRejectedValueOnce(new Error('IDB broken')); + + const { composerDraftStorage: freshStorage } = await import( + '@renderer/services/composerDraftStorage' + ); + + const snap: ComposerDraftSnapshot = { + version: 1, + teamName: 'del-team', + text: 'to delete', + chips: [], + attachments: [], + updatedAt: Date.now(), + }; + + await freshStorage.saveSnapshot('del-team', snap); + await freshStorage.deleteSnapshot('del-team'); + + const result = await freshStorage.loadSnapshot('del-team'); + expect(result).toBeNull(); + }); +}); diff --git a/test/renderer/utils/teamMessageExpandStorage.test.ts b/test/renderer/utils/teamMessageExpandStorage.test.ts new file mode 100644 index 00000000..1cbad969 --- /dev/null +++ b/test/renderer/utils/teamMessageExpandStorage.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + addExpanded, + getExpandedOverrides, + removeExpanded, +} from '@renderer/utils/teamMessageExpandStorage'; + +describe('teamMessageExpandStorage', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('stores overrides per team', () => { + addExpanded('alpha', 'msg-1'); + addExpanded('beta', 'msg-2'); + + expect([...getExpandedOverrides('alpha')]).toEqual(['msg-1']); + expect([...getExpandedOverrides('beta')]).toEqual(['msg-2']); + }); + + it('deduplicates repeated expansions', () => { + addExpanded('alpha', 'msg-1'); + addExpanded('alpha', 'msg-1'); + + expect([...getExpandedOverrides('alpha')]).toEqual(['msg-1']); + }); + + it('removes only the requested override', () => { + addExpanded('alpha', 'msg-1'); + addExpanded('alpha', 'msg-2'); + + removeExpanded('alpha', 'msg-1'); + + expect([...getExpandedOverrides('alpha')]).toEqual(['msg-2']); + }); + + it('returns an empty set for malformed stored data', () => { + localStorage.setItem('team-msg-expanded:alpha', '{bad json'); + + expect(getExpandedOverrides('alpha')).toEqual(new Set()); + }); +});