feat: enhance UI and functionality in team dialogs and components
- Improved lightbox toolbar button hit targets for better accessibility. - Updated ActivityItem and ActivityTimeline components to support managed collapse states for messages. - Refactored message collapsing logic to allow for user-controlled expansion in various components. - Enhanced CreateTeamDialog and LaunchTeamDialog with improved loading indicators and layout adjustments. - Increased maximum message length in SendMessageDialog to accommodate larger inputs. - Added icons and visual enhancements in ProjectPathSelector and EffortLevelSelector for better user experience.
This commit is contained in:
parent
c09ab76d43
commit
9bfcbb182c
23 changed files with 1864 additions and 385 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<article
|
||||
|
|
@ -340,21 +334,13 @@ export const ActivityItem = ({
|
|||
'flex items-center gap-2 px-3 py-2',
|
||||
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||
].join(' ')}
|
||||
onClick={
|
||||
isHeaderClickable
|
||||
? () => {
|
||||
setIsExpanded((v) => !v);
|
||||
onCollapseToggle?.();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={handleHeaderToggle}
|
||||
onKeyDown={
|
||||
isHeaderClickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsExpanded((v) => !v);
|
||||
onCollapseToggle?.();
|
||||
handleHeaderToggle?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const reportedRef = useRef(false);
|
||||
|
|
@ -121,8 +121,7 @@ const MessageRowWithObserver = ({
|
|||
onReply={onReply}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
forceCollapsed={forceCollapsed}
|
||||
onCollapseToggle={onCollapseToggle}
|
||||
collapseState={collapseState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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 (
|
||||
<LeadThoughtsGroupRow
|
||||
key={itemKey}
|
||||
|
|
@ -371,8 +351,7 @@ export const ActivityTimeline = ({
|
|||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(0)}
|
||||
forceCollapsed={collapseProps.forceCollapsed}
|
||||
onCollapseToggle={collapseProps.onCollapseToggle}
|
||||
collapseState={collapseState}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
@ -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 (
|
||||
<React.Fragment key={itemKey}>
|
||||
{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}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrolledUpRef = useRef(false);
|
||||
const distanceFromBottomRef = useRef(0);
|
||||
const scrollSyncFrameRef = useRef<number | null>(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 */}
|
||||
<div
|
||||
role={forceCollapsed === true || onCollapseToggle != null ? 'button' : undefined}
|
||||
tabIndex={forceCollapsed === true || onCollapseToggle != null ? 0 : undefined}
|
||||
role={canToggleBodyVisibility ? 'button' : undefined}
|
||||
tabIndex={canToggleBodyVisibility ? 0 : undefined}
|
||||
className={[
|
||||
'flex select-none items-center gap-2 px-3 py-1.5',
|
||||
forceCollapsed === true || onCollapseToggle != null ? 'cursor-pointer' : '',
|
||||
canToggleBodyVisibility ? 'cursor-pointer' : '',
|
||||
].join(' ')}
|
||||
onClick={
|
||||
forceCollapsed === true || onCollapseToggle != null
|
||||
? () => {
|
||||
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 ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
|
|
@ -608,7 +666,6 @@ export const LeadThoughtsGroupRow = ({
|
|||
scrollbarColor:
|
||||
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
||||
overflowAnchor: 'none',
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
|
|
@ -626,10 +683,13 @@ export const LeadThoughtsGroupRow = ({
|
|||
) : null}
|
||||
</article>
|
||||
{isBodyVisible && !expanded && needsTruncation ? (
|
||||
<div className="flex justify-center pt-1" style={{ transform: 'translateY(-20px)' }}>
|
||||
<div
|
||||
className="pointer-events-none flex justify-center pt-1"
|
||||
style={{ transform: 'translateY(-20px)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(true);
|
||||
|
|
@ -642,12 +702,12 @@ export const LeadThoughtsGroupRow = ({
|
|||
) : null}
|
||||
{isBodyVisible && expanded && needsTruncation ? (
|
||||
<div
|
||||
className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
|
||||
className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
|
||||
style={{ transform: 'translateY(-20px)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCollapse();
|
||||
|
|
|
|||
66
src/renderer/components/team/activity/collapseState.ts
Normal file
66
src/renderer/components/team/activity/collapseState.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export interface DefaultActivityCollapseState {
|
||||
mode: 'default';
|
||||
}
|
||||
|
||||
export interface ManagedActivityCollapseState {
|
||||
mode: 'managed';
|
||||
isCollapsed: boolean;
|
||||
canToggle: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export type ActivityCollapseState = DefaultActivityCollapseState | ManagedActivityCollapseState;
|
||||
|
||||
export interface TimelineItemLike {
|
||||
type: 'message' | 'lead-thoughts';
|
||||
}
|
||||
|
||||
interface ResolveTimelineCollapseStateArgs {
|
||||
allCollapsed?: boolean;
|
||||
itemIndex: number;
|
||||
newestMessageIndex: number;
|
||||
isPinnedThoughtGroup: boolean;
|
||||
isExpandedOverride: boolean;
|
||||
onToggleOverride?: () => void;
|
||||
}
|
||||
|
||||
export function isManagedCollapseState(
|
||||
collapseState: ActivityCollapseState | undefined
|
||||
): collapseState is ManagedActivityCollapseState {
|
||||
return collapseState?.mode === 'managed';
|
||||
}
|
||||
|
||||
export function findNewestMessageIndex(items: readonly TimelineItemLike[]): number {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i]?.type === 'message') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function resolveTimelineCollapseState({
|
||||
allCollapsed,
|
||||
itemIndex,
|
||||
newestMessageIndex,
|
||||
isPinnedThoughtGroup,
|
||||
isExpandedOverride,
|
||||
onToggleOverride,
|
||||
}: ResolveTimelineCollapseStateArgs): ActivityCollapseState {
|
||||
if (!allCollapsed) {
|
||||
return { mode: 'default' };
|
||||
}
|
||||
|
||||
if (isPinnedThoughtGroup || itemIndex === newestMessageIndex) {
|
||||
return {
|
||||
mode: 'managed',
|
||||
isCollapsed: false,
|
||||
canToggle: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'managed',
|
||||
isCollapsed: !isExpandedOverride,
|
||||
canToggle: onToggleOverride != null,
|
||||
onToggle: onToggleOverride,
|
||||
};
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ export const ImageLightbox = ({
|
|||
}}
|
||||
styles={{
|
||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
||||
button: { padding: 16 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -837,43 +837,6 @@ export const CreateTeamDialog = ({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && prepareState === 'ready' ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{prepareMessage}</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -934,36 +897,79 @@ export const CreateTeamDialog = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{canOpenExistingTeam ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onOpenTeam(request.teamName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Open Existing Team
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'ready' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||
{prepareMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canOpenExistingTeam ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onOpenTeam(request.teamName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Open Existing Team
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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<EffortLevelSelectorProps> = ({
|
|||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||
Effort level (optional)
|
||||
</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{EFFORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{EFFORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Controls how much reasoning Claude invests before responding. Default uses Claude's
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
|||
disabled = false,
|
||||
}) => (
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked && !disabled}
|
||||
|
|
|
|||
|
|
@ -506,62 +506,70 @@ export const LaunchTeamDialog = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'ready' ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{prepareMessage}</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={isSubmitting || prepareState !== 'ready'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Launching...
|
||||
</>
|
||||
) : (
|
||||
'Launch'
|
||||
)}
|
||||
</Button>
|
||||
{prepareState === 'ready' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||
{prepareMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'failed' ? <div /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={isSubmitting || prepareState !== 'ready'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Launching...
|
||||
</>
|
||||
) : (
|
||||
'Launch'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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' ? (
|
||||
<div className="space-y-1.5">
|
||||
<Combobox
|
||||
options={projects.map((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) => (
|
||||
<>
|
||||
<Check
|
||||
className={cn('mr-2 size-3.5 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-[var(--color-text)]">
|
||||
{renderHighlightedText(option.label, query)}
|
||||
</p>
|
||||
<p className="truncate text-[var(--color-text-muted)]">
|
||||
{renderHighlightedText(option.description ?? '', query)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<Combobox
|
||||
options={projects.map((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) => (
|
||||
<>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 size-3.5 shrink-0',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-[var(--color-text)]">
|
||||
{renderHighlightedText(option.label, query)}
|
||||
</p>
|
||||
<p className="truncate text-[var(--color-text-muted)]">
|
||||
{renderHighlightedText(option.description ?? '', query)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{!selectedProjectPath ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Select a project from the list
|
||||
|
|
@ -137,12 +143,15 @@ export const ProjectPathSelector = ({
|
|||
) : null}
|
||||
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
||||
{!projectsLoading && projects.length === 0 ? (
|
||||
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>No projects found, switch to custom path.</p>
|
||||
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
||||
No projects found, switch to custom path.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={customCwd}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ interface QuotedMessage {
|
|||
text: string;
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 4000;
|
||||
const MAX_MESSAGE_LENGTH = 50_000;
|
||||
|
||||
interface SendMessageDialogProps {
|
||||
open: boolean;
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ export const SkipPermissionsCheckbox: React.FC<SkipPermissionsCheckboxProps> = (
|
|||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
>
|
||||
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<FileIcon
|
||||
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
|
||||
className="size-3.5"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
|
|
@ -878,12 +880,6 @@ export const TaskDetailDialog = ({
|
|||
containerClassName="-mx-6"
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<DialogFooter className="flex items-center justify-end sm:justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<EnhancedChunk[] | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(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 = ({
|
|||
<SubagentRecentMessagesPreview
|
||||
messages={previewMessages}
|
||||
memberName={previewLog.memberName ?? undefined}
|
||||
hasMore={previewHasMore}
|
||||
onLoadMore={() => 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({
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
|
||||
Latest messages{memberName ? ` — ${memberName}` : ''}
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(messages[0].timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-[10px] text-[var(--color-text-muted)]">
|
||||
{m.label ? (
|
||||
<span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
|
||||
{m.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono">{m.kind}</span>
|
||||
)}
|
||||
<div className={`${expandedAll ? 'max-h-none' : 'max-h-[200px]'} overflow-y-auto pr-1`}>
|
||||
{messages.map((m, index) => (
|
||||
<div key={m.id} className="py-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1 text-xs text-[var(--color-text)]">
|
||||
<MarkdownViewer
|
||||
content={m.content}
|
||||
bare
|
||||
maxHeight="max-h-none"
|
||||
className="[&>div>div]:p-0 [&_ol]:my-1 [&_p]:my-1 [&_ul]:my-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
<div className="shrink-0 text-right text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(m.timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{m.kind === 'tool_result' ? (
|
||||
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--color-text)]">
|
||||
{m.content}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto text-xs text-[var(--color-text)]">
|
||||
<MarkdownViewer content={m.content} copyable />
|
||||
</div>
|
||||
)}
|
||||
{index < messages.length - 1 ? (
|
||||
<hr className="mt-2 border-[var(--color-border)]" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMore && onLoadMore ? (
|
||||
<div className="flex justify-center pb-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 flex justify-end pb-1 pt-2">
|
||||
{!expandedAll ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setExpandedAll(true)}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
Expand
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setExpandedAll(false)}
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
Collapse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>) => {
|
||||
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 = ({
|
|||
<TooltipContent side="top">
|
||||
{!isTeamAlive
|
||||
? 'Team must be online to attach images'
|
||||
: !canAddMore
|
||||
: !draft.canAddMore
|
||||
? 'Maximum attachments reached'
|
||||
: 'Attach images (paste or drag & drop)'}
|
||||
</TooltipContent>
|
||||
|
|
@ -408,10 +396,10 @@ export const MessageComposer = ({
|
|||
</div>
|
||||
|
||||
<AttachmentPreviewList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
error={attachmentError}
|
||||
onDismissError={clearAttachmentError}
|
||||
attachments={draft.attachments}
|
||||
onRemove={draft.removeAttachment}
|
||||
error={draft.attachmentError}
|
||||
onDismissError={draft.clearAttachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
/>
|
||||
|
|
@ -419,13 +407,13 @@ export const MessageComposer = ({
|
|||
<MentionableTextarea
|
||||
id={`compose-${teamName}`}
|
||||
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
value={draft.text}
|
||||
onValueChange={draft.setText}
|
||||
suggestions={mentionSuggestions}
|
||||
chips={chipDraft.chips}
|
||||
onChipRemove={chipDraft.removeChip}
|
||||
chips={draft.chips}
|
||||
onChipRemove={draft.removeChip}
|
||||
projectPath={projectPath}
|
||||
onFileChipInsert={chipDraft.addChip}
|
||||
onFileChipInsert={draft.addChip}
|
||||
onModEnter={handleSend}
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
|
|
|
|||
448
src/renderer/hooks/useComposerDraft.ts
Normal file
448
src/renderer/hooks/useComposerDraft.ts
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
/**
|
||||
* Unified composer draft hook — atomic persistence of text + chips + attachments.
|
||||
*
|
||||
* Replaces the trio of `useDraftPersistence`, `useChipDraftPersistence`, and
|
||||
* `useAttachments` for the team `MessageComposer`.
|
||||
*
|
||||
* Key guarantees:
|
||||
* - Single IndexedDB key per team (`composer:<teamName>`), 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<void>;
|
||||
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<InlineChip[]>([]);
|
||||
const [attachments, setAttachmentsState] = useState<AttachmentPayload[]>([]);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(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<InlineChip[]>([]);
|
||||
const attachmentsRef = useRef<AttachmentPayload[]>([]);
|
||||
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<ReturnType<typeof setTimeout> | 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
270
src/renderer/services/composerDraftStorage.ts
Normal file
270
src/renderer/services/composerDraftStorage.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<string, ComposerDraftSnapshot>();
|
||||
|
||||
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<void> {
|
||||
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<ComposerDraftSnapshot | null> {
|
||||
const key = storageKey(teamName);
|
||||
if (idbUnavailable) {
|
||||
return fallbackStore.get(key) ?? null;
|
||||
}
|
||||
try {
|
||||
const data = await get<unknown>(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<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<ComposerDraftSnapshot | null> {
|
||||
if (idbUnavailable) return null;
|
||||
|
||||
const keys = legacyKeys(teamName);
|
||||
|
||||
try {
|
||||
const [rawText, rawChips, rawAttachments] = await Promise.all([
|
||||
get<unknown>(keys.text),
|
||||
get<unknown>(keys.chips),
|
||||
get<unknown>(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,
|
||||
};
|
||||
116
test/renderer/components/team/activity/collapseState.test.ts
Normal file
116
test/renderer/components/team/activity/collapseState.test.ts
Normal file
|
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
437
test/renderer/hooks/useComposerDraft.test.ts
Normal file
437
test/renderer/hooks/useComposerDraft.test.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock idb-keyval before importing composerDraftStorage
|
||||
const store = new Map<string, unknown>();
|
||||
|
||||
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>
|
||||
): 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:<teamName> 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:<teamName>: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:<teamName>: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();
|
||||
});
|
||||
});
|
||||
43
test/renderer/utils/teamMessageExpandStorage.test.ts
Normal file
43
test/renderer/utils/teamMessageExpandStorage.test.ts
Normal file
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue