feat: enhance TeamDataService and UI components for lead session management and message deduplication
- Refactored TeamDataService to improve lead session message handling, including deduplication logic to prevent double-rendering of messages. - Added leadSessionId to InboxMessage type for better session tracking. - Updated ActivityTimeline to visually separate messages by session, enhancing user experience. - Improved LeadThoughtsGroupRow to support live indicators and auto-scroll functionality for new messages. - Enhanced AddMemberDialog, LaunchTeamDialog, and MemberDraftRow with updated placeholder text for clarity.
This commit is contained in:
parent
1326c099fb
commit
65eb788097
8 changed files with 203 additions and 98 deletions
|
|
@ -258,8 +258,9 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
mark('messages');
|
mark('messages');
|
||||||
|
|
||||||
|
let leadTexts: InboxMessage[] = [];
|
||||||
try {
|
try {
|
||||||
const leadTexts = await this.extractLeadSessionTexts(config);
|
leadTexts = await this.extractLeadSessionTexts(config);
|
||||||
if (leadTexts.length > 0) {
|
if (leadTexts.length > 0) {
|
||||||
messages = [...messages, ...leadTexts];
|
messages = [...messages, ...leadTexts];
|
||||||
}
|
}
|
||||||
|
|
@ -268,8 +269,9 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
mark('leadTexts');
|
mark('leadTexts');
|
||||||
|
|
||||||
|
let sentMessages: InboxMessage[] = [];
|
||||||
try {
|
try {
|
||||||
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
|
sentMessages = await this.sentMessagesStore.readMessages(teamName);
|
||||||
if (sentMessages.length > 0) {
|
if (sentMessages.length > 0) {
|
||||||
messages = [...messages, ...sentMessages];
|
messages = [...messages, ...sentMessages];
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +280,33 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
mark('sentMessages');
|
mark('sentMessages');
|
||||||
|
|
||||||
|
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
||||||
|
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
||||||
|
if (leadTexts.length > 0 && sentMessages.length > 0) {
|
||||||
|
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||||
|
const leadSessionFingerprints = new Set<string>();
|
||||||
|
for (const msg of leadTexts) {
|
||||||
|
if (msg.source !== 'lead_session') continue;
|
||||||
|
leadSessionFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||||
|
}
|
||||||
|
messages = messages.filter((m) => {
|
||||||
|
if (m.source !== 'lead_process') return true;
|
||||||
|
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
|
||||||
|
return !leadSessionFingerprints.has(fp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich messages without leadSessionId: assign current session for lead_process/user_sent.
|
||||||
|
// lead_process messages surviving dedup are from the current session;
|
||||||
|
// user_sent messages written before this feature lack the field.
|
||||||
|
if (config.leadSessionId) {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) {
|
||||||
|
msg.leadSessionId = config.leadSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||||
|
|
||||||
let metaMembers: TeamConfig['members'] = [];
|
let metaMembers: TeamConfig['members'] = [];
|
||||||
|
|
@ -1102,6 +1131,15 @@ export class TeamDataService {
|
||||||
attachments?: AttachmentMeta[]
|
attachments?: AttachmentMeta[]
|
||||||
): Promise<SendMessageResult> {
|
): Promise<SendMessageResult> {
|
||||||
const messageId = randomUUID();
|
const messageId = randomUUID();
|
||||||
|
|
||||||
|
let leadSessionId: string | undefined;
|
||||||
|
try {
|
||||||
|
const config = await this.configReader.getConfig(teamName);
|
||||||
|
leadSessionId = config?.leadSessionId;
|
||||||
|
} catch {
|
||||||
|
// non-critical — proceed without sessionId
|
||||||
|
}
|
||||||
|
|
||||||
const msg: InboxMessage = {
|
const msg: InboxMessage = {
|
||||||
from: 'user',
|
from: 'user',
|
||||||
to: leadName,
|
to: leadName,
|
||||||
|
|
@ -1112,6 +1150,7 @@ export class TeamDataService {
|
||||||
messageId,
|
messageId,
|
||||||
source: 'user_sent',
|
source: 'user_sent',
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
|
leadSessionId,
|
||||||
};
|
};
|
||||||
await this.sentMessagesStore.appendMessage(teamName, msg);
|
await this.sentMessagesStore.appendMessage(teamName, msg);
|
||||||
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
|
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
|
||||||
|
|
@ -1214,7 +1253,8 @@ export class TeamDataService {
|
||||||
name: (() => {
|
name: (() => {
|
||||||
const name = member.name.trim();
|
const name = member.name.trim();
|
||||||
if (!name) throw new Error('Member name cannot be empty');
|
if (!name) throw new Error('Member name cannot be empty');
|
||||||
if (name.toLowerCase() === 'team-lead') throw new Error('Member name "team-lead" is reserved');
|
if (name.toLowerCase() === 'team-lead')
|
||||||
|
throw new Error('Member name "team-lead" is reserved');
|
||||||
const suffixInfo = parseNumericSuffixName(name);
|
const suffixInfo = parseNumericSuffixName(name);
|
||||||
if (suffixInfo && suffixInfo.suffix >= 2) {
|
if (suffixInfo && suffixInfo.suffix >= 2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -1374,6 +1414,7 @@ export class TeamDataService {
|
||||||
timestamp,
|
timestamp,
|
||||||
read: true,
|
read: true,
|
||||||
source: 'lead_session',
|
source: 'lead_session',
|
||||||
|
leadSessionId: config.leadSessionId,
|
||||||
});
|
});
|
||||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export class TeamSentMessagesStore {
|
||||||
color: typeof row.color === 'string' ? row.color : undefined,
|
color: typeof row.color === 'string' ? row.color : undefined,
|
||||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||||
|
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
|
|
@ -213,7 +213,8 @@ export const ActivityTimeline = ({
|
||||||
const newItemKeys = useMemo(() => {
|
const newItemKeys = useMemo(() => {
|
||||||
const getItemKey = (item: TimelineItem): string => {
|
const getItemKey = (item: TimelineItem): string => {
|
||||||
if (item.type === 'lead-thoughts') {
|
if (item.type === 'lead-thoughts') {
|
||||||
return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}-${item.group.thoughts.length}`;
|
// Stable key: identify group by its first thought, not by count (which changes)
|
||||||
|
return `thoughts-${item.group.thoughts[0].messageId ?? item.originalIndices[0]}`;
|
||||||
}
|
}
|
||||||
const msg = item.message;
|
const msg = item.message;
|
||||||
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
|
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
|
||||||
|
|
@ -274,22 +275,47 @@ export const ActivityTimeline = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
||||||
|
item.type === 'lead-thoughts'
|
||||||
|
? item.group.thoughts[0].leadSessionId
|
||||||
|
: item.message.leadSessionId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{timelineItems.map((item, index) => {
|
{timelineItems.map((item, index) => {
|
||||||
|
// Session boundary separator (messages sorted desc — new on top)
|
||||||
|
let sessionSeparator: React.JSX.Element | null = null;
|
||||||
|
if (index > 0) {
|
||||||
|
const prevSessionId = getItemSessionId(timelineItems[index - 1]);
|
||||||
|
const currSessionId = getItemSessionId(item);
|
||||||
|
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||||
|
sessionSeparator = (
|
||||||
|
<div className="flex items-center gap-3 py-4">
|
||||||
|
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||||
|
<span className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Новая сессия
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'lead-thoughts') {
|
if (item.type === 'lead-thoughts') {
|
||||||
const { group } = item;
|
const { group } = item;
|
||||||
const firstThought = group.thoughts[0];
|
const firstThought = group.thoughts[0];
|
||||||
const info = memberInfo.get(firstThought.from);
|
const info = memberInfo.get(firstThought.from);
|
||||||
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}-${group.thoughts.length}`;
|
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
||||||
return (
|
return (
|
||||||
<LeadThoughtsGroupRow
|
<React.Fragment key={itemKey}>
|
||||||
key={itemKey}
|
{sessionSeparator}
|
||||||
group={group}
|
<LeadThoughtsGroupRow
|
||||||
memberColor={info?.color}
|
group={group}
|
||||||
isNew={newItemKeys.has(itemKey)}
|
memberColor={info?.color}
|
||||||
onVisible={onMessageVisible}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
/>
|
onVisible={onMessageVisible}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,24 +329,26 @@ export const ActivityTimeline = ({
|
||||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||||
: !message.read;
|
: !message.read;
|
||||||
return (
|
return (
|
||||||
<MessageRowWithObserver
|
<React.Fragment key={messageKey}>
|
||||||
key={messageKey}
|
{sessionSeparator}
|
||||||
message={message}
|
<MessageRowWithObserver
|
||||||
teamName={teamName}
|
message={message}
|
||||||
memberRole={info?.role}
|
teamName={teamName}
|
||||||
memberColor={info?.color}
|
memberRole={info?.role}
|
||||||
recipientColor={recipientColor}
|
memberColor={info?.color}
|
||||||
isUnread={isUnread}
|
recipientColor={recipientColor}
|
||||||
isNew={newItemKeys.has(messageKey)}
|
isUnread={isUnread}
|
||||||
zebraShade={zebraShadeSet.has(index)}
|
isNew={newItemKeys.has(messageKey)}
|
||||||
memberColorMap={colorMap}
|
zebraShade={zebraShadeSet.has(index)}
|
||||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
memberColorMap={colorMap}
|
||||||
onCreateTask={onCreateTaskFromMessage}
|
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||||
onReply={onReplyToMessage}
|
onCreateTask={onCreateTaskFromMessage}
|
||||||
onVisible={onMessageVisible}
|
onReply={onReplyToMessage}
|
||||||
onTaskIdClick={onTaskIdClick}
|
onVisible={onMessageVisible}
|
||||||
onRestartTeam={onRestartTeam}
|
onTaskIdClick={onTaskIdClick}
|
||||||
/>
|
onRestartTeam={onRestartTeam}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
CARD_TEXT_LIGHT,
|
CARD_TEXT_LIGHT,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -74,6 +73,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWPORT_THRESHOLD = 0.15;
|
const VIEWPORT_THRESHOLD = 0.15;
|
||||||
|
const LIVE_WINDOW_MS = 10_000;
|
||||||
|
const AUTO_SCROLL_THRESHOLD = 30;
|
||||||
|
|
||||||
interface LeadThoughtsGroupRowProps {
|
interface LeadThoughtsGroupRowProps {
|
||||||
group: LeadThoughtGroup;
|
group: LeadThoughtGroup;
|
||||||
|
|
@ -94,34 +95,61 @@ function formatTimeWithSec(timestamp: string): string {
|
||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRecentTimestamp(timestamp: string): boolean {
|
||||||
|
const t = Date.parse(timestamp);
|
||||||
|
if (Number.isNaN(t)) return false;
|
||||||
|
return Date.now() - t <= LIVE_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export const LeadThoughtsGroupRow = ({
|
export const LeadThoughtsGroupRow = ({
|
||||||
group,
|
group,
|
||||||
memberColor,
|
memberColor,
|
||||||
isNew,
|
isNew,
|
||||||
onVisible,
|
onVisible,
|
||||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const reportedRef = useRef(false);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isUserScrolledUpRef = useRef(false);
|
||||||
|
|
||||||
const colors = getTeamColorSet(memberColor ?? '');
|
const colors = getTeamColorSet(memberColor ?? '');
|
||||||
const { thoughts } = group;
|
const { thoughts } = group;
|
||||||
const first = thoughts[0];
|
// thoughts is newest-first; first=newest, last=oldest
|
||||||
const last = thoughts[thoughts.length - 1];
|
const newest = thoughts[0];
|
||||||
const leadName = first.from;
|
const oldest = thoughts[thoughts.length - 1];
|
||||||
|
const leadName = newest.from;
|
||||||
|
|
||||||
|
// Chronological order for rendering (oldest at top, newest at bottom)
|
||||||
|
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
||||||
|
|
||||||
|
// Live indicator: newest thought is from lead_process and recent
|
||||||
|
const computeIsLive = useCallback(
|
||||||
|
() => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp),
|
||||||
|
[newest.source, newest.timestamp]
|
||||||
|
);
|
||||||
|
const [isLive, setIsLive] = useState(computeIsLive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLive(computeIsLive());
|
||||||
|
const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [computeIsLive]);
|
||||||
|
|
||||||
|
// Track how many thoughts have been reported as visible so far.
|
||||||
|
const reportedCountRef = useRef(0);
|
||||||
|
|
||||||
// Mark all thoughts as visible when the group enters the viewport
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onVisible) return;
|
if (!onVisible) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (!entry?.isIntersecting || reportedRef.current) return;
|
if (!entry?.isIntersecting) return;
|
||||||
reportedRef.current = true;
|
const alreadyReported = reportedCountRef.current;
|
||||||
for (const thought of thoughts) {
|
if (alreadyReported >= thoughts.length) return;
|
||||||
onVisible(thought);
|
for (let i = alreadyReported; i < thoughts.length; i++) {
|
||||||
|
onVisible(thoughts[i]);
|
||||||
}
|
}
|
||||||
|
reportedCountRef.current = thoughts.length;
|
||||||
},
|
},
|
||||||
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
||||||
);
|
);
|
||||||
|
|
@ -129,10 +157,20 @@ export const LeadThoughtsGroupRow = ({
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onVisible, thoughts]);
|
}, [onVisible, thoughts]);
|
||||||
|
|
||||||
// Preview: summary of newest thought (first in array since newest-first)
|
// Auto-scroll to bottom when new thoughts arrive
|
||||||
const previewText = first.summary || first.text.split('\n')[0];
|
useEffect(() => {
|
||||||
const previewTruncated =
|
if (isUserScrolledUpRef.current) return;
|
||||||
previewText.length > 120 ? previewText.slice(0, 117) + '...' : previewText;
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}, [thoughts.length]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||||
|
|
@ -142,62 +180,53 @@ export const LeadThoughtsGroupRow = ({
|
||||||
backgroundColor: CARD_BG,
|
backgroundColor: CARD_BG,
|
||||||
border: CARD_BORDER_STYLE,
|
border: CARD_BORDER_STYLE,
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
opacity: 0.75,
|
opacity: isLive ? undefined : 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header — click to expand/collapse */}
|
{/* Header */}
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below */}
|
<div className="flex select-none items-center gap-2 px-3 py-1.5">
|
||||||
<div
|
{/* Live / offline indicator */}
|
||||||
role="button"
|
{isLive ? (
|
||||||
tabIndex={0}
|
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||||
className="flex cursor-pointer select-none items-center gap-2 px-3 py-1.5 hover:bg-[rgba(255,255,255,0.02)]"
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||||
onClick={() => setExpanded((v) => !v)}
|
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||||
onKeyDown={(e) => {
|
</span>
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
) : (
|
||||||
e.preventDefault();
|
<span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
|
||||||
setExpanded((v) => !v);
|
)}
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className="size-3 shrink-0 transition-transform duration-150"
|
|
||||||
style={{
|
|
||||||
color: CARD_ICON_MUTED,
|
|
||||||
transform: expanded ? 'rotate(90deg)' : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{thoughts.length} thoughts
|
{thoughts.length} thoughts
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{formatTime(last.timestamp)}–{formatTime(first.timestamp)}
|
{formatTime(oldest.timestamp)}–{formatTime(newest.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
{!expanded && (
|
|
||||||
<span className="flex-1 truncate text-[11px]" style={{ color: CARD_TEXT_LIGHT }}>
|
|
||||||
{previewTruncated}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded: all thoughts as compact timestamped lines */}
|
{/* Scrollable body — fixed height, always visible */}
|
||||||
{expanded && (
|
<div
|
||||||
<div
|
ref={scrollRef}
|
||||||
className="space-y-px border-t px-3 py-1.5"
|
className="space-y-px border-t px-3 py-1.5"
|
||||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
style={{
|
||||||
>
|
borderColor: 'var(--color-border-subtle)',
|
||||||
{thoughts.map((thought, idx) => (
|
maxHeight: '200px',
|
||||||
<div key={thought.messageId ?? idx} className="flex gap-2 py-0.5 text-[11px]">
|
overflowY: 'auto',
|
||||||
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
|
scrollbarWidth: 'thin',
|
||||||
{formatTimeWithSec(thought.timestamp)}
|
scrollbarColor: 'var(--scrollbar-thumb) transparent',
|
||||||
</span>
|
}}
|
||||||
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}>
|
onScroll={handleScroll}
|
||||||
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
|
>
|
||||||
</span>
|
{chronologicalThoughts.map((thought, idx) => (
|
||||||
</div>
|
<div key={thought.messageId ?? idx} className="flex gap-2 py-0.5 text-[11px]">
|
||||||
))}
|
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
|
||||||
</div>
|
{formatTimeWithSec(thought.timestamp)}
|
||||||
)}
|
</span>
|
||||||
|
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
|
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ export const AddMemberDialog = ({
|
||||||
onValueChange={handleWorkflowChange}
|
onValueChange={handleWorkflowChange}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
projectPath={projectPath ?? undefined}
|
projectPath={projectPath ?? undefined}
|
||||||
placeholder="How this agent should behave, what tasks it handles. Use @ to mention teammates or add files."
|
placeholder="How this agent should behave, what tasks it handles..."
|
||||||
footerRight={
|
footerRight={
|
||||||
workflowDraft.isSaved ? (
|
workflowDraft.isSaved ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||||
|
|
|
||||||
|
|
@ -362,7 +362,11 @@ export const LaunchTeamDialog = ({
|
||||||
{prepareWarnings.length > 0 ? (
|
{prepareWarnings.length > 0 ? (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{prepareWarnings.map((warning) => (
|
{prepareWarnings.map((warning) => (
|
||||||
<p key={warning} className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
<p
|
||||||
|
key={warning}
|
||||||
|
className="text-[11px]"
|
||||||
|
style={{ color: 'var(--warning-text)' }}
|
||||||
|
>
|
||||||
{warning}
|
{warning}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
@ -406,7 +410,7 @@ export const LaunchTeamDialog = ({
|
||||||
chips={chipDraft.chips}
|
chips={chipDraft.chips}
|
||||||
onChipRemove={chipDraft.removeChip}
|
onChipRemove={chipDraft.removeChip}
|
||||||
onFileChipInsert={chipDraft.addChip}
|
onFileChipInsert={chipDraft.addChip}
|
||||||
placeholder="Instructions for team lead... Use @ to mention team members."
|
placeholder="Instructions for team lead..."
|
||||||
footerRight={
|
footerRight={
|
||||||
promptDraft.isSaved ? (
|
promptDraft.isSaved ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ export const MemberDraftRow = ({
|
||||||
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
onCustomRoleChange={(customRole) => onCustomRoleChange(member.id, customRole)}
|
||||||
triggerClassName="h-8 text-xs"
|
triggerClassName="h-8 text-xs"
|
||||||
inputClassName="h-8 text-xs"
|
inputClassName="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
{showWorkflow && onWorkflowChange ? (
|
{showWorkflow && onWorkflowChange ? (
|
||||||
|
|
@ -191,7 +191,7 @@ export const MemberDraftRow = ({
|
||||||
onChipRemove={handleChipRemove}
|
onChipRemove={handleChipRemove}
|
||||||
projectPath={projectPath ?? undefined}
|
projectPath={projectPath ?? undefined}
|
||||||
onFileChipInsert={handleFileChipInsert}
|
onFileChipInsert={handleFileChipInsert}
|
||||||
placeholder="How this agent should behave, interact with others. Use @ to mention teammates or add files."
|
placeholder="How this agent should behave, interact with others..."
|
||||||
footerRight={
|
footerRight={
|
||||||
workflowDraft.isSaved ? (
|
workflowDraft.isSaved ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ export interface InboxMessage {
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
|
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
|
||||||
attachments?: AttachmentMeta[];
|
attachments?: AttachmentMeta[];
|
||||||
|
/** Lead session ID that produced this message (for session boundary detection). */
|
||||||
|
leadSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageRequest {
|
export interface SendMessageRequest {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue