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:
iliya 2026-03-06 23:21:56 +02:00
parent c09ab76d43
commit 9bfcbb182c
23 changed files with 1864 additions and 385 deletions

View file

@ -309,7 +309,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
showNoise: false,
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(false);
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
// Open editor overlay when a file reveal is requested (e.g. from chip click)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
@ -26,8 +26,10 @@ import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react';
import { isManagedCollapseState } from './collapseState';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
import type { ActivityCollapseState } from './collapseState';
import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { InboxMessage } from '@shared/types';
@ -52,10 +54,8 @@ interface ActivityItemProps {
onRestartTeam?: () => void;
/** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean;
/** When true, collapse message body — show only header with expand chevron. */
forceCollapsed?: boolean;
/** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */
onCollapseToggle?: () => void;
/** Explicit collapse state for timeline-controlled collapsed mode. */
collapseState?: ActivityCollapseState;
}
function getStringField(obj: StructuredMessage, key: string): string | null {
@ -217,8 +217,7 @@ export const ActivityItem = ({
onTaskIdClick,
onRestartTeam,
zebraShade,
forceCollapsed,
onCollapseToggle,
collapseState,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const formattedRole = formatAgentRole(memberRole);
@ -237,19 +236,9 @@ export const ActivityItem = ({
// Never collapse rate limit messages as noise — they must be visible
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
// System/automated messages start collapsed (but not rate limits)
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel && !forceCollapsed);
// Sync expand/collapse when the global collapse mode toggles (skip initial mount)
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setIsExpanded(forceCollapsed ? false : !systemLabel);
}, [forceCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps -- systemLabel is stable (derived from message.text)
const isManaged = isManagedCollapseState(collapseState);
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
// Strip agent-only blocks + normalize escape sequences (before linkification)
const strippedText = useMemo(() => {
@ -298,11 +287,16 @@ export const ActivityItem = ({
onCreateTask?.(subject, description);
};
const isHeaderClickable =
Boolean(systemLabel) || forceCollapsed === true || onCollapseToggle != null;
const isHeaderClickable = isManaged ? collapseState.canToggle : false;
const showChevron = isHeaderClickable;
const isUserSent = message.source === 'user_sent';
const isSystemMessage = message.from === 'system';
const onManagedToggle = isManaged ? collapseState.onToggle : undefined;
const handleHeaderToggle = isHeaderClickable
? (): void => {
onManagedToggle?.();
}
: undefined;
return (
<article
@ -340,21 +334,13 @@ export const ActivityItem = ({
'flex items-center gap-2 px-3 py-2',
isHeaderClickable ? 'cursor-pointer select-none' : '',
].join(' ')}
onClick={
isHeaderClickable
? () => {
setIsExpanded((v) => !v);
onCollapseToggle?.();
}
: undefined
}
onClick={handleHeaderToggle}
onKeyDown={
isHeaderClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded((v) => !v);
onCollapseToggle?.();
handleHeaderToggle?.();
}
}
: undefined

View file

@ -4,9 +4,11 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
import type { TimelineItem } from './LeadThoughtsGroup';
import type { ActivityCollapseState } from './collapseState';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps {
@ -54,8 +56,7 @@ const MessageRowWithObserver = ({
onVisible,
onTaskIdClick,
onRestartTeam,
forceCollapsed,
onCollapseToggle,
collapseState,
}: {
message: InboxMessage;
teamName: string;
@ -72,8 +73,7 @@ const MessageRowWithObserver = ({
onVisible?: (message: InboxMessage) => void;
onTaskIdClick?: (taskId: string) => void;
onRestartTeam?: () => void;
forceCollapsed?: boolean;
onCollapseToggle?: () => void;
collapseState?: ActivityCollapseState;
}): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false);
@ -121,8 +121,7 @@ const MessageRowWithObserver = ({
onReply={onReply}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
forceCollapsed={forceCollapsed}
onCollapseToggle={onCollapseToggle}
collapseState={collapseState}
/>
</div>
);
@ -308,46 +307,27 @@ export const ActivityTimeline = ({
const startIndex = pinnedThoughtGroup ? 1 : 0;
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
// Pinned thought group is always at index 0 when present, so newest message is the
// first non-thought item in the remaining list.
const newestMessageIndex = useMemo(() => {
for (let i = startIndex; i < timelineItems.length; i++) {
if (timelineItems[i].type !== 'lead-thoughts') return i;
}
return -1;
}, [timelineItems, startIndex]);
return findNewestMessageIndex(timelineItems);
}, [timelineItems]);
/**
* Compute per-item forceCollapsed + onCollapseToggle based on:
* - allCollapsed mode enabled/disabled
* - Whether this is the newest message (auto-expanded, no chevron)
* - Whether user has manually expanded this item (override in localStorage)
*
* | allCollapsed | isNewest | inOverrides | forceCollapsed | onCollapseToggle |
* |-------------|----------|-------------|----------------|------------------|
* | false | any | any | undefined | undefined |
* | true | yes | any | undefined | undefined |
* | true | no | yes | false | fn |
* | true | no | no | true | fn |
* Compute the externally managed collapse state for an item in the timeline.
* In collapsed mode we always keep the newest real message open, keep the pinned
* thought group open, and let localStorage overrides reopen older items.
*/
const getItemCollapseProps = useCallback(
(
stableKey: string,
itemIndex: number
): { forceCollapsed?: boolean; onCollapseToggle?: () => void } => {
if (!allCollapsed) return {};
if (itemIndex === newestMessageIndex) return {};
// Pinned thought group (index 0) is always the newest thought → expanded
if (itemIndex === 0 && pinnedThoughtGroup) return {};
const isOverridden = expandOverrides?.has(stableKey) ?? false;
return {
forceCollapsed: !isOverridden,
onCollapseToggle: onToggleExpandOverride
const getItemCollapseState = useCallback(
(stableKey: string, itemIndex: number): ActivityCollapseState =>
resolveTimelineCollapseState({
allCollapsed,
itemIndex,
newestMessageIndex,
isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null,
isExpandedOverride: expandOverrides?.has(stableKey) ?? false,
onToggleOverride: onToggleExpandOverride
? () => onToggleExpandOverride(stableKey)
: undefined,
};
},
}),
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
);
@ -361,7 +341,7 @@ export const ActivityTimeline = ({
const info = memberInfo.get(firstThought.from);
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
const stableKey = toMessageKey(firstThought);
const collapseProps = getItemCollapseProps(stableKey, 0);
const collapseState = getItemCollapseState(stableKey, 0);
return (
<LeadThoughtsGroupRow
key={itemKey}
@ -371,8 +351,7 @@ export const ActivityTimeline = ({
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(0)}
forceCollapsed={collapseProps.forceCollapsed}
onCollapseToggle={collapseProps.onCollapseToggle}
collapseState={collapseState}
/>
);
})()}
@ -406,7 +385,7 @@ export const ActivityTimeline = ({
const info = memberInfo.get(firstThought.from);
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
const stableKey = toMessageKey(firstThought);
const collapseProps = getItemCollapseProps(stableKey, realIndex);
const collapseState = getItemCollapseState(stableKey, realIndex);
return (
<React.Fragment key={itemKey}>
{sessionSeparator}
@ -417,8 +396,7 @@ export const ActivityTimeline = ({
isNew={newItemKeys.has(itemKey)}
onVisible={onMessageVisible}
zebraShade={zebraShadeSet.has(realIndex)}
forceCollapsed={collapseProps.forceCollapsed}
onCollapseToggle={collapseProps.onCollapseToggle}
collapseState={collapseState}
/>
</React.Fragment>
);
@ -431,7 +409,7 @@ export const ActivityTimeline = ({
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
const stableKey = toMessageKey(message);
const collapseProps = getItemCollapseProps(stableKey, realIndex);
const collapseState = getItemCollapseState(stableKey, realIndex);
const isUnread = readState
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
: !message.read;
@ -454,8 +432,7 @@ export const ActivityTimeline = ({
onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
forceCollapsed={collapseProps.forceCollapsed}
onCollapseToggle={collapseProps.onCollapseToggle}
collapseState={collapseState}
/>
</React.Fragment>
);

View file

@ -16,6 +16,9 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { isManagedCollapseState } from './collapseState';
import type { ActivityCollapseState } from './collapseState';
import type { InboxMessage, ToolCallMeta } from '@shared/types';
export interface LeadThoughtGroup {
@ -45,6 +48,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
const result: TimelineItem[] = [];
let pendingThoughts: InboxMessage[] = [];
let pendingIndices: number[] = [];
const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean =>
(a.leadSessionId ?? null) === (b.leadSessionId ?? null);
const flushThoughts = (): void => {
if (pendingThoughts.length === 0) return;
@ -60,6 +65,10 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (isLeadThought(msg)) {
const previousThought = pendingThoughts[pendingThoughts.length - 1];
if (previousThought && !hasSameLeadSession(previousThought, msg)) {
flushThoughts();
}
pendingThoughts.push(msg);
pendingIndices.push(i);
} else {
@ -86,10 +95,8 @@ interface LeadThoughtsGroupRowProps {
canBeLive?: boolean;
/** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean;
/** When true, collapse the thought body — show only the header with expand chevron. */
forceCollapsed?: boolean;
/** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */
onCollapseToggle?: () => void;
/** Explicit collapse state for timeline-controlled collapsed mode. */
collapseState?: ActivityCollapseState;
}
function formatTime(timestamp: string): string {
@ -347,13 +354,14 @@ export const LeadThoughtsGroupRow = ({
onVisible,
canBeLive,
zebraShade,
forceCollapsed,
onCollapseToggle,
collapseState,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const isUserScrolledUpRef = useRef(false);
const distanceFromBottomRef = useRef(0);
const scrollSyncFrameRef = useRef<number | null>(null);
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const leadActivity = useStore((s) => {
const teamName = s.selectedTeamName;
@ -412,17 +420,14 @@ export const LeadThoughtsGroupRow = ({
const [isLive, setIsLive] = useState(computeIsLive);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
const [isBodyVisible, setIsBodyVisible] = useState(!forceCollapsed);
// Sync body visibility when the global collapse mode toggles (skip initial mount)
const isFirstRenderRef = useRef(false);
useEffect(() => {
if (!isFirstRenderRef.current) {
isFirstRenderRef.current = true;
return;
}
setIsBodyVisible(!forceCollapsed);
}, [forceCollapsed]);
const isManaged = isManagedCollapseState(collapseState);
const isBodyVisible = isManaged ? !collapseState.isCollapsed : true;
const canToggleBodyVisibility = isManaged && collapseState.canToggle;
const handleBodyToggle = canToggleBodyVisibility
? (): void => {
collapseState.onToggle?.();
}
: undefined;
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
@ -454,6 +459,41 @@ export const LeadThoughtsGroupRow = ({
return () => observer.disconnect();
}, [onVisible, thoughts]);
const clearPendingScrollSync = useCallback(() => {
if (scrollSyncFrameRef.current !== null) {
cancelAnimationFrame(scrollSyncFrameRef.current);
scrollSyncFrameRef.current = null;
}
}, []);
const queueScrollSync = useCallback(
(mode: 'bottom' | 'preserve') => {
clearPendingScrollSync();
scrollSyncFrameRef.current = requestAnimationFrame(() => {
scrollSyncFrameRef.current = requestAnimationFrame(() => {
const scrollEl = scrollRef.current;
if (!scrollEl || expanded || !isBodyVisible) {
scrollSyncFrameRef.current = null;
return;
}
const nextScrollTop =
mode === 'bottom'
? scrollEl.scrollHeight - scrollEl.clientHeight
: scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current;
scrollEl.scrollTop = Math.max(0, nextScrollTop);
if (mode === 'bottom') {
distanceFromBottomRef.current = 0;
isUserScrolledUpRef.current = false;
}
scrollSyncFrameRef.current = null;
});
});
},
[clearPendingScrollSync, expanded, isBodyVisible]
);
const syncScrollableBody = useCallback(
(forceScrollToBottom = false) => {
const scrollEl = scrollRef.current;
@ -463,14 +503,26 @@ export const LeadThoughtsGroupRow = ({
const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));
if (expanded) return;
if (!forceScrollToBottom && isUserScrolledUpRef.current) return;
scrollEl.scrollTop = scrollEl.scrollHeight;
if (expanded || !isBodyVisible) return;
if (!nextNeedsTruncation) {
clearPendingScrollSync();
distanceFromBottomRef.current = 0;
isUserScrolledUpRef.current = false;
return;
}
if (forceScrollToBottom || !isUserScrolledUpRef.current) {
queueScrollSync('bottom');
return;
}
queueScrollSync('preserve');
},
[expanded]
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
);
useEffect(() => {
if (!isBodyVisible) return;
const contentEl = contentRef.current;
if (!contentEl) return;
@ -482,18 +534,32 @@ export const LeadThoughtsGroupRow = ({
observer.observe(contentEl);
return () => observer.disconnect();
}, [syncScrollableBody]);
}, [isBodyVisible, syncScrollableBody]);
useEffect(
() => () => {
clearPendingScrollSync();
},
[clearPendingScrollSync]
);
useEffect(() => {
if (isBodyVisible) return;
clearPendingScrollSync();
}, [clearPendingScrollSync, isBodyVisible]);
const handleScroll = useCallback(() => {
if (expanded) return;
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
distanceFromBottomRef.current = distanceFromBottom;
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
}, [expanded]);
const handleCollapse = useCallback(() => {
isUserScrolledUpRef.current = false;
distanceFromBottomRef.current = 0;
setExpanded(false);
requestAnimationFrame(() => {
@ -524,34 +590,26 @@ export const LeadThoughtsGroupRow = ({
{/* Header */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
<div
role={forceCollapsed === true || onCollapseToggle != null ? 'button' : undefined}
tabIndex={forceCollapsed === true || onCollapseToggle != null ? 0 : undefined}
role={canToggleBodyVisibility ? 'button' : undefined}
tabIndex={canToggleBodyVisibility ? 0 : undefined}
className={[
'flex select-none items-center gap-2 px-3 py-1.5',
forceCollapsed === true || onCollapseToggle != null ? 'cursor-pointer' : '',
canToggleBodyVisibility ? 'cursor-pointer' : '',
].join(' ')}
onClick={
forceCollapsed === true || onCollapseToggle != null
? () => {
setIsBodyVisible((v) => !v);
onCollapseToggle?.();
}
: undefined
}
onClick={handleBodyToggle}
onKeyDown={
forceCollapsed === true || onCollapseToggle != null
canToggleBodyVisibility
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsBodyVisible((v) => !v);
onCollapseToggle?.();
handleBodyToggle?.();
}
}
: undefined
}
>
{/* Chevron for collapse mode */}
{forceCollapsed === true || onCollapseToggle != null ? (
{canToggleBodyVisibility ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
@ -608,7 +666,6 @@ export const LeadThoughtsGroupRow = ({
scrollbarColor:
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
overflowAnchor: 'none',
overscrollBehavior: 'contain',
}}
onScroll={handleScroll}
>
@ -626,10 +683,13 @@ export const LeadThoughtsGroupRow = ({
) : null}
</article>
{isBodyVisible && !expanded && needsTruncation ? (
<div className="flex justify-center pt-1" style={{ transform: 'translateY(-20px)' }}>
<div
className="pointer-events-none flex justify-center pt-1"
style={{ transform: 'translateY(-20px)' }}
>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(true);
@ -642,12 +702,12 @@ export const LeadThoughtsGroupRow = ({
) : null}
{isBodyVisible && expanded && needsTruncation ? (
<div
className="sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
style={{ transform: 'translateY(-20px)' }}
>
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={(e) => {
e.stopPropagation();
handleCollapse();

View 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,
};
}

View file

@ -80,6 +80,7 @@ export const ImageLightbox = ({
}}
styles={{
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
button: { padding: 16 },
}}
/>
);

View file

@ -837,43 +837,6 @@ export const CreateTeamDialog = ({
/>
)}
</div>
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
</div>
) : null}
{canCreate && prepareState === 'ready' ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
{prepareMessage ? (
<p className="text-[11px] text-[var(--color-text-muted)]">{prepareMessage}</p>
) : null}
{prepareWarnings.length > 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
@ -934,36 +897,79 @@ export const CreateTeamDialog = ({
</p>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
{canOpenExistingTeam ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenTeam(request.teamName);
onClose();
}}
>
Open Existing Team
<DialogFooter className="pt-4 sm:justify-between">
<div className="min-w-0">
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
</div>
) : null}
{canCreate && launchTeam && prepareState === 'ready' ? (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
{prepareMessage ? (
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
{prepareMessage}
</p>
) : null}
{prepareWarnings.length > 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{canOpenExistingTeam ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenTeam(request.teamName);
onClose();
}}
>
Open Existing Team
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
<Button
size="sm"
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating...
</>
) : (
'Create'
)}
</Button>
<Button
size="sm"
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating...
</>
) : (
'Create'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -2,6 +2,7 @@ import React from 'react';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
import { Brain } from 'lucide-react';
const EFFORT_OPTIONS = [
{ value: '', label: 'Default' },
@ -25,23 +26,26 @@ export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
<Label htmlFor={id} className="label-optional mb-1.5 block">
Effort level (optional)
</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{EFFORT_OPTIONS.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
<div className="flex items-center gap-2">
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{EFFORT_OPTIONS.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning Claude invests before responding. Default uses Claude&apos;s

View file

@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
disabled = false,
}) => (
<>
<div className="mt-2 flex items-center gap-2">
<div className="mt-4 flex items-center gap-2">
<Checkbox
id={id}
checked={checked && !disabled}

View file

@ -506,62 +506,70 @@ export const LaunchTeamDialog = ({
</p>
) : null}
{prepareState === 'idle' || prepareState === 'loading' ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
</div>
) : null}
{prepareState === 'ready' ? (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
{prepareMessage ? (
<p className="text-[11px] text-[var(--color-text-muted)]">{prepareMessage}</p>
) : null}
{prepareWarnings.length > 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
{warning}
</p>
))}
<DialogFooter className="pt-4 sm:justify-between">
<div className="min-w-0">
{prepareState === 'idle' || prepareState === 'loading' ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
</div>
) : null}
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
disabled={isSubmitting || prepareState !== 'ready'}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Launching...
</>
) : (
'Launch'
)}
</Button>
{prepareState === 'ready' ? (
<div>
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
{prepareMessage ? (
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
{prepareMessage}
</p>
) : null}
{prepareWarnings.length > 0 ? (
<div className="mt-0.5 space-y-0.5 pl-5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-sky-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
{prepareState === 'failed' ? <div /> : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
disabled={isSubmitting || prepareState !== 'ready'}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Launching...
</>
) : (
'Launch'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -6,7 +6,7 @@ import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
import { Check } from 'lucide-react';
import { Check, FolderOpen } from 'lucide-react';
import type { Project } from '@shared/types';
@ -102,34 +102,40 @@ export const ProjectPathSelector = ({
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn('mr-2 size-3.5 shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
</div>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
@ -137,12 +143,15 @@ export const ProjectPathSelector = ({
) : null}
{projectsError ? <p className="text-[11px] text-red-300">{projectsError}</p> : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>No projects found, switch to custom path.</p>
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
No projects found, switch to custom path.
</p>
) : null}
</div>
) : (
<div className="space-y-1.5">
<div className="flex gap-2">
<div className="flex items-center gap-2">
<FolderOpen size={16} className="shrink-0 text-[var(--color-text-muted)]" />
<Input
className="h-8 text-xs"
value={customCwd}

View file

@ -39,7 +39,7 @@ interface QuotedMessage {
text: string;
}
const MAX_MESSAGE_LENGTH = 4000;
const MAX_MESSAGE_LENGTH = 50_000;
interface SendMessageDialogProps {
open: boolean;

View file

@ -41,8 +41,8 @@ export const SkipPermissionsCheckbox: React.FC<SkipPermissionsCheckboxProps> = (
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
Autonomous mode all tools execute without confirmation. Be cautious with untrusted
code.
Unleash Claude&apos;s full power no interruptions asking for permission. Autonomous
mode all tools execute without confirmation. Be cautious with untrusted code.
</p>
</div>
</div>

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge';
@ -10,7 +11,6 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
@ -35,7 +35,6 @@ import {
Check,
Clock,
Eye,
FileCode,
FileDiff,
GitCompareArrows,
HelpCircle,
@ -589,7 +588,10 @@ export const TaskDetailDialog = ({
key={file.filePath}
className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
>
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<FileIcon
fileName={file.relativePath.split('/').pop() ?? file.relativePath}
className="size-3.5"
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
@ -878,12 +880,6 @@ export const TaskDetailDialog = ({
containerClassName="-mx-6"
/>
</CollapsibleTeamSection>
<DialogFooter className="flex items-center justify-end sm:justify-end">
<Button variant="outline" onClick={handleClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

View file

@ -46,6 +46,8 @@ interface MemberLogsTabProps {
onPreviewOnlineChange?: (isOnline: boolean) => void;
}
const PREVIEW_PAGE_SIZE = 4;
export const MemberLogsTab = ({
teamName,
memberName,
@ -78,6 +80,7 @@ export const MemberLogsTab = ({
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
const [previewVisibleCount, setPreviewVisibleCount] = useState(PREVIEW_PAGE_SIZE);
useEffect(() => {
return () => {
@ -189,11 +192,17 @@ export const MemberLogsTab = ({
return null;
}, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]);
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
const allPreviewMessages = useMemo((): SubagentPreviewMessage[] => {
if (!previewChunks || previewChunks.length === 0) return [];
return extractSubagentPreviewMessages(previewChunks, 4);
return extractSubagentPreviewMessages(previewChunks);
}, [previewChunks]);
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
return allPreviewMessages.slice(0, previewVisibleCount);
}, [allPreviewMessages, previewVisibleCount]);
const previewHasMore = allPreviewMessages.length > previewVisibleCount;
const previewOnline = useMemo((): boolean => {
const newest = previewMessages[0];
if (!newest) return false;
@ -214,6 +223,20 @@ export const MemberLogsTab = ({
onPreviewOnlineChange?.(previewOnline);
}, [onPreviewOnlineChange, previewOnline]);
useEffect(() => {
setPreviewVisibleCount(PREVIEW_PAGE_SIZE);
}, [previewLog?.kind, previewLog?.sessionId]);
useEffect(() => {
if (allPreviewMessages.length === 0) {
setPreviewVisibleCount(PREVIEW_PAGE_SIZE);
return;
}
setPreviewVisibleCount((prev) =>
Math.max(PREVIEW_PAGE_SIZE, Math.min(prev, allPreviewMessages.length))
);
}, [allPreviewMessages.length]);
useEffect(() => {
return () => onPreviewOnlineChange?.(false);
}, [onPreviewOnlineChange]);
@ -493,6 +516,8 @@ export const MemberLogsTab = ({
<SubagentRecentMessagesPreview
messages={previewMessages}
memberName={previewLog.memberName ?? undefined}
hasMore={previewHasMore}
onLoadMore={() => setPreviewVisibleCount((prev) => prev + PREVIEW_PAGE_SIZE)}
/>
) : null}
{sortedLogs.map((log) => (
@ -605,21 +630,18 @@ function formatRelativeTime(isoString: string): string {
return date.toLocaleDateString();
}
function extractSubagentPreviewMessages(
chunks: EnhancedChunk[],
limit: number
): SubagentPreviewMessage[] {
function extractSubagentPreviewMessages(chunks: EnhancedChunk[]): SubagentPreviewMessage[] {
const conversation = transformChunksToConversation(chunks, [], false);
const out: SubagentPreviewMessage[] = [];
// Collect newest-first and stop as soon as we have enough.
for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) {
// Collect newest-first.
for (let i = conversation.items.length - 1; i >= 0; i--) {
const item = conversation.items[i];
if (item.type === 'ai') {
const enhanced = enhanceAIGroup(item.group);
const items = enhanced.displayItems ?? [];
for (let j = items.length - 1; j >= 0 && out.length < limit; j--) {
for (let j = items.length - 1; j >= 0; j--) {
const di = items[j];
if (di.type === 'output' && di.content.trim()) {
out.push({

View file

@ -1,4 +1,7 @@
import { useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { format } from 'date-fns';
export type SubagentPreviewMessageKind =
@ -23,57 +26,85 @@ export interface SubagentPreviewMessage {
interface SubagentRecentMessagesPreviewProps {
messages: SubagentPreviewMessage[];
memberName?: string;
hasMore?: boolean;
onLoadMore?: () => void;
}
export const SubagentRecentMessagesPreview = ({
messages,
memberName,
hasMore = false,
onLoadMore,
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
const [expandedAll, setExpandedAll] = useState(false);
if (!messages.length) return null;
return (
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="mb-2 flex items-center gap-2">
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
Latest messages{memberName ? `${memberName}` : ''}
</div>
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{format(messages[0].timestamp, 'h:mm:ss a')}
</div>
</div>
<div className="space-y-2">
{messages.map((m) => (
<div
key={m.id}
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[10px] text-[var(--color-text-muted)]">
{m.label ? (
<span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
{m.label}
</span>
) : (
<span className="font-mono">{m.kind}</span>
)}
<div className={`${expandedAll ? 'max-h-none' : 'max-h-[200px]'} overflow-y-auto pr-1`}>
{messages.map((m, index) => (
<div key={m.id} className="py-1.5">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 text-xs text-[var(--color-text)]">
<MarkdownViewer
content={m.content}
bare
maxHeight="max-h-none"
className="[&>div>div]:p-0 [&_ol]:my-1 [&_p]:my-1 [&_ul]:my-1"
/>
</div>
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
<div className="shrink-0 text-right text-[10px] text-[var(--color-text-muted)]">
{format(m.timestamp, 'h:mm:ss a')}
</div>
</div>
{m.kind === 'tool_result' ? (
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--color-text)]">
{m.content}
</pre>
) : (
<div className="max-h-40 overflow-y-auto text-xs text-[var(--color-text)]">
<MarkdownViewer content={m.content} copyable />
</div>
)}
{index < messages.length - 1 ? (
<hr className="mt-2 border-[var(--color-border)]" />
) : null}
</div>
))}
{hasMore && onLoadMore ? (
<div className="flex justify-center pb-1 pt-2">
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={onLoadMore}
>
<ChevronDown size={12} />
Load more
</button>
</div>
) : null}
</div>
<div className="sticky bottom-0 z-10 flex justify-end pb-1 pt-2">
{!expandedAll ? (
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setExpandedAll(true)}
>
<ChevronDown size={12} />
Expand
</button>
) : (
<button
type="button"
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setExpandedAll(false)}
>
<ChevronUp size={12} />
Collapse
</button>
)}
</div>
</div>
);

View file

@ -6,9 +6,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useAttachments } from '@renderer/hooks/useAttachments';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
@ -33,7 +31,7 @@ interface MessageComposerProps {
) => void;
}
const MAX_MESSAGE_LENGTH = 4000;
const MAX_MESSAGE_LENGTH = 50_000;
/** Circular progress indicator for lead context usage. */
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
@ -119,19 +117,7 @@ export const MessageComposer = ({
}, [members, recipient]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const draft = useDraftPersistence({ key: `compose:${teamName}` });
const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`);
const {
attachments,
error: attachmentError,
canAddMore,
addFiles,
removeAttachment,
clearAttachments,
clearError: clearAttachmentError,
handlePaste,
handleDrop,
} = useAttachments({ persistenceKey: `compose:${teamName}:attachments` });
const draft = useComposerDraft(teamName);
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
@ -146,7 +132,7 @@ export const MessageComposer = ({
[members, colorMap]
);
const trimmed = draft.value.trim();
const trimmed = draft.text.trim();
const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
@ -157,8 +143,8 @@ export const MessageComposer = ({
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
const supportsAttachments = isLeadRecipient;
const canAttach = supportsAttachments && canAddMore;
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
const canAttach = supportsAttachments && draft.canAddMore;
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
const canSend =
recipient.length > 0 &&
trimmed.length > 0 &&
@ -172,10 +158,15 @@ export const MessageComposer = ({
const handleSend = useCallback(() => {
if (!canSend) return;
pendingSendRef.current = true;
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
const serialized = serializeChipsWithText(trimmed, draft.chips);
// Summary should stay compact (no expanded chip markdown)
onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined);
}, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]);
onSend(
recipient,
serialized,
trimmed,
draft.attachments.length > 0 ? draft.attachments : undefined
);
}, [canSend, recipient, trimmed, onSend, draft.attachments, draft.chips]);
// Clear draft only after send completes successfully (sending: true → false, no error)
useEffect(() => {
@ -183,12 +174,9 @@ export const MessageComposer = ({
pendingSendRef.current = false;
if (!sendError) {
draft.clearDraft();
chipDraft.clearChipDraft();
clearAttachments();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with [])
}, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]);
}, [sending, sendError, draft]);
const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => {
@ -205,11 +193,11 @@ export const MessageComposer = ({
(e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
if (input.files?.length) {
void addFiles(input.files);
void draft.addFiles(input.files);
}
input.value = '';
},
[addFiles]
[draft.addFiles]
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
@ -235,16 +223,16 @@ export const MessageComposer = ({
(e: React.DragEvent) => {
dragCounterRef.current = 0;
setIsDragOver(false);
if (canAttach) handleDrop(e);
if (canAttach) draft.handleDrop(e);
},
[canAttach, handleDrop]
[canAttach, draft.handleDrop]
);
const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => {
if (canAttach) handlePaste(e);
if (canAttach) draft.handlePaste(e);
},
[canAttach, handlePaste]
[canAttach, draft.handlePaste]
);
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
@ -292,7 +280,7 @@ export const MessageComposer = ({
<TooltipContent side="top">
{!isTeamAlive
? 'Team must be online to attach images'
: !canAddMore
: !draft.canAddMore
? 'Maximum attachments reached'
: 'Attach images (paste or drag & drop)'}
</TooltipContent>
@ -408,10 +396,10 @@ export const MessageComposer = ({
</div>
<AttachmentPreviewList
attachments={attachments}
onRemove={removeAttachment}
error={attachmentError}
onDismissError={clearAttachmentError}
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
@ -419,13 +407,13 @@ export const MessageComposer = ({
<MentionableTextarea
id={`compose-${teamName}`}
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
value={draft.value}
onValueChange={draft.setValue}
value={draft.text}
onValueChange={draft.setText}
suggestions={mentionSuggestions}
chips={chipDraft.chips}
onChipRemove={chipDraft.removeChip}
chips={draft.chips}
onChipRemove={draft.removeChip}
projectPath={projectPath}
onFileChipInsert={chipDraft.addChip}
onFileChipInsert={draft.addChip}
onModEnter={handleSend}
minRows={2}
maxRows={6}

View 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,
};
}

View file

@ -773,3 +773,14 @@ body {
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
background-color: #ffffff;
}
/* Lightbox toolbar buttons — enlarge hit targets and fix SVG dead zones */
.yarl__toolbar .yarl__button {
min-width: 44px;
min-height: 44px;
position: relative;
}
.yarl__toolbar .yarl__button > svg {
pointer-events: none;
}

View 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,
};

View 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),
});
});
});
});

View 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();
});
});

View 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());
});
});