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 { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { buildToolSummary } from '@shared/utils/toolSummary';
|
import { formatToolSummaryFromMap } from '@shared/utils/toolSummary';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -1424,13 +1424,29 @@ export class TeamDataService {
|
||||||
|
|
||||||
const projectId = encodePath(config.projectPath);
|
const projectId = encodePath(config.projectPath);
|
||||||
const baseDir = extractBaseDir(projectId);
|
const baseDir = extractBaseDir(projectId);
|
||||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(jsonlPath, fs.constants.F_OK);
|
await fs.promises.access(jsonlPath, fs.constants.F_OK);
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug(`Lead session JSONL not found: ${jsonlPath}`);
|
// Claude Code encodes underscores as hyphens in project directory names;
|
||||||
return [];
|
// 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';
|
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 INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
|
||||||
|
|
||||||
const textsReversed: InboxMessage[] = [];
|
const textsReversed: InboxMessage[] = [];
|
||||||
|
const seenMessageIds = new Set<string>();
|
||||||
const handle = await fs.promises.open(jsonlPath, 'r');
|
const handle = await fs.promises.open(jsonlPath, 'r');
|
||||||
try {
|
try {
|
||||||
const stat = await handle.stat();
|
const stat = await handle.stat();
|
||||||
|
|
@ -1487,7 +1504,32 @@ export class TeamDataService {
|
||||||
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
||||||
if (combined.length < MIN_TEXT_LENGTH) continue;
|
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)
|
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
|
||||||
const textPrefix = combined
|
const textPrefix = combined
|
||||||
|
|
@ -1495,6 +1537,10 @@ export class TeamDataService {
|
||||||
.replace(/[^\p{L}\p{N}]/gu, '')
|
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
|
|
||||||
|
const messageId = `lead-session-${timestamp}-${textPrefix}`;
|
||||||
|
if (seenMessageIds.has(messageId)) continue;
|
||||||
|
seenMessageIds.add(messageId);
|
||||||
|
|
||||||
textsReversed.push({
|
textsReversed.push({
|
||||||
from: leadName,
|
from: leadName,
|
||||||
text: combined,
|
text: combined,
|
||||||
|
|
@ -1502,7 +1548,7 @@ export class TeamDataService {
|
||||||
read: true,
|
read: true,
|
||||||
source: 'lead_session',
|
source: 'lead_session',
|
||||||
leadSessionId: config.leadSessionId,
|
leadSessionId: config.leadSessionId,
|
||||||
messageId: `lead-session-${timestamp}-${textPrefix}`,
|
messageId,
|
||||||
toolSummary,
|
toolSummary,
|
||||||
});
|
});
|
||||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
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 { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -61,7 +61,7 @@ const STDOUT_RING_LIMIT = 64 * 1024;
|
||||||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
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_TIMEOUT_MS = 60000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
|
|
@ -164,6 +164,8 @@ interface ProvisioningRun {
|
||||||
} | null;
|
} | null;
|
||||||
/** Monotonic counter for individual lead assistant messages. */
|
/** Monotonic counter for individual lead assistant messages. */
|
||||||
leadMsgSeq: number;
|
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. */
|
/** Throttle timestamp for emitting inbox refresh events for lead text. */
|
||||||
lastLeadTextEmitMs: number;
|
lastLeadTextEmitMs: number;
|
||||||
/**
|
/**
|
||||||
|
|
@ -1745,6 +1747,7 @@ export class TeamProvisioningService {
|
||||||
fsPhase: 'waiting_config',
|
fsPhase: 'waiting_config',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
leadMsgSeq: 0,
|
leadMsgSeq: 0,
|
||||||
|
pendingToolCounts: new Map(),
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
|
@ -2044,6 +2047,7 @@ export class TeamProvisioningService {
|
||||||
fsPhase: 'waiting_members',
|
fsPhase: 'waiting_members',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
leadMsgSeq: 0,
|
leadMsgSeq: 0,
|
||||||
|
pendingToolCounts: new Map(),
|
||||||
lastLeadTextEmitMs: 0,
|
lastLeadTextEmitMs: 0,
|
||||||
silentUserDmForward: null,
|
silentUserDmForward: null,
|
||||||
silentUserDmForwardClearHandle: null,
|
silentUserDmForwardClearHandle: null,
|
||||||
|
|
@ -2920,7 +2924,12 @@ export class TeamProvisioningService {
|
||||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
'team-lead';
|
'team-lead';
|
||||||
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
|
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 = {
|
const leadMsg: InboxMessage = {
|
||||||
from: leadName,
|
from: leadName,
|
||||||
text: cleanText,
|
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.
|
// Capture SendMessage(to: "user") tool_use blocks from assistant output.
|
||||||
// Claude Code's internal teamContext may route to "default" instead of the real team
|
// 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
|
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,15 @@ export const SortableTab = ({
|
||||||
? teamColorSet
|
? teamColorSet
|
||||||
? teamColorSet.badge
|
? teamColorSet.badge
|
||||||
: 'var(--color-surface-overlay)'
|
: 'var(--color-surface-overlay)'
|
||||||
: 'transparent',
|
: teamColorSet
|
||||||
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
|
? 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',
|
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
||||||
outlineOffset: '-1px',
|
outlineOffset: '-1px',
|
||||||
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
|
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
|
||||||
|
|
@ -125,7 +132,7 @@ export const SortableTab = ({
|
||||||
role="tab"
|
role="tab"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-selected={isActive}
|
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}
|
style={style}
|
||||||
onClick={(e) => onTabClick(tab.id, e)}
|
onClick={(e) => onTabClick(tab.id, e)}
|
||||||
onMouseDown={(e) => onMouseDown(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" />
|
<Pin className="size-3 shrink-0 text-blue-400" />
|
||||||
</span>
|
</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 && (
|
{isTeamTab && (
|
||||||
<TeamTabSectionNav
|
<TeamTabSectionNav
|
||||||
teamName={tab.teamName!}
|
teamName={tab.teamName!}
|
||||||
|
|
@ -188,7 +199,7 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface-raised)',
|
backgroundColor: 'var(--color-surface-raised)',
|
||||||
borderColor: 'var(--color-accent, #6366f1)',
|
borderColor: 'var(--color-accent, #6366f1)',
|
||||||
|
|
@ -198,7 +209,11 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0" />
|
<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>
|
</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.
|
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. */}
|
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
|
||||||
<div
|
<div
|
||||||
className="min-w-[48px] flex-1 self-stretch"
|
className="min-w-[48px] shrink-0 self-stretch"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||||
|
|
|
||||||
|
|
@ -405,7 +405,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
cliLogsTail={filteredText}
|
cliLogsTail={filteredText}
|
||||||
order="newest-first"
|
order="newest-first"
|
||||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||||
className="max-h-[320px] p-2"
|
className="max-h-[213px] p-2"
|
||||||
containerRefCallback={(el) => {
|
containerRefCallback={(el) => {
|
||||||
logContainerRef.current = el;
|
logContainerRef.current = el;
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -574,9 +574,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return result;
|
return result;
|
||||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
}, [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(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
let list = data.messages;
|
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) {
|
if (timeWindow) {
|
||||||
list = list.filter((m) => {
|
list = list.filter((m) => {
|
||||||
const ts = new Date(m.timestamp).getTime();
|
const ts = new Date(m.timestamp).getTime();
|
||||||
|
|
@ -603,7 +618,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
}, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
|
||||||
|
|
||||||
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||||
const messagesUnreadCount = useMemo(
|
const messagesUnreadCount = useMemo(
|
||||||
|
|
@ -627,11 +642,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return filterKanbanTasks(filteredTasks, query);
|
return filterKanbanTasks(filteredTasks, query);
|
||||||
}, [filteredTasks, kanbanSearch]);
|
}, [filteredTasks, kanbanSearch]);
|
||||||
|
|
||||||
const activeMembers = useMemo(
|
|
||||||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
|
||||||
[data?.members]
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeTeammateCount = useMemo(
|
const activeTeammateCount = useMemo(
|
||||||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||||||
[activeMembers]
|
[activeMembers]
|
||||||
|
|
|
||||||
|
|
@ -190,16 +190,17 @@ export const LeadThoughtsGroupRow = ({
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onVisible, thoughts]);
|
}, [onVisible, thoughts]);
|
||||||
|
|
||||||
// Stable ref for auto-scroll trigger: track content changes (new thoughts + text growth)
|
// Auto-scroll via ResizeObserver — fires after CSS animations expand content
|
||||||
const newestTextLength = newest.text.length;
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when new thoughts arrive or text grows
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUserScrolledUpRef.current) return;
|
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollTop = el.scrollHeight;
|
const observer = new ResizeObserver(() => {
|
||||||
}, [thoughts.length, newestTextLength]);
|
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 handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
|
|
@ -297,6 +298,14 @@ export const LeadThoughtsGroupRow = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||||
|
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
|
|
@ -9,7 +10,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
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 { MemberDraft } from './membersEditorTypes';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -48,6 +49,7 @@ export const MemberDraftRow = ({
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
||||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||||
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
|
|
||||||
// Pre-warm file list cache when workflow section is expanded
|
// Pre-warm file list cache when workflow section is expanded
|
||||||
useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null);
|
useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null);
|
||||||
|
|
@ -162,6 +164,19 @@ export const MemberDraftRow = ({
|
||||||
Workflow
|
Workflow
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -200,6 +215,20 @@ export const MemberDraftRow = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ export const MessageComposer = ({
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
const supportsAttachments = isLeadRecipient;
|
||||||
const canAttach = supportsAttachments && canAddMore;
|
const canAttach = supportsAttachments && canAddMore;
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
const canSend =
|
const canSend =
|
||||||
|
|
@ -263,104 +263,6 @@ export const MessageComposer = ({
|
||||||
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
||||||
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<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 ? (
|
{isLeadRecipient ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
|
@ -398,11 +300,111 @@ export const MessageComposer = ({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isTeamAlive ? (
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
{!isTeamAlive ? (
|
||||||
Team offline
|
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||||
</span>
|
Team offline
|
||||||
) : null}
|
</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>
|
</div>
|
||||||
|
|
||||||
<AttachmentPreviewList
|
<AttachmentPreviewList
|
||||||
|
|
@ -457,9 +459,6 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
footerRight={
|
footerRight={
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{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">
|
<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" />
|
<AlertCircle size={10} className="shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -595,11 +595,30 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
}
|
}
|
||||||
: style;
|
: style;
|
||||||
|
|
||||||
// --- Hint text ---
|
// --- Rotating tips ---
|
||||||
const defaultHintText = enableFiles
|
const rotatingTips = React.useMemo(
|
||||||
? 'Use @ to mention team members or search files'
|
() => [
|
||||||
: 'Use @ to mention team members';
|
'Tip: Use @ to mention team members or search files',
|
||||||
const resolvedHintText = hintText ?? defaultHintText;
|
'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 showHintRow = showHint && (suggestions.length > 0 || enableFiles);
|
||||||
const showFooter = showHintRow || footerRight;
|
const showFooter = showHintRow || footerRight;
|
||||||
|
|
||||||
|
|
@ -683,7 +702,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
||||||
{showFooter ? (
|
{showFooter ? (
|
||||||
<div className="mt-1 flex items-center justify-between">
|
<div className="mt-1 flex items-center justify-between">
|
||||||
{showHintRow ? (
|
{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 />
|
<span />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,13 @@ export function formatToolSummary(data: ToolSummaryData): string {
|
||||||
.join(', ');
|
.join(', ');
|
||||||
return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`;
|
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