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,
|
showNoise: false,
|
||||||
});
|
});
|
||||||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(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)
|
// Open editor overlay when a file reveal is requested (e.g. from chip click)
|
||||||
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
|
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 { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
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 { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react';
|
import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isManagedCollapseState } from './collapseState';
|
||||||
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
||||||
|
|
||||||
|
import type { ActivityCollapseState } from './collapseState';
|
||||||
import type { TeamColorSet } from '@renderer/constants/teamColors';
|
import type { TeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -52,10 +54,8 @@ interface ActivityItemProps {
|
||||||
onRestartTeam?: () => void;
|
onRestartTeam?: () => void;
|
||||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||||
zebraShade?: boolean;
|
zebraShade?: boolean;
|
||||||
/** When true, collapse message body — show only header with expand chevron. */
|
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||||
forceCollapsed?: boolean;
|
collapseState?: ActivityCollapseState;
|
||||||
/** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */
|
|
||||||
onCollapseToggle?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStringField(obj: StructuredMessage, key: string): string | null {
|
function getStringField(obj: StructuredMessage, key: string): string | null {
|
||||||
|
|
@ -217,8 +217,7 @@ export const ActivityItem = ({
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
zebraShade,
|
zebraShade,
|
||||||
forceCollapsed,
|
collapseState,
|
||||||
onCollapseToggle,
|
|
||||||
}: ActivityItemProps): React.JSX.Element => {
|
}: ActivityItemProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||||
const formattedRole = formatAgentRole(memberRole);
|
const formattedRole = formatAgentRole(memberRole);
|
||||||
|
|
@ -237,19 +236,9 @@ export const ActivityItem = ({
|
||||||
// Never collapse rate limit messages as noise — they must be visible
|
// Never collapse rate limit messages as noise — they must be visible
|
||||||
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
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 systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||||
const [isExpanded, setIsExpanded] = useState(!systemLabel && !forceCollapsed);
|
const isManaged = isManagedCollapseState(collapseState);
|
||||||
|
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
||||||
const strippedText = useMemo(() => {
|
const strippedText = useMemo(() => {
|
||||||
|
|
@ -298,11 +287,16 @@ export const ActivityItem = ({
|
||||||
onCreateTask?.(subject, description);
|
onCreateTask?.(subject, description);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHeaderClickable =
|
const isHeaderClickable = isManaged ? collapseState.canToggle : false;
|
||||||
Boolean(systemLabel) || forceCollapsed === true || onCollapseToggle != null;
|
|
||||||
const showChevron = isHeaderClickable;
|
const showChevron = isHeaderClickable;
|
||||||
const isUserSent = message.source === 'user_sent';
|
const isUserSent = message.source === 'user_sent';
|
||||||
const isSystemMessage = message.from === 'system';
|
const isSystemMessage = message.from === 'system';
|
||||||
|
const onManagedToggle = isManaged ? collapseState.onToggle : undefined;
|
||||||
|
const handleHeaderToggle = isHeaderClickable
|
||||||
|
? (): void => {
|
||||||
|
onManagedToggle?.();
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|
@ -340,21 +334,13 @@ export const ActivityItem = ({
|
||||||
'flex items-center gap-2 px-3 py-2',
|
'flex items-center gap-2 px-3 py-2',
|
||||||
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={
|
onClick={handleHeaderToggle}
|
||||||
isHeaderClickable
|
|
||||||
? () => {
|
|
||||||
setIsExpanded((v) => !v);
|
|
||||||
onCollapseToggle?.();
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
isHeaderClickable
|
isHeaderClickable
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsExpanded((v) => !v);
|
handleHeaderToggle?.();
|
||||||
onCollapseToggle?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
|
||||||
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||||
|
|
||||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||||
|
import type { ActivityCollapseState } from './collapseState';
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
|
|
@ -54,8 +56,7 @@ const MessageRowWithObserver = ({
|
||||||
onVisible,
|
onVisible,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
forceCollapsed,
|
collapseState,
|
||||||
onCollapseToggle,
|
|
||||||
}: {
|
}: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -72,8 +73,7 @@ const MessageRowWithObserver = ({
|
||||||
onVisible?: (message: InboxMessage) => void;
|
onVisible?: (message: InboxMessage) => void;
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
onRestartTeam?: () => void;
|
onRestartTeam?: () => void;
|
||||||
forceCollapsed?: boolean;
|
collapseState?: ActivityCollapseState;
|
||||||
onCollapseToggle?: () => void;
|
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const reportedRef = useRef(false);
|
const reportedRef = useRef(false);
|
||||||
|
|
@ -121,8 +121,7 @@ const MessageRowWithObserver = ({
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
forceCollapsed={forceCollapsed}
|
collapseState={collapseState}
|
||||||
onCollapseToggle={onCollapseToggle}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -308,46 +307,27 @@ export const ActivityTimeline = ({
|
||||||
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
||||||
|
|
||||||
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
|
// 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(() => {
|
const newestMessageIndex = useMemo(() => {
|
||||||
for (let i = startIndex; i < timelineItems.length; i++) {
|
return findNewestMessageIndex(timelineItems);
|
||||||
if (timelineItems[i].type !== 'lead-thoughts') return i;
|
}, [timelineItems]);
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}, [timelineItems, startIndex]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute per-item forceCollapsed + onCollapseToggle based on:
|
* Compute the externally managed collapse state for an item in the timeline.
|
||||||
* - allCollapsed mode enabled/disabled
|
* In collapsed mode we always keep the newest real message open, keep the pinned
|
||||||
* - Whether this is the newest message (auto-expanded, no chevron)
|
* thought group open, and let localStorage overrides reopen older items.
|
||||||
* - 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 |
|
|
||||||
*/
|
*/
|
||||||
const getItemCollapseProps = useCallback(
|
const getItemCollapseState = useCallback(
|
||||||
(
|
(stableKey: string, itemIndex: number): ActivityCollapseState =>
|
||||||
stableKey: string,
|
resolveTimelineCollapseState({
|
||||||
itemIndex: number
|
allCollapsed,
|
||||||
): { forceCollapsed?: boolean; onCollapseToggle?: () => void } => {
|
itemIndex,
|
||||||
if (!allCollapsed) return {};
|
newestMessageIndex,
|
||||||
if (itemIndex === newestMessageIndex) return {};
|
isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null,
|
||||||
// Pinned thought group (index 0) is always the newest thought → expanded
|
isExpandedOverride: expandOverrides?.has(stableKey) ?? false,
|
||||||
if (itemIndex === 0 && pinnedThoughtGroup) return {};
|
onToggleOverride: onToggleExpandOverride
|
||||||
|
|
||||||
const isOverridden = expandOverrides?.has(stableKey) ?? false;
|
|
||||||
return {
|
|
||||||
forceCollapsed: !isOverridden,
|
|
||||||
onCollapseToggle: onToggleExpandOverride
|
|
||||||
? () => onToggleExpandOverride(stableKey)
|
? () => onToggleExpandOverride(stableKey)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
}),
|
||||||
},
|
|
||||||
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
|
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -361,7 +341,7 @@ export const ActivityTimeline = ({
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
||||||
const stableKey = toMessageKey(firstThought);
|
const stableKey = toMessageKey(firstThought);
|
||||||
const collapseProps = getItemCollapseProps(stableKey, 0);
|
const collapseState = getItemCollapseState(stableKey, 0);
|
||||||
return (
|
return (
|
||||||
<LeadThoughtsGroupRow
|
<LeadThoughtsGroupRow
|
||||||
key={itemKey}
|
key={itemKey}
|
||||||
|
|
@ -371,8 +351,7 @@ export const ActivityTimeline = ({
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(0)}
|
zebraShade={zebraShadeSet.has(0)}
|
||||||
forceCollapsed={collapseProps.forceCollapsed}
|
collapseState={collapseState}
|
||||||
onCollapseToggle={collapseProps.onCollapseToggle}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -406,7 +385,7 @@ export const ActivityTimeline = ({
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
||||||
const stableKey = toMessageKey(firstThought);
|
const stableKey = toMessageKey(firstThought);
|
||||||
const collapseProps = getItemCollapseProps(stableKey, realIndex);
|
const collapseState = getItemCollapseState(stableKey, realIndex);
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={itemKey}>
|
<React.Fragment key={itemKey}>
|
||||||
{sessionSeparator}
|
{sessionSeparator}
|
||||||
|
|
@ -417,8 +396,7 @@ export const ActivityTimeline = ({
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
zebraShade={zebraShadeSet.has(realIndex)}
|
zebraShade={zebraShadeSet.has(realIndex)}
|
||||||
forceCollapsed={collapseProps.forceCollapsed}
|
collapseState={collapseState}
|
||||||
onCollapseToggle={collapseProps.onCollapseToggle}
|
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
@ -431,7 +409,7 @@ export const ActivityTimeline = ({
|
||||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||||
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
||||||
const stableKey = toMessageKey(message);
|
const stableKey = toMessageKey(message);
|
||||||
const collapseProps = getItemCollapseProps(stableKey, realIndex);
|
const collapseState = getItemCollapseState(stableKey, realIndex);
|
||||||
const isUnread = readState
|
const isUnread = readState
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
|
|
@ -454,8 +432,7 @@ export const ActivityTimeline = ({
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onRestartTeam={onRestartTeam}
|
onRestartTeam={onRestartTeam}
|
||||||
forceCollapsed={collapseProps.forceCollapsed}
|
collapseState={collapseState}
|
||||||
onCollapseToggle={collapseProps.onCollapseToggle}
|
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
|
import { isManagedCollapseState } from './collapseState';
|
||||||
|
|
||||||
|
import type { ActivityCollapseState } from './collapseState';
|
||||||
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
||||||
|
|
||||||
export interface LeadThoughtGroup {
|
export interface LeadThoughtGroup {
|
||||||
|
|
@ -45,6 +48,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
||||||
const result: TimelineItem[] = [];
|
const result: TimelineItem[] = [];
|
||||||
let pendingThoughts: InboxMessage[] = [];
|
let pendingThoughts: InboxMessage[] = [];
|
||||||
let pendingIndices: number[] = [];
|
let pendingIndices: number[] = [];
|
||||||
|
const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean =>
|
||||||
|
(a.leadSessionId ?? null) === (b.leadSessionId ?? null);
|
||||||
|
|
||||||
const flushThoughts = (): void => {
|
const flushThoughts = (): void => {
|
||||||
if (pendingThoughts.length === 0) return;
|
if (pendingThoughts.length === 0) return;
|
||||||
|
|
@ -60,6 +65,10 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
if (isLeadThought(msg)) {
|
if (isLeadThought(msg)) {
|
||||||
|
const previousThought = pendingThoughts[pendingThoughts.length - 1];
|
||||||
|
if (previousThought && !hasSameLeadSession(previousThought, msg)) {
|
||||||
|
flushThoughts();
|
||||||
|
}
|
||||||
pendingThoughts.push(msg);
|
pendingThoughts.push(msg);
|
||||||
pendingIndices.push(i);
|
pendingIndices.push(i);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -86,10 +95,8 @@ interface LeadThoughtsGroupRowProps {
|
||||||
canBeLive?: boolean;
|
canBeLive?: boolean;
|
||||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||||
zebraShade?: boolean;
|
zebraShade?: boolean;
|
||||||
/** When true, collapse the thought body — show only the header with expand chevron. */
|
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||||
forceCollapsed?: boolean;
|
collapseState?: ActivityCollapseState;
|
||||||
/** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */
|
|
||||||
onCollapseToggle?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: string): string {
|
function formatTime(timestamp: string): string {
|
||||||
|
|
@ -347,13 +354,14 @@ export const LeadThoughtsGroupRow = ({
|
||||||
onVisible,
|
onVisible,
|
||||||
canBeLive,
|
canBeLive,
|
||||||
zebraShade,
|
zebraShade,
|
||||||
forceCollapsed,
|
collapseState,
|
||||||
onCollapseToggle,
|
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const isUserScrolledUpRef = useRef(false);
|
const isUserScrolledUpRef = useRef(false);
|
||||||
|
const distanceFromBottomRef = useRef(0);
|
||||||
|
const scrollSyncFrameRef = useRef<number | null>(null);
|
||||||
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||||
const leadActivity = useStore((s) => {
|
const leadActivity = useStore((s) => {
|
||||||
const teamName = s.selectedTeamName;
|
const teamName = s.selectedTeamName;
|
||||||
|
|
@ -412,17 +420,14 @@ export const LeadThoughtsGroupRow = ({
|
||||||
const [isLive, setIsLive] = useState(computeIsLive);
|
const [isLive, setIsLive] = useState(computeIsLive);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [needsTruncation, setNeedsTruncation] = useState(false);
|
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||||
const [isBodyVisible, setIsBodyVisible] = useState(!forceCollapsed);
|
const isManaged = isManagedCollapseState(collapseState);
|
||||||
|
const isBodyVisible = isManaged ? !collapseState.isCollapsed : true;
|
||||||
// Sync body visibility when the global collapse mode toggles (skip initial mount)
|
const canToggleBodyVisibility = isManaged && collapseState.canToggle;
|
||||||
const isFirstRenderRef = useRef(false);
|
const handleBodyToggle = canToggleBodyVisibility
|
||||||
useEffect(() => {
|
? (): void => {
|
||||||
if (!isFirstRenderRef.current) {
|
collapseState.onToggle?.();
|
||||||
isFirstRenderRef.current = true;
|
}
|
||||||
return;
|
: undefined;
|
||||||
}
|
|
||||||
setIsBodyVisible(!forceCollapsed);
|
|
||||||
}, [forceCollapsed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
|
// 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();
|
return () => observer.disconnect();
|
||||||
}, [onVisible, thoughts]);
|
}, [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(
|
const syncScrollableBody = useCallback(
|
||||||
(forceScrollToBottom = false) => {
|
(forceScrollToBottom = false) => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
|
|
@ -463,14 +503,26 @@ export const LeadThoughtsGroupRow = ({
|
||||||
const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
|
const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
|
||||||
setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));
|
setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));
|
||||||
|
|
||||||
if (expanded) return;
|
if (expanded || !isBodyVisible) return;
|
||||||
if (!forceScrollToBottom && isUserScrolledUpRef.current) return;
|
if (!nextNeedsTruncation) {
|
||||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
clearPendingScrollSync();
|
||||||
|
distanceFromBottomRef.current = 0;
|
||||||
|
isUserScrolledUpRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceScrollToBottom || !isUserScrolledUpRef.current) {
|
||||||
|
queueScrollSync('bottom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueScrollSync('preserve');
|
||||||
},
|
},
|
||||||
[expanded]
|
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isBodyVisible) return;
|
||||||
const contentEl = contentRef.current;
|
const contentEl = contentRef.current;
|
||||||
if (!contentEl) return;
|
if (!contentEl) return;
|
||||||
|
|
||||||
|
|
@ -482,18 +534,32 @@ export const LeadThoughtsGroupRow = ({
|
||||||
observer.observe(contentEl);
|
observer.observe(contentEl);
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [syncScrollableBody]);
|
}, [isBodyVisible, syncScrollableBody]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clearPendingScrollSync();
|
||||||
|
},
|
||||||
|
[clearPendingScrollSync]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBodyVisible) return;
|
||||||
|
clearPendingScrollSync();
|
||||||
|
}, [clearPendingScrollSync, isBodyVisible]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (expanded) return;
|
if (expanded) return;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
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;
|
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
|
||||||
}, [expanded]);
|
}, [expanded]);
|
||||||
|
|
||||||
const handleCollapse = useCallback(() => {
|
const handleCollapse = useCallback(() => {
|
||||||
isUserScrolledUpRef.current = false;
|
isUserScrolledUpRef.current = false;
|
||||||
|
distanceFromBottomRef.current = 0;
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -524,34 +590,26 @@ export const LeadThoughtsGroupRow = ({
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
|
||||||
<div
|
<div
|
||||||
role={forceCollapsed === true || onCollapseToggle != null ? 'button' : undefined}
|
role={canToggleBodyVisibility ? 'button' : undefined}
|
||||||
tabIndex={forceCollapsed === true || onCollapseToggle != null ? 0 : undefined}
|
tabIndex={canToggleBodyVisibility ? 0 : undefined}
|
||||||
className={[
|
className={[
|
||||||
'flex select-none items-center gap-2 px-3 py-1.5',
|
'flex select-none items-center gap-2 px-3 py-1.5',
|
||||||
forceCollapsed === true || onCollapseToggle != null ? 'cursor-pointer' : '',
|
canToggleBodyVisibility ? 'cursor-pointer' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={
|
onClick={handleBodyToggle}
|
||||||
forceCollapsed === true || onCollapseToggle != null
|
|
||||||
? () => {
|
|
||||||
setIsBodyVisible((v) => !v);
|
|
||||||
onCollapseToggle?.();
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
forceCollapsed === true || onCollapseToggle != null
|
canToggleBodyVisibility
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsBodyVisible((v) => !v);
|
handleBodyToggle?.();
|
||||||
onCollapseToggle?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Chevron for collapse mode */}
|
{/* Chevron for collapse mode */}
|
||||||
{forceCollapsed === true || onCollapseToggle != null ? (
|
{canToggleBodyVisibility ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className="size-3 shrink-0 transition-transform duration-150"
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -608,7 +666,6 @@ export const LeadThoughtsGroupRow = ({
|
||||||
scrollbarColor:
|
scrollbarColor:
|
||||||
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
||||||
overflowAnchor: 'none',
|
overflowAnchor: 'none',
|
||||||
overscrollBehavior: 'contain',
|
|
||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
|
|
@ -626,10 +683,13 @@ export const LeadThoughtsGroupRow = ({
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
{isBodyVisible && !expanded && needsTruncation ? (
|
{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
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
|
|
@ -642,12 +702,12 @@ export const LeadThoughtsGroupRow = ({
|
||||||
) : null}
|
) : null}
|
||||||
{isBodyVisible && expanded && needsTruncation ? (
|
{isBodyVisible && expanded && needsTruncation ? (
|
||||||
<div
|
<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)' }}
|
style={{ transform: 'translateY(-20px)' }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCollapse();
|
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={{
|
styles={{
|
||||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
||||||
|
button: { padding: 16 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -837,43 +837,6 @@ export const CreateTeamDialog = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -934,36 +897,79 @@ export const CreateTeamDialog = ({
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="pt-4 sm:justify-between">
|
||||||
{canOpenExistingTeam ? (
|
<div className="min-w-0">
|
||||||
<Button
|
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||||
variant="outline"
|
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
size="sm"
|
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
onClick={() => {
|
<span>
|
||||||
onOpenTeam(request.teamName);
|
{prepareMessage ??
|
||||||
onClose();
|
(prepareState === 'idle'
|
||||||
}}
|
? 'Warming up CLI environment...'
|
||||||
>
|
: 'Preparing environment...')}
|
||||||
Open Existing Team
|
</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>
|
</Button>
|
||||||
) : null}
|
<Button
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
size="sm"
|
||||||
Close
|
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
||||||
</Button>
|
onClick={handleSubmit}
|
||||||
<Button
|
>
|
||||||
size="sm"
|
{isSubmitting ? (
|
||||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
<>
|
||||||
onClick={handleSubmit}
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
>
|
Creating...
|
||||||
{isSubmitting ? (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
'Create'
|
||||||
Creating...
|
)}
|
||||||
</>
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
'Create'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
|
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { Brain } from 'lucide-react';
|
||||||
|
|
||||||
const EFFORT_OPTIONS = [
|
const EFFORT_OPTIONS = [
|
||||||
{ value: '', label: 'Default' },
|
{ value: '', label: 'Default' },
|
||||||
|
|
@ -25,23 +26,26 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||||
Effort level (optional)
|
Effort level (optional)
|
||||||
</Label>
|
</Label>
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
<div className="flex items-center gap-2">
|
||||||
{EFFORT_OPTIONS.map((opt) => (
|
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<button
|
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||||
key={opt.value || '__default__'}
|
{EFFORT_OPTIONS.map((opt) => (
|
||||||
type="button"
|
<button
|
||||||
id={opt.value === value ? id : undefined}
|
key={opt.value || '__default__'}
|
||||||
className={cn(
|
type="button"
|
||||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
id={opt.value === value ? id : undefined}
|
||||||
value === opt.value
|
className={cn(
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
value === opt.value
|
||||||
)}
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||||
onClick={() => onValueChange(opt.value)}
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
>
|
)}
|
||||||
{opt.label}
|
onClick={() => onValueChange(opt.value)}
|
||||||
</button>
|
>
|
||||||
))}
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||||
Controls how much reasoning Claude invests before responding. Default uses Claude's
|
Controls how much reasoning Claude invests before responding. Default uses Claude's
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={id}
|
id={id}
|
||||||
checked={checked && !disabled}
|
checked={checked && !disabled}
|
||||||
|
|
|
||||||
|
|
@ -506,62 +506,70 @@ export const LaunchTeamDialog = ({
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
<DialogFooter className="pt-4 sm:justify-between">
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
<div className="min-w-0">
|
||||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||||
<span>
|
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{prepareMessage ??
|
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
(prepareState === 'idle'
|
<span>
|
||||||
? 'Warming up CLI environment...'
|
{prepareMessage ??
|
||||||
: 'Preparing environment...')}
|
(prepareState === 'idle'
|
||||||
</span>
|
? 'Warming up CLI environment...'
|
||||||
</div>
|
: 'Preparing environment...')}
|
||||||
) : null}
|
</span>
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
{prepareState === 'ready' ? (
|
||||||
<Button variant="outline" size="sm" onClick={onClose}>
|
<div>
|
||||||
Close
|
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||||
</Button>
|
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||||
<Button
|
<span>
|
||||||
size="sm"
|
{prepareWarnings.length > 0
|
||||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
? 'CLI environment ready (with notes)'
|
||||||
disabled={isSubmitting || prepareState !== 'ready'}
|
: 'CLI environment ready'}
|
||||||
onClick={handleSubmit}
|
</span>
|
||||||
>
|
</div>
|
||||||
{isSubmitting ? (
|
{prepareMessage ? (
|
||||||
<>
|
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
{prepareMessage}
|
||||||
Launching...
|
</p>
|
||||||
</>
|
) : null}
|
||||||
) : (
|
{prepareWarnings.length > 0 ? (
|
||||||
'Launch'
|
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||||
)}
|
{prepareWarnings.map((warning) => (
|
||||||
</Button>
|
<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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Check } from 'lucide-react';
|
import { Check, FolderOpen } from 'lucide-react';
|
||||||
|
|
||||||
import type { Project } from '@shared/types';
|
import type { Project } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -102,34 +102,40 @@ export const ProjectPathSelector = ({
|
||||||
|
|
||||||
{cwdMode === 'project' ? (
|
{cwdMode === 'project' ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Combobox
|
<div className="flex items-center gap-2">
|
||||||
options={projects.map((project) => ({
|
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
value: project.path,
|
<Combobox
|
||||||
label: project.name,
|
options={projects.map((project) => ({
|
||||||
description: project.path,
|
value: project.path,
|
||||||
}))}
|
label: project.name,
|
||||||
value={selectedProjectPath}
|
description: project.path,
|
||||||
onValueChange={onSelectedProjectPathChange}
|
}))}
|
||||||
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
|
value={selectedProjectPath}
|
||||||
searchPlaceholder="Search project by name or path"
|
onValueChange={onSelectedProjectPathChange}
|
||||||
emptyMessage="Nothing found"
|
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
|
||||||
disabled={projectsLoading || projects.length === 0}
|
searchPlaceholder="Search project by name or path"
|
||||||
renderOption={(option, isSelected, query) => (
|
emptyMessage="Nothing found"
|
||||||
<>
|
disabled={projectsLoading || projects.length === 0}
|
||||||
<Check
|
renderOption={(option, isSelected, query) => (
|
||||||
className={cn('mr-2 size-3.5 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}
|
<>
|
||||||
/>
|
<Check
|
||||||
<div className="min-w-0 flex-1">
|
className={cn(
|
||||||
<p className="truncate font-medium text-[var(--color-text)]">
|
'mr-2 size-3.5 shrink-0',
|
||||||
{renderHighlightedText(option.label, query)}
|
isSelected ? 'opacity-100' : 'opacity-0'
|
||||||
</p>
|
)}
|
||||||
<p className="truncate text-[var(--color-text-muted)]">
|
/>
|
||||||
{renderHighlightedText(option.description ?? '', query)}
|
<div className="min-w-0 flex-1">
|
||||||
</p>
|
<p className="truncate font-medium text-[var(--color-text)]">
|
||||||
</div>
|
{renderHighlightedText(option.label, query)}
|
||||||
</>
|
</p>
|
||||||
)}
|
<p className="truncate text-[var(--color-text-muted)]">
|
||||||
/>
|
{renderHighlightedText(option.description ?? '', query)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{!selectedProjectPath ? (
|
{!selectedProjectPath ? (
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
Select a project from the list
|
Select a project from the list
|
||||||
|
|
@ -137,12 +143,15 @@ export const ProjectPathSelector = ({
|
||||||
) : null}
|
) : null}
|
||||||
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
|
||||||
{!projectsLoading && projects.length === 0 ? (
|
{!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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
value={customCwd}
|
value={customCwd}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ interface QuotedMessage {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_MESSAGE_LENGTH = 4000;
|
const MAX_MESSAGE_LENGTH = 50_000;
|
||||||
|
|
||||||
interface SendMessageDialogProps {
|
interface SendMessageDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ export const SkipPermissionsCheckbox: React.FC<SkipPermissionsCheckboxProps> = (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
<p>
|
<p>
|
||||||
Autonomous mode — all tools execute without confirmation. Be cautious with untrusted
|
Unleash Claude's full power — no interruptions asking for permission. Autonomous
|
||||||
code.
|
mode — all tools execute without confirmation. Be cautious with untrusted code.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
|
@ -10,7 +11,6 @@ import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
|
|
@ -35,7 +35,6 @@ import {
|
||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
FileCode,
|
|
||||||
FileDiff,
|
FileDiff,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
|
@ -589,7 +588,10 @@ export const TaskDetailDialog = ({
|
||||||
key={file.filePath}
|
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)]"
|
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
|
<button
|
||||||
type="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)]"
|
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"
|
containerClassName="-mx-6"
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<DialogFooter className="flex items-center justify-end sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={handleClose}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ interface MemberLogsTabProps {
|
||||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PREVIEW_PAGE_SIZE = 4;
|
||||||
|
|
||||||
export const MemberLogsTab = ({
|
export const MemberLogsTab = ({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -78,6 +80,7 @@ export const MemberLogsTab = ({
|
||||||
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||||
|
const [previewVisibleCount, setPreviewVisibleCount] = useState(PREVIEW_PAGE_SIZE);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -189,11 +192,17 @@ export const MemberLogsTab = ({
|
||||||
return null;
|
return null;
|
||||||
}, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]);
|
}, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]);
|
||||||
|
|
||||||
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
|
const allPreviewMessages = useMemo((): SubagentPreviewMessage[] => {
|
||||||
if (!previewChunks || previewChunks.length === 0) return [];
|
if (!previewChunks || previewChunks.length === 0) return [];
|
||||||
return extractSubagentPreviewMessages(previewChunks, 4);
|
return extractSubagentPreviewMessages(previewChunks);
|
||||||
}, [previewChunks]);
|
}, [previewChunks]);
|
||||||
|
|
||||||
|
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
|
||||||
|
return allPreviewMessages.slice(0, previewVisibleCount);
|
||||||
|
}, [allPreviewMessages, previewVisibleCount]);
|
||||||
|
|
||||||
|
const previewHasMore = allPreviewMessages.length > previewVisibleCount;
|
||||||
|
|
||||||
const previewOnline = useMemo((): boolean => {
|
const previewOnline = useMemo((): boolean => {
|
||||||
const newest = previewMessages[0];
|
const newest = previewMessages[0];
|
||||||
if (!newest) return false;
|
if (!newest) return false;
|
||||||
|
|
@ -214,6 +223,20 @@ export const MemberLogsTab = ({
|
||||||
onPreviewOnlineChange?.(previewOnline);
|
onPreviewOnlineChange?.(previewOnline);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => onPreviewOnlineChange?.(false);
|
return () => onPreviewOnlineChange?.(false);
|
||||||
}, [onPreviewOnlineChange]);
|
}, [onPreviewOnlineChange]);
|
||||||
|
|
@ -493,6 +516,8 @@ export const MemberLogsTab = ({
|
||||||
<SubagentRecentMessagesPreview
|
<SubagentRecentMessagesPreview
|
||||||
messages={previewMessages}
|
messages={previewMessages}
|
||||||
memberName={previewLog.memberName ?? undefined}
|
memberName={previewLog.memberName ?? undefined}
|
||||||
|
hasMore={previewHasMore}
|
||||||
|
onLoadMore={() => setPreviewVisibleCount((prev) => prev + PREVIEW_PAGE_SIZE)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{sortedLogs.map((log) => (
|
{sortedLogs.map((log) => (
|
||||||
|
|
@ -605,21 +630,18 @@ function formatRelativeTime(isoString: string): string {
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSubagentPreviewMessages(
|
function extractSubagentPreviewMessages(chunks: EnhancedChunk[]): SubagentPreviewMessage[] {
|
||||||
chunks: EnhancedChunk[],
|
|
||||||
limit: number
|
|
||||||
): SubagentPreviewMessage[] {
|
|
||||||
const conversation = transformChunksToConversation(chunks, [], false);
|
const conversation = transformChunksToConversation(chunks, [], false);
|
||||||
|
|
||||||
const out: SubagentPreviewMessage[] = [];
|
const out: SubagentPreviewMessage[] = [];
|
||||||
|
|
||||||
// Collect newest-first and stop as soon as we have enough.
|
// Collect newest-first.
|
||||||
for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) {
|
for (let i = conversation.items.length - 1; i >= 0; i--) {
|
||||||
const item = conversation.items[i];
|
const item = conversation.items[i];
|
||||||
if (item.type === 'ai') {
|
if (item.type === 'ai') {
|
||||||
const enhanced = enhanceAIGroup(item.group);
|
const enhanced = enhanceAIGroup(item.group);
|
||||||
const items = enhanced.displayItems ?? [];
|
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];
|
const di = items[j];
|
||||||
if (di.type === 'output' && di.content.trim()) {
|
if (di.type === 'output' && di.content.trim()) {
|
||||||
out.push({
|
out.push({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
export type SubagentPreviewMessageKind =
|
export type SubagentPreviewMessageKind =
|
||||||
|
|
@ -23,57 +26,85 @@ export interface SubagentPreviewMessage {
|
||||||
interface SubagentRecentMessagesPreviewProps {
|
interface SubagentRecentMessagesPreviewProps {
|
||||||
messages: SubagentPreviewMessage[];
|
messages: SubagentPreviewMessage[];
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
|
hasMore?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubagentRecentMessagesPreview = ({
|
export const SubagentRecentMessagesPreview = ({
|
||||||
messages,
|
messages,
|
||||||
memberName,
|
memberName,
|
||||||
|
hasMore = false,
|
||||||
|
onLoadMore,
|
||||||
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
|
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
|
||||||
|
const [expandedAll, setExpandedAll] = useState(false);
|
||||||
|
|
||||||
if (!messages.length) return null;
|
if (!messages.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
<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)]">
|
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
|
||||||
Latest messages{memberName ? ` — ${memberName}` : ''}
|
Latest messages{memberName ? ` — ${memberName}` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
{format(messages[0].timestamp, 'h:mm:ss a')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className={`${expandedAll ? 'max-h-none' : 'max-h-[200px]'} overflow-y-auto pr-1`}>
|
||||||
{messages.map((m) => (
|
{messages.map((m, index) => (
|
||||||
<div
|
<div key={m.id} className="py-1.5">
|
||||||
key={m.id}
|
<div className="flex items-start gap-2">
|
||||||
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"
|
<div className="min-w-0 flex-1 text-xs text-[var(--color-text)]">
|
||||||
>
|
<MarkdownViewer
|
||||||
<div className="mb-1 flex items-center justify-between gap-2">
|
content={m.content}
|
||||||
<div className="min-w-0 truncate text-[10px] text-[var(--color-text-muted)]">
|
bare
|
||||||
{m.label ? (
|
maxHeight="max-h-none"
|
||||||
<span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
|
className="[&>div>div]:p-0 [&_ol]:my-1 [&_p]:my-1 [&_ul]:my-1"
|
||||||
{m.label}
|
/>
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono">{m.kind}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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')}
|
{format(m.timestamp, 'h:mm:ss a')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.kind === 'tool_result' ? (
|
{index < messages.length - 1 ? (
|
||||||
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--color-text)]">
|
<hr className="mt-2 border-[var(--color-border)]" />
|
||||||
{m.content}
|
) : null}
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-40 overflow-y-auto text-xs text-[var(--color-text)]">
|
|
||||||
<MarkdownViewer content={m.content} copyable />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -33,7 +31,7 @@ interface MessageComposerProps {
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_MESSAGE_LENGTH = 4000;
|
const MAX_MESSAGE_LENGTH = 50_000;
|
||||||
|
|
||||||
/** Circular progress indicator for lead context usage. */
|
/** Circular progress indicator for lead context usage. */
|
||||||
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
||||||
|
|
@ -119,19 +117,7 @@ export const MessageComposer = ({
|
||||||
}, [members, recipient]);
|
}, [members, recipient]);
|
||||||
|
|
||||||
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
|
||||||
const draft = useDraftPersistence({ key: `compose:${teamName}` });
|
const draft = useComposerDraft(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 colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
|
|
@ -146,7 +132,7 @@ export const MessageComposer = ({
|
||||||
[members, colorMap]
|
[members, colorMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const trimmed = draft.value.trim();
|
const trimmed = draft.text.trim();
|
||||||
|
|
||||||
const selectedMember = members.find((m) => m.name === recipient);
|
const selectedMember = members.find((m) => m.name === recipient);
|
||||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||||
|
|
@ -157,8 +143,8 @@ export const MessageComposer = ({
|
||||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const supportsAttachments = isLeadRecipient;
|
const supportsAttachments = isLeadRecipient;
|
||||||
const canAttach = supportsAttachments && canAddMore;
|
const canAttach = supportsAttachments && draft.canAddMore;
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
|
||||||
const canSend =
|
const canSend =
|
||||||
recipient.length > 0 &&
|
recipient.length > 0 &&
|
||||||
trimmed.length > 0 &&
|
trimmed.length > 0 &&
|
||||||
|
|
@ -172,10 +158,15 @@ export const MessageComposer = ({
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
pendingSendRef.current = true;
|
pendingSendRef.current = true;
|
||||||
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
|
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
||||||
// Summary should stay compact (no expanded chip markdown)
|
// Summary should stay compact (no expanded chip markdown)
|
||||||
onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined);
|
onSend(
|
||||||
}, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]);
|
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)
|
// Clear draft only after send completes successfully (sending: true → false, no error)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -183,12 +174,9 @@ export const MessageComposer = ({
|
||||||
pendingSendRef.current = false;
|
pendingSendRef.current = false;
|
||||||
if (!sendError) {
|
if (!sendError) {
|
||||||
draft.clearDraft();
|
draft.clearDraft();
|
||||||
chipDraft.clearChipDraft();
|
|
||||||
clearAttachments();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with [])
|
}, [sending, sendError, draft]);
|
||||||
}, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]);
|
|
||||||
|
|
||||||
const handleKeyDownCapture = useCallback(
|
const handleKeyDownCapture = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
|
@ -205,11 +193,11 @@ export const MessageComposer = ({
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const input = e.target;
|
const input = e.target;
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
void addFiles(input.files);
|
void draft.addFiles(input.files);
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
},
|
},
|
||||||
[addFiles]
|
[draft.addFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
|
@ -235,16 +223,16 @@ export const MessageComposer = ({
|
||||||
(e: React.DragEvent) => {
|
(e: React.DragEvent) => {
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
if (canAttach) handleDrop(e);
|
if (canAttach) draft.handleDrop(e);
|
||||||
},
|
},
|
||||||
[canAttach, handleDrop]
|
[canAttach, draft.handleDrop]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePasteWrapper = useCallback(
|
const handlePasteWrapper = useCallback(
|
||||||
(e: React.ClipboardEvent) => {
|
(e: React.ClipboardEvent) => {
|
||||||
if (canAttach) handlePaste(e);
|
if (canAttach) draft.handlePaste(e);
|
||||||
},
|
},
|
||||||
[canAttach, handlePaste]
|
[canAttach, draft.handlePaste]
|
||||||
);
|
);
|
||||||
|
|
||||||
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
|
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
|
||||||
|
|
@ -292,7 +280,7 @@ export const MessageComposer = ({
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{!isTeamAlive
|
{!isTeamAlive
|
||||||
? 'Team must be online to attach images'
|
? 'Team must be online to attach images'
|
||||||
: !canAddMore
|
: !draft.canAddMore
|
||||||
? 'Maximum attachments reached'
|
? 'Maximum attachments reached'
|
||||||
: 'Attach images (paste or drag & drop)'}
|
: 'Attach images (paste or drag & drop)'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|
@ -408,10 +396,10 @@ export const MessageComposer = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AttachmentPreviewList
|
<AttachmentPreviewList
|
||||||
attachments={attachments}
|
attachments={draft.attachments}
|
||||||
onRemove={removeAttachment}
|
onRemove={draft.removeAttachment}
|
||||||
error={attachmentError}
|
error={draft.attachmentError}
|
||||||
onDismissError={clearAttachmentError}
|
onDismissError={draft.clearAttachmentError}
|
||||||
disabled={attachmentsBlocked}
|
disabled={attachmentsBlocked}
|
||||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
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
|
<MentionableTextarea
|
||||||
id={`compose-${teamName}`}
|
id={`compose-${teamName}`}
|
||||||
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
||||||
value={draft.value}
|
value={draft.text}
|
||||||
onValueChange={draft.setValue}
|
onValueChange={draft.setText}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
chips={chipDraft.chips}
|
chips={draft.chips}
|
||||||
onChipRemove={chipDraft.removeChip}
|
onChipRemove={draft.removeChip}
|
||||||
projectPath={projectPath}
|
projectPath={projectPath}
|
||||||
onFileChipInsert={chipDraft.addChip}
|
onFileChipInsert={draft.addChip}
|
||||||
onModEnter={handleSend}
|
onModEnter={handleSend}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={6}
|
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%);
|
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
|
||||||
background-color: #ffffff;
|
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