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:
iliya 2026-03-06 15:27:46 +02:00
parent 9368e7639d
commit 79ea547674
11 changed files with 308 additions and 144 deletions

View file

@ -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;

View file

@ -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

View file

@ -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>
);
};

View file

@ -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,

View file

@ -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;
}}

View file

@ -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]

View file

@ -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>

View file

@ -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&apos;t support per-member model selection yet &mdash; all teammates
inherit the team launch model. We plan to solve this via a local proxy.
</p>
</div>
</div>
)}
</div>
);
};

View file

@ -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 &quot;create a task&quot; 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" />

View file

@ -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 />
)}

View file

@ -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})`;
}