From 79ea5476747ce0492489147be70b8cd076415774 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 6 Mar 2026 15:27:46 +0200 Subject: [PATCH] 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. --- src/main/services/team/TeamDataService.ts | 58 ++++- .../services/team/TeamProvisioningService.ts | 28 ++- .../components/layout/SortableTab.tsx | 27 ++- src/renderer/components/layout/TabBar.tsx | 2 +- .../components/team/ClaudeLogsSection.tsx | 2 +- .../components/team/TeamDetailView.tsx | 22 +- .../team/activity/LeadThoughtsGroup.tsx | 23 +- .../team/members/MemberDraftRow.tsx | 31 ++- .../team/messages/MessageComposer.tsx | 213 +++++++++--------- .../components/ui/MentionableTextarea.tsx | 36 ++- src/shared/utils/toolSummary.ts | 10 + 11 files changed, 308 insertions(+), 144 deletions(-) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 533a041d..097a8239 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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(); 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[]); + // 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(); + 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; + try { + tMsg = JSON.parse(tLine) as Record; + } catch { + continue; + } + if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines + const tMessage = (tMsg.message ?? tMsg) as Record; + const tContent = tMessage.content; + if (!Array.isArray(tContent)) continue; + const tBlocks = tContent as Record[]; + 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; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 09ff87c7..ec737a22 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; /** 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 diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 7ee062cf..2f2e3f64 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -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 = ({ )} - {tab.label} + 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`} + > + {tab.label} + {isTeamTab && ( { return (
{ }} > - {tab.label} + 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`} + > + {tab.label} +
); }; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 74b5f215..5b59053c 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -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. */}
{ logContainerRef.current = el; }} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index b64f145a..94f1adde 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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] diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index d5672beb..a32e105d 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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 = ({ />
+ {thought.toolSummary && ( +
+ 🔧 {thought.toolSummary} +
+ )} ))} diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 470c5562..f18f485a 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -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 ) : null} + - - { - e.preventDefault(); - setRecipientSearch(''); - setTimeout(() => recipientSearchRef.current?.focus(), 0); - }} - > - {members.length > 5 && ( -
- - setRecipientSearch(e.target.value)} - /> -
- )} -
- {/* 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 ( -
- No results -
- ); - } - return filtered.map((m) => { - const resolvedColor = colorMap.get(m.name); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - }); - })()} -
-
- - {isLeadRecipient ? ( <> ) : null} - {!isTeamAlive ? ( - - Team offline - - ) : null} +
+ {!isTeamAlive ? ( + + Team offline + + ) : null} + + + + + + { + e.preventDefault(); + setRecipientSearch(''); + setTimeout(() => recipientSearchRef.current?.focus(), 0); + }} + > + {members.length > 5 && ( +
+ + setRecipientSearch(e.target.value)} + /> +
+ )} +
+ {/* 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 ( +
+ No results +
+ ); + } + return filtered.map((m) => { + const resolvedColor = colorMap.get(m.name); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const isSelected = m.name === recipient; + return ( + + ); + }); + })()} +
+
+
+
- - Mention "create a task" to add it to the board - {sendError ? ( diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index c51e7d7d..c20fc142 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -595,11 +595,30 @@ export const MentionableTextarea = React.forwardRef [ + '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 {showHintRow ? ( - {resolvedHintText} + + {resolvedHintText} + ) : ( )} diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts index 84bccbb5..ef50bfbc 100644 --- a/src/shared/utils/toolSummary.ts +++ b/src/shared/utils/toolSummary.ts @@ -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. */ +export function formatToolSummaryFromMap(counts: Map): 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})`; +}