feat: improve tool summary handling and UI enhancements across services and components
- Replaced buildToolSummary with formatToolSummaryFromMap in TeamDataService and TeamProvisioningService to streamline tool summary generation. - Enhanced JSONL file path handling in TeamDataService to accommodate variations in project directory naming. - Introduced pendingToolCounts in TeamProvisioningService to accumulate tool usage data for better message context. - Updated SortableTab and DragOverlayTab components for improved styling and responsiveness. - Adjusted ClaudeLogsSection to refine log display height for better usability. - Enhanced TeamDetailView to filter out lead-to-user messages temporarily, improving message clarity. - Added model selection UI in MemberDraftRow with informative tooltips for user guidance. - Improved MentionableTextarea with rotating tips to enhance user experience during message composition.
This commit is contained in:
parent
9368e7639d
commit
79ea547674
11 changed files with 308 additions and 144 deletions
|
|
@ -17,7 +17,7 @@ import {
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { buildToolSummary } from '@shared/utils/toolSummary';
|
||||
import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -1424,13 +1424,29 @@ export class TeamDataService {
|
|||
|
||||
const projectId = encodePath(config.projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
||||
let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
||||
|
||||
try {
|
||||
await fs.promises.access(jsonlPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
logger.debug(`Lead session JSONL not found: ${jsonlPath}`);
|
||||
return [];
|
||||
// Claude Code encodes underscores as hyphens in project directory names;
|
||||
// our encodePath only handles slashes. Try the underscore-to-hyphen variant.
|
||||
const altBaseDir = baseDir.replace(/_/g, '-');
|
||||
if (altBaseDir !== baseDir) {
|
||||
const altPath = path.join(
|
||||
getProjectsBasePath(),
|
||||
altBaseDir,
|
||||
`${config.leadSessionId}.jsonl`
|
||||
);
|
||||
try {
|
||||
await fs.promises.access(altPath, fs.constants.F_OK);
|
||||
jsonlPath = altPath;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
|
||||
|
|
@ -1441,6 +1457,7 @@ export class TeamDataService {
|
|||
const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
|
||||
|
||||
const textsReversed: InboxMessage[] = [];
|
||||
const seenMessageIds = new Set<string>();
|
||||
const handle = await fs.promises.open(jsonlPath, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
|
|
@ -1487,7 +1504,32 @@ export class TeamDataService {
|
|||
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
||||
if (combined.length < MIN_TEXT_LENGTH) continue;
|
||||
|
||||
const toolSummary = buildToolSummary(content as Record<string, unknown>[]);
|
||||
// Count tool_use blocks from following lines (text and tool_use are separate in JSONL).
|
||||
// tool_result (type=user) lines are interleaved between tool_use lines — skip them.
|
||||
const toolCounts = new Map<string, number>();
|
||||
const lookaheadLimit = Math.min(i + 200, lines.length);
|
||||
for (let j = i + 1; j < lookaheadLimit; j++) {
|
||||
const tLine = lines[j]?.trim();
|
||||
if (!tLine) continue;
|
||||
let tMsg: Record<string, unknown>;
|
||||
try {
|
||||
tMsg = JSON.parse(tLine) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines
|
||||
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
|
||||
const tContent = tMessage.content;
|
||||
if (!Array.isArray(tContent)) continue;
|
||||
const tBlocks = tContent as Record<string, unknown>[];
|
||||
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
|
||||
for (const b of tBlocks) {
|
||||
if (b.type === 'tool_use' && typeof b.name === 'string') {
|
||||
toolCounts.set(b.name, (toolCounts.get(b.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolSummary = formatToolSummaryFromMap(toolCounts);
|
||||
|
||||
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
|
||||
const textPrefix = combined
|
||||
|
|
@ -1495,6 +1537,10 @@ export class TeamDataService {
|
|||
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||
.slice(0, 20);
|
||||
|
||||
const messageId = `lead-session-${timestamp}-${textPrefix}`;
|
||||
if (seenMessageIds.has(messageId)) continue;
|
||||
seenMessageIds.add(messageId);
|
||||
|
||||
textsReversed.push({
|
||||
from: leadName,
|
||||
text: combined,
|
||||
|
|
@ -1502,7 +1548,7 @@ export class TeamDataService {
|
|||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId: config.leadSessionId,
|
||||
messageId: `lead-session-${timestamp}-${textPrefix}`,
|
||||
messageId,
|
||||
toolSummary,
|
||||
});
|
||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
|||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { buildToolSummary } from '@shared/utils/toolSummary';
|
||||
import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
|
@ -61,7 +61,7 @@ const STDOUT_RING_LIMIT = 64 * 1024;
|
|||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||
const PROBE_CACHE_TTL_MS = 10 * 60_000;
|
||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 60000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
|
|
@ -164,6 +164,8 @@ interface ProvisioningRun {
|
|||
} | null;
|
||||
/** Monotonic counter for individual lead assistant messages. */
|
||||
leadMsgSeq: number;
|
||||
/** Accumulated tool_use counts between text messages (tool name → count). */
|
||||
pendingToolCounts: Map<string, number>;
|
||||
/** Throttle timestamp for emitting inbox refresh events for lead text. */
|
||||
lastLeadTextEmitMs: number;
|
||||
/**
|
||||
|
|
@ -1745,6 +1747,7 @@ export class TeamProvisioningService {
|
|||
fsPhase: 'waiting_config',
|
||||
leadRelayCapture: null,
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCounts: new Map(),
|
||||
lastLeadTextEmitMs: 0,
|
||||
silentUserDmForward: null,
|
||||
silentUserDmForwardClearHandle: null,
|
||||
|
|
@ -2044,6 +2047,7 @@ export class TeamProvisioningService {
|
|||
fsPhase: 'waiting_members',
|
||||
leadRelayCapture: null,
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCounts: new Map(),
|
||||
lastLeadTextEmitMs: 0,
|
||||
silentUserDmForward: null,
|
||||
silentUserDmForwardClearHandle: null,
|
||||
|
|
@ -2920,7 +2924,12 @@ export class TeamProvisioningService {
|
|||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
|
||||
const toolSummary = buildToolSummary(content ?? []);
|
||||
// Attach accumulated tool counts from preceding tool_use messages, then reset.
|
||||
const toolSummary =
|
||||
run.pendingToolCounts.size > 0
|
||||
? formatToolSummaryFromMap(run.pendingToolCounts)
|
||||
: undefined;
|
||||
run.pendingToolCounts.clear();
|
||||
const leadMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
text: cleanText,
|
||||
|
|
@ -2950,6 +2959,19 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
// Accumulate tool_use counts from tool-only messages (text + tool_use are separate in stream-json).
|
||||
// These counts will be attached to the next text message as toolSummary.
|
||||
if (run.provisioningComplete) {
|
||||
for (const block of content ?? []) {
|
||||
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||
run.pendingToolCounts.set(
|
||||
block.name as string,
|
||||
(run.pendingToolCounts.get(block.name as string) ?? 0) + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
|
||||
// Claude Code's internal teamContext may route to "default" instead of the real team
|
||||
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
||||
|
|
|
|||
|
|
@ -96,8 +96,15 @@ export const SortableTab = ({
|
|||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'var(--color-surface-overlay)'
|
||||
: 'transparent',
|
||||
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
: teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'transparent',
|
||||
color:
|
||||
isActive || isHovered
|
||||
? 'var(--color-text)'
|
||||
: teamColorSet
|
||||
? teamColorSet.text
|
||||
: 'var(--color-text-muted)',
|
||||
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
|
||||
|
|
@ -125,7 +132,7 @@ export const SortableTab = ({
|
|||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={isActive}
|
||||
className="group flex min-w-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
||||
className="group flex shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
||||
style={style}
|
||||
onClick={(e) => onTabClick(tab.id, e)}
|
||||
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
||||
|
|
@ -150,7 +157,11 @@ export const SortableTab = ({
|
|||
<Pin className="size-3 shrink-0 text-blue-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">{tab.label}</span>
|
||||
<span
|
||||
className={`${tab.label.length > 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isTeamTab && (
|
||||
<TeamTabSectionNav
|
||||
teamName={tab.teamName!}
|
||||
|
|
@ -188,7 +199,7 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 rounded-md border-2 px-3 py-1.5"
|
||||
className="flex shrink-0 items-center gap-2 rounded-md border-2 px-3 py-1.5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-accent, #6366f1)',
|
||||
|
|
@ -198,7 +209,11 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
|||
}}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="truncate text-sm">{tab.label}</span>
|
||||
<span
|
||||
className={`${tab.label.length > 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
Gives users a reliable window-drag target regardless of how many tabs are open.
|
||||
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
|
||||
<div
|
||||
className="min-w-[48px] flex-1 self-stretch"
|
||||
className="min-w-[48px] shrink-0 self-stretch"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
cliLogsTail={filteredText}
|
||||
order="newest-first"
|
||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||
className="max-h-[320px] p-2"
|
||||
className="max-h-[213px] p-2"
|
||||
containerRefCallback={(el) => {
|
||||
logContainerRef.current = el;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -574,9 +574,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return result;
|
||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
||||
[data?.members]
|
||||
);
|
||||
|
||||
const leadMemberName = useMemo(
|
||||
() => activeMembers.find((m) => m.agentType === 'team-lead')?.name,
|
||||
[activeMembers]
|
||||
);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let list = data.messages;
|
||||
// Temporarily hide lead→user messages from the UI
|
||||
// (notifications and other processing still receive them via data.messages)
|
||||
if (leadMemberName) {
|
||||
list = list.filter((m) => !(m.to?.trim() === 'user' && m.from?.trim() === leadMemberName));
|
||||
}
|
||||
if (timeWindow) {
|
||||
list = list.filter((m) => {
|
||||
const ts = new Date(m.timestamp).getTime();
|
||||
|
|
@ -603,7 +618,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
});
|
||||
}
|
||||
return list;
|
||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
|
||||
|
||||
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||
const messagesUnreadCount = useMemo(
|
||||
|
|
@ -627,11 +642,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return filterKanbanTasks(filteredTasks, query);
|
||||
}, [filteredTasks, kanbanSearch]);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
||||
[data?.members]
|
||||
);
|
||||
|
||||
const activeTeammateCount = useMemo(
|
||||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||||
[activeMembers]
|
||||
|
|
|
|||
|
|
@ -190,16 +190,17 @@ export const LeadThoughtsGroupRow = ({
|
|||
return () => observer.disconnect();
|
||||
}, [onVisible, thoughts]);
|
||||
|
||||
// Stable ref for auto-scroll trigger: track content changes (new thoughts + text growth)
|
||||
const newestTextLength = newest.text.length;
|
||||
|
||||
// Auto-scroll to bottom when new thoughts arrive or text grows
|
||||
// Auto-scroll via ResizeObserver — fires after CSS animations expand content
|
||||
useEffect(() => {
|
||||
if (isUserScrolledUpRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [thoughts.length, newestTextLength]);
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isUserScrolledUpRef.current) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- scrollRef is stable
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
|
|
@ -297,6 +298,14 @@ export const LeadThoughtsGroupRow = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{thought.toolSummary && (
|
||||
<div
|
||||
className="px-1 pb-0.5 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
🔧 {thought.toolSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
|
|
@ -9,7 +10,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -48,6 +49,7 @@ export const MemberDraftRow = ({
|
|||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
|
||||
// Pre-warm file list cache when workflow section is expanded
|
||||
useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null);
|
||||
|
|
@ -162,6 +164,19 @@ export const MemberDraftRow = ({
|
|||
Workflow
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1"
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
>
|
||||
{modelExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Model
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -200,6 +215,20 @@ export const MemberDraftRow = ({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{modelExpanded && (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<div className="pointer-events-none opacity-40">
|
||||
<TeamModelSelector value="" onValueChange={() => {}} />
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Claude Code doesn't support per-member model selection yet — all teammates
|
||||
inherit the team launch model. We plan to solve this via a local proxy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export const MessageComposer = ({
|
|||
// const leadContext = useStore((s) =>
|
||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||
const supportsAttachments = isLeadRecipient;
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
const canSend =
|
||||
|
|
@ -263,104 +263,6 @@ export const MessageComposer = ({
|
|||
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
||||
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
|
||||
>
|
||||
{recipient ? (
|
||||
<MemberBadge
|
||||
name={recipient}
|
||||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
)}
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-56 p-1.5"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
setRecipientSearch('');
|
||||
setTimeout(() => recipientSearchRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{members.length > 5 && (
|
||||
<div className="relative mb-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={recipientSearchRef}
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
placeholder="Search..."
|
||||
value={recipientSearch}
|
||||
onChange={(e) => setRecipientSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
|
||||
{(() => {
|
||||
const query = recipientSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? members.filter((m) => m.name.toLowerCase().includes(query))
|
||||
: members;
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return filtered.map((m) => {
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const isSelected = m.name === recipient;
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
|
||||
isSelected && 'bg-[var(--color-surface-raised)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setRecipient(m.name);
|
||||
setRecipientOpen(false);
|
||||
setRecipientSearch('');
|
||||
}}
|
||||
>
|
||||
<MemberBadge
|
||||
name={m.name}
|
||||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
/>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{role}
|
||||
</span>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isLeadRecipient ? (
|
||||
<>
|
||||
<input
|
||||
|
|
@ -398,11 +300,111 @@ export const MessageComposer = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!isTeamAlive ? (
|
||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
|
||||
>
|
||||
{recipient ? (
|
||||
<MemberBadge
|
||||
name={recipient}
|
||||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
)}
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-56 p-1.5"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
setRecipientSearch('');
|
||||
setTimeout(() => recipientSearchRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{members.length > 5 && (
|
||||
<div className="relative mb-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={recipientSearchRef}
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
placeholder="Search..."
|
||||
value={recipientSearch}
|
||||
onChange={(e) => setRecipientSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
|
||||
{(() => {
|
||||
const query = recipientSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? members.filter((m) => m.name.toLowerCase().includes(query))
|
||||
: members;
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return filtered.map((m) => {
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const isSelected = m.name === recipient;
|
||||
return (
|
||||
<button
|
||||
key={m.name}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
|
||||
isSelected && 'bg-[var(--color-surface-raised)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setRecipient(m.name);
|
||||
setRecipientOpen(false);
|
||||
setRecipientSearch('');
|
||||
}}
|
||||
>
|
||||
<MemberBadge
|
||||
name={m.name}
|
||||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
/>
|
||||
{role ? (
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{role}
|
||||
</span>
|
||||
) : null}
|
||||
{isSelected ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentPreviewList
|
||||
|
|
@ -457,9 +459,6 @@ export const MessageComposer = ({
|
|||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Mention "create a task" to add it to the board
|
||||
</span>
|
||||
{sendError ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
|
|
|
|||
|
|
@ -595,11 +595,30 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
}
|
||||
: style;
|
||||
|
||||
// --- Hint text ---
|
||||
const defaultHintText = enableFiles
|
||||
? 'Use @ to mention team members or search files'
|
||||
: 'Use @ to mention team members';
|
||||
const resolvedHintText = hintText ?? defaultHintText;
|
||||
// --- Rotating tips ---
|
||||
const rotatingTips = React.useMemo(
|
||||
() => [
|
||||
'Tip: Use @ to mention team members or search files',
|
||||
'Tip: Mention "create a task" to add it to the board',
|
||||
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
|
||||
],
|
||||
[]
|
||||
);
|
||||
const [tipIndex, setTipIndex] = React.useState(0);
|
||||
const [tipVisible, setTipVisible] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTipVisible(false);
|
||||
setTimeout(() => {
|
||||
setTipIndex((prev) => (prev + 1) % rotatingTips.length);
|
||||
setTipVisible(true);
|
||||
}, 300);
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [rotatingTips.length]);
|
||||
|
||||
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
|
||||
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
|
||||
const showFooter = showHintRow || footerRight;
|
||||
|
||||
|
|
@ -683,7 +702,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
{showFooter ? (
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
{showHintRow ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">{resolvedHintText}</span>
|
||||
<span
|
||||
className="text-[10px] text-[var(--color-text-muted)] transition-opacity duration-300"
|
||||
style={{ opacity: tipVisible ? 1 : 0 }}
|
||||
>
|
||||
{resolvedHintText}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,3 +45,13 @@ export function formatToolSummary(data: ToolSummaryData): string {
|
|||
.join(', ');
|
||||
return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||
}
|
||||
|
||||
/** Format tool summary directly from a Map<toolName, count>. */
|
||||
export function formatToolSummaryFromMap(counts: Map<string, number>): string | undefined {
|
||||
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
|
||||
if (total === 0) return undefined;
|
||||
const parts = Array.from(counts.entries())
|
||||
.map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
|
||||
.join(', ');
|
||||
return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue