feat: update TeamProvisioningService and UI components for enhanced user experience and functionality

- Increased PREFLIGHT_TIMEOUT_MS to 60 seconds for improved preflight checks.
- Updated PREFLIGHT_PING_PROMPT for clarity and added new arguments to PREFLIGHT_PING_ARGS for enhanced probing.
- Introduced isTransientProbeWarning function to better handle transient warnings during probe operations.
- Enhanced readiness messages in TeamProvisioningService to provide clearer feedback based on warning states.
- Improved ClaudeLogsSection to reflect team status more accurately in log messages.
- Simplified message sending behavior in SendMessageDialog and TaskCommentInput by removing modifier key references.
- Added subagent message preview functionality in MemberLogsTab for better task tracking and visibility.
This commit is contained in:
iliya 2026-03-05 21:02:56 +02:00
parent 6a67838d20
commit 218189b241
10 changed files with 627 additions and 247 deletions

View file

@ -62,7 +62,7 @@ const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000;
// const CLI_PREPARE_TIMEOUT_MS = 10000;
const PROBE_CACHE_TTL_MS = 10 * 60_000;
const PREFLIGHT_TIMEOUT_MS = 30000;
const PREFLIGHT_TIMEOUT_MS = 60000;
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
const FS_MONITOR_POLL_MS = 2000;
@ -70,8 +70,20 @@ const TASK_WAIT_FALLBACK_MS = 15_000;
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
const PREFLIGHT_PING_PROMPT = 'Reply with the single word PONG and nothing else';
const PREFLIGHT_PING_ARGS = ['-p', PREFLIGHT_PING_PROMPT, '--output-format', 'text'] as const;
const PREFLIGHT_PING_PROMPT = 'Output only the single word PONG.';
const PREFLIGHT_PING_ARGS = [
'-p',
PREFLIGHT_PING_PROMPT,
'--output-format',
'text',
'--model',
'haiku',
'--max-turns',
'1',
'--max-budget-usd',
'0.05',
'--no-session-persistence',
] as const;
const PREFLIGHT_EXPECTED = 'PONG';
type TeamsBaseLocation = 'configured' | 'default';
@ -1008,8 +1020,23 @@ interface CachedProbeResult {
}
let cachedProbeResult: CachedProbeResult | null = null;
let probeInFlight: Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> | null =
null;
let probeInFlight: Promise<{
claudePath: string;
authSource: ProvisioningAuthSource;
warning?: string;
} | null> | null = null;
function isTransientProbeWarning(warning: string): boolean {
const lower = warning.toLowerCase();
return (
lower.includes('timeout running:') ||
lower.includes('did not complete') ||
lower.includes('timed out') ||
lower.includes('etimedout') ||
lower.includes('econnreset') ||
lower.includes('eai_again')
);
}
export class TeamProvisioningService {
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
@ -1046,7 +1073,9 @@ export class TeamProvisioningService {
const offsetRaw = query?.offset ?? 0;
const limitRaw = query?.limit ?? 100;
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
: 100;
const total = run.claudeLogLines.length;
if (total === 0) {
@ -1057,8 +1086,10 @@ export class TeamProvisioningService {
const oldestInclusive = Math.max(0, newestExclusive - limit);
const normalizeLine = (line: string): string => {
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length);
if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length);
if (line.startsWith('[stdout] ') && line !== '[stdout]')
return line.slice('[stdout] '.length);
if (line.startsWith('[stderr] ') && line !== '[stderr]')
return line.slice('[stderr] '.length);
return line;
};
@ -1201,7 +1232,8 @@ export class TeamProvisioningService {
async warmup(): Promise<void> {
try {
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) return;
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS)
return;
const result = await this.getCachedOrProbeResult(process.cwd());
if (!result) return;
logger.info('CLI warmup completed');
@ -1226,7 +1258,11 @@ export class TeamProvisioningService {
const ready = !warning || authSource !== 'none' || !isAuthFailure;
return {
ready,
message: ready ? 'CLI is warmed up and ready to launch' : warning || 'CLI is not ready',
message: ready
? warnings.length > 0
? 'CLI is ready to launch (see notes)'
: 'CLI is warmed up and ready to launch'
: warning || 'CLI is not ready',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
@ -1267,7 +1303,10 @@ export class TeamProvisioningService {
return {
ready: true,
message: 'CLI is warmed up and ready to launch',
message:
warnings.length > 0
? 'CLI is ready to launch (see notes)'
: 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
};
}
@ -1287,7 +1326,11 @@ export class TeamProvisioningService {
): Promise<{ claudePath: string; authSource: ProvisioningAuthSource; warning?: string } | null> {
const cached = this.getFreshCachedProbeResult();
if (cached) {
return { claudePath: cached.claudePath, authSource: cached.authSource, warning: cached.warning };
return {
claudePath: cached.claudePath,
authSource: cached.authSource,
warning: cached.warning,
};
}
if (probeInFlight) {
@ -1300,12 +1343,20 @@ export class TeamProvisioningService {
const { env, authSource } = await this.buildProvisioningEnv();
const probe = await this.probeClaudeRuntime(claudePath, cwd, env);
const result = { claudePath, authSource, ...(probe.warning ? { warning: probe.warning } : {}) };
const result = {
claudePath,
authSource,
...(probe.warning ? { warning: probe.warning } : {}),
};
if (!probe.warning || !this.isAuthFailureWarning(probe.warning)) {
const shouldCache =
!probe.warning ||
(!this.isAuthFailureWarning(probe.warning) && !isTransientProbeWarning(probe.warning));
if (shouldCache) {
cachedProbeResult = { ...result, cachedAtMs: Date.now() };
} else {
// Don't pin auth failures in cache — user may log in externally and retry.
// Don't pin auth failures / transient failures in cache — user may fix and retry.
cachedProbeResult = null;
}
@ -2819,7 +2870,6 @@ export class TeamProvisioningService {
'team-lead';
const leadMsg: InboxMessage = {
from: leadName,
to: 'user',
text: cleanText,
timestamp: nowIso(),
read: true,
@ -2831,7 +2881,10 @@ export class TeamProvisioningService {
run.leadTextPushedInCurrentTurn = true;
const now = Date.now();
if (now - run.lastLeadTextEmitMs >= TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS) {
if (
now - run.lastLeadTextEmitMs >=
TeamProvisioningService.LEAD_TEXT_EMIT_THROTTLE_MS
) {
run.lastLeadTextEmitMs = now;
this.teamChangeEmitter?.({
type: 'inbox',
@ -3126,9 +3179,7 @@ export class TeamProvisioningService {
| undefined;
const trigger = typeof meta?.trigger === 'string' ? meta.trigger : 'auto';
const preTokens = typeof meta?.pre_tokens === 'number' ? meta.pre_tokens : null;
const tokenInfo = preTokens
? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)`
: '';
const tokenInfo = preTokens ? ` (was ~${(preTokens / 1000).toFixed(0)}k tokens)` : '';
const compactMsg: InboxMessage = {
from: 'system',
@ -3160,7 +3211,12 @@ export class TeamProvisioningService {
private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise<void> {
// Guard: must be set synchronously BEFORE any await to prevent
// double-invocation from filesystem monitor + stream-json racing.
if (run.provisioningComplete || run.cancelRequested || run.processKilled || run.progress.state === 'failed')
if (
run.provisioningComplete ||
run.cancelRequested ||
run.processKilled ||
run.progress.state === 'failed'
)
return;
// Prevent false "ready" when auth failure was printed as assistant text or logs
@ -3172,7 +3228,11 @@ export class TeamProvisioningService {
.filter(Boolean)
.join('\n')
.trim();
if (preCompleteText && this.hasApiError(preCompleteText) && !this.isAuthFailureWarning(preCompleteText)) {
if (
preCompleteText &&
this.hasApiError(preCompleteText) &&
!this.isAuthFailureWarning(preCompleteText)
) {
this.failProvisioningWithApiError(run, preCompleteText);
return;
}
@ -3976,7 +4036,9 @@ export class TeamProvisioningService {
if (membersRaw.length > 0) {
const teammateNames = membersRaw
.map((m) => (typeof m.name === 'string' ? m.name.trim() : ''))
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
.filter(
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
);
const keepName = createCliAutoSuffixNameGuard(teammateNames);
const nextMembers: Record<string, unknown>[] = [];
@ -4015,7 +4077,9 @@ export class TeamProvisioningService {
const activeNames = metaMembers
.filter((m) => !m.removedAt)
.map((m) => m.name.trim())
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user');
.filter(
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
);
const keepName = createCliAutoSuffixNameGuard(activeNames);
const removedFromMeta: string[] = [];
@ -4042,7 +4106,9 @@ export class TeamProvisioningService {
nextMeta
.filter((m) => !m.removedAt)
.map((m) => m.name.trim())
.filter((n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user')
.filter(
(n) => n.length > 0 && n.toLowerCase() !== 'team-lead' && n.toLowerCase() !== 'user'
)
);
}
} catch {
@ -4658,7 +4724,13 @@ export class TeamProvisioningService {
[...PREFLIGHT_PING_ARGS],
cwd,
env,
PREFLIGHT_TIMEOUT_MS
PREFLIGHT_TIMEOUT_MS,
{
resolveOnOutputMatch: ({ stdout, stderr }) => {
const combined = `${stdout}\n${stderr}`.trim();
return /\bPONG\b/i.test(combined);
},
}
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@ -4705,7 +4777,7 @@ export class TeamProvisioningService {
}
const pongCandidate = pingProbe.stdout.trim() || pingProbe.stderr.trim();
const isPong = pongCandidate.toUpperCase() === PREFLIGHT_EXPECTED;
const isPong = new RegExp(`\\b${PREFLIGHT_EXPECTED}\\b`, 'i').test(pongCandidate);
if (!isPong) {
return {
warning:
@ -4730,7 +4802,15 @@ export class TeamProvisioningService {
args: string[],
cwd: string,
env: NodeJS.ProcessEnv,
timeoutMs: number
timeoutMs: number,
options?: {
/**
* Optional early success predicate. If this returns true based on
* buffered stdout/stderr, the probe resolves immediately (and the process
* is best-effort terminated) instead of waiting for `close`.
*/
resolveOnOutputMatch?: (ctx: { stdout: string; stderr: string }) => boolean;
}
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawnCli(claudePath, args, {
@ -4738,26 +4818,52 @@ export class TeamProvisioningService {
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutText = '';
let stderrText = '';
let settled = false;
const timeoutHandle = setTimeout(() => {
settled = true;
killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
}, timeoutMs);
child.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
const maybeResolveEarly = (): void => {
if (settled) return;
if (!options?.resolveOnOutputMatch) return;
const ctx = { stdout: stdoutText.trim(), stderr: stderrText.trim() };
if (!options.resolveOnOutputMatch(ctx)) return;
settled = true;
clearTimeout(timeoutHandle);
// If the process printed the match but hangs during teardown, don't
// block the UI; terminate best-effort and resolve.
killProcessTree(child);
resolve({ exitCode: 0, stdout: ctx.stdout, stderr: ctx.stderr });
};
child.stdout?.on('data', (chunk: Buffer) => {
stdoutText += chunk.toString('utf8');
maybeResolveEarly();
});
child.stderr?.on('data', (chunk: Buffer) => {
stderrText += chunk.toString('utf8');
maybeResolveEarly();
});
child.once('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
reject(error);
});
child.once('close', (exitCode) => {
if (settled) return;
settled = true;
clearTimeout(timeoutHandle);
resolve({
exitCode,
stdout: Buffer.concat(stdoutChunks).toString('utf8').trim(),
stderr: Buffer.concat(stderrChunks).toString('utf8').trim(),
stdout: stdoutText.trim(),
stderr: stderrText.trim(),
});
});
});

View file

@ -3,14 +3,12 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { Search, Terminal, X } from 'lucide-react';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { CliLogsRichView } from './CliLogsRichView';
import {
ClaudeLogsFilterPopover,
DEFAULT_CLAUDE_LOGS_FILTER,
} from './ClaudeLogsFilterPopover';
import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
import type { TeamClaudeLogsResponse } from '@shared/types';
import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
@ -108,7 +106,10 @@ function filterStreamJsonText(
return null;
};
const writeBlocks = (parsed: Record<string, unknown>, blocks: AssistantContentBlock[]): Record<string, unknown> => {
const writeBlocks = (
parsed: Record<string, unknown>,
blocks: AssistantContentBlock[]
): Record<string, unknown> => {
if (Array.isArray(parsed.content)) {
return { ...parsed, content: blocks };
}
@ -188,6 +189,7 @@ function filterStreamJsonText(
}
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
@ -233,12 +235,16 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
useEffect(() => {
let cancelled = false;
const computeNewCount = (committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse): number => {
const computeNewCount = (
committed: TeamClaudeLogsResponse,
latest: TeamClaudeLogsResponse
): number => {
if (committed.lines.length === 0) return latest.lines.length;
const marker = committed.lines[0];
const idx = latest.lines.indexOf(marker);
if (idx >= 0) return idx;
const diff = (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
const diff =
(latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length);
return Math.max(0, diff);
};
@ -331,8 +337,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
<span className="font-mono">{data.total}</span>
</>
) : (
) : isAlive ? (
'No logs yet.'
) : (
'Team is not running.'
)}
</span>
<div className="flex items-center gap-2">
@ -366,7 +374,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
className="h-7 border-blue-500/30 bg-blue-600 px-2 text-xs text-white hover:bg-blue-500"
onClick={applyPending}
title="Show newest logs"
>
@ -386,12 +394,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
</div>
</div>
<div
className={cn(
'rounded',
loading && 'opacity-80'
)}
>
<div className={cn('rounded', loading && 'opacity-80')}>
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
{!error && filteredText.trim().length > 0 ? (
<CliLogsRichView
@ -414,16 +417,13 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
) : null}
{!error && data.lines.length === 0 ? (
<p className="p-2 text-xs text-[var(--color-text-muted)]">
{loading ? 'Loading…' : 'No logs captured.'}
{loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}
</p>
) : null}
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
<p className="p-2 text-xs text-[var(--color-text-muted)]">
No matching logs.
</p>
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
) : null}
</div>
</CollapsibleTeamSection>
);
};

View file

@ -26,7 +26,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge';
@ -129,7 +128,16 @@ export const SendMessageDialog = ({
}
}
prevOpenRef.current = open;
}, [open, defaultRecipient, defaultText, defaultChip, quotedMessage, lastResult, textDraft, chipDraft]);
}, [
open,
defaultRecipient,
defaultText,
defaultChip,
quotedMessage,
lastResult,
textDraft,
chipDraft,
]);
// Track whether auto-close is needed (avoid setState in render)
useEffect(() => {
@ -381,7 +389,7 @@ export const SendMessageDialog = ({
<MentionableTextarea
id="smd-message"
className={quote ? 'rounded-t-none' : undefined}
placeholder={`Write your message... (${getModifierKeyName()}+Enter to send)`}
placeholder="Write your message... (Enter to send)"
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
@ -421,7 +429,9 @@ export const SendMessageDialog = ({
</span>
) : null}
{textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved
</span>
) : null}
</div>
}
@ -442,7 +452,6 @@ export const SendMessageDialog = ({
Shown as notification preview. Team lead also sees this for peer messages.
</p>
</div>
</div>
<DialogFooter>

View file

@ -7,7 +7,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useStore } from '@renderer/store';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
@ -71,50 +70,45 @@ export const TaskCommentInput = ({
trimmed.length <= MAX_COMMENT_LENGTH &&
!addingComment;
const addFiles = useCallback(
(files: FileList | File[]) => {
setAttachError(null);
const fileArray = Array.from(files);
for (const file of fileArray) {
if (!ACCEPTED_TYPES.has(file.type)) {
setAttachError(`Unsupported type: ${file.type}`);
continue;
}
if (file.size > MAX_FILE_SIZE) {
setAttachError(
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
);
continue;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
if (!base64) return;
const id = crypto.randomUUID();
setPendingAttachments((prev) => {
if (prev.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
return prev;
}
return [
...prev,
{
id,
filename: file.name,
mimeType: file.type,
base64Data: base64,
previewUrl: result,
size: file.size,
},
];
});
};
reader.readAsDataURL(file);
const addFiles = useCallback((files: FileList | File[]) => {
setAttachError(null);
const fileArray = Array.from(files);
for (const file of fileArray) {
if (!ACCEPTED_TYPES.has(file.type)) {
setAttachError(`Unsupported type: ${file.type}`);
continue;
}
},
[]
);
if (file.size > MAX_FILE_SIZE) {
setAttachError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`);
continue;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1];
if (!base64) return;
const id = crypto.randomUUID();
setPendingAttachments((prev) => {
if (prev.length >= MAX_ATTACHMENTS) {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
return prev;
}
return [
...prev,
{
id,
filename: file.name,
mimeType: file.type,
base64Data: base64,
previewUrl: result,
size: file.size,
},
];
});
};
reader.readAsDataURL(file);
}
}, []);
const removeAttachment = useCallback((id: string) => {
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
@ -250,7 +244,7 @@ export const TaskCommentInput = ({
/>
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
placeholder="Add a comment... (Enter to send)"
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}

View file

@ -13,7 +13,6 @@ import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
@ -169,125 +168,128 @@ export const TaskCommentsSection = ({
) : null}
<div className={containerClassName ?? ''}>
{visibleComments.map((comment, index) => (
<div
key={comment.id}
className={[
'group px-4 py-2.5',
comment.type === 'review_approved'
? 'border-y border-emerald-500/20 bg-emerald-500/5'
: comment.type === 'review_request'
? 'border-y border-blue-500/20 bg-blue-500/5'
: '',
].join(' ')}
style={
!comment.type || comment.type === 'regular'
? { backgroundColor: index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)' }
: undefined
}
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
{comment.type === 'review_approved' ? (
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
<CheckCircle2 size={10} />
Approved
{visibleComments.map((comment, index) => (
<div
key={comment.id}
className={[
'group px-4 py-2.5',
comment.type === 'review_approved'
? 'border-y border-emerald-500/20 bg-emerald-500/5'
: comment.type === 'review_request'
? 'border-y border-blue-500/20 bg-blue-500/5'
: '',
].join(' ')}
style={
!comment.type || comment.type === 'regular'
? {
backgroundColor:
index % 2 === 1 ? 'var(--card-bg-zebra)' : 'var(--card-bg)',
}
: undefined
}
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<MemberBadge name={comment.author} color={colorMap.get(comment.author)} />
{comment.type === 'review_approved' ? (
<span className="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
<CheckCircle2 size={10} />
Approved
</span>
) : comment.type === 'review_request' ? (
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
<Eye size={10} />
Review requested
</span>
) : null}
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
) : comment.type === 'review_request' ? (
<span className="inline-flex items-center gap-0.5 rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
<Eye size={10} />
Review requested
</span>
) : null}
<span>
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
? 'unknown time'
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => {
const replyText = stripAgentBlocks(
parseMessageReply(comment.text)?.replyText ?? comment.text
);
if (onReply) {
onReply(comment.author, replyText);
} else {
setReplyTo({ author: comment.author, text: replyText });
}
}}
>
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
return (
<ExpandableContent collapsedHeight={120} className="text-xs">
{reply ? (
<ReplyQuoteBlock
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
onClick={() => {
const replyText = stripAgentBlocks(
parseMessageReply(comment.text)?.replyText ?? comment.text
);
if (onReply) {
onReply(comment.author, replyText);
} else {
setReplyTo({ author: comment.author, text: replyText });
}
}}
memberColor={colorMap.get(reply.agentName)}
bodyMaxHeight="max-h-none"
/>
) : (
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'a[href^="task://"]'
);
if (link) {
e.preventDefault();
e.stopPropagation();
const id = link.getAttribute('href')?.replace('task://', '');
if (id) onTaskIdClick(id);
}
}
: undefined
}
>
<MarkdownViewer
content={(() => {
let t = linkifyTaskIdsInMarkdown(displayText);
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
return t;
})()}
maxHeight="max-h-none"
bare
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);
const rawForDisplay = reply ? reply.replyText : comment.text;
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
return (
<ExpandableContent collapsedHeight={120} className="text-xs">
{reply ? (
<ReplyQuoteBlock
reply={{
...reply,
originalText: stripAgentBlocks(reply.originalText),
replyText: stripAgentBlocks(reply.replyText),
}}
memberColor={colorMap.get(reply.agentName)}
bodyMaxHeight="max-h-none"
/>
</span>
)}
</ExpandableContent>
);
})()}
{comment.attachments && comment.attachments.length > 0 ? (
<CommentAttachments
attachments={comment.attachments}
teamName={teamName}
taskId={taskId}
onPreview={setPreviewImageUrl}
/>
) : null}
</div>
))}
) : (
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'a[href^="task://"]'
);
if (link) {
e.preventDefault();
e.stopPropagation();
const id = link.getAttribute('href')?.replace('task://', '');
if (id) onTaskIdClick(id);
}
}
: undefined
}
>
<MarkdownViewer
content={(() => {
let t = linkifyTaskIdsInMarkdown(displayText);
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
return t;
})()}
maxHeight="max-h-none"
bare
/>
</span>
)}
</ExpandableContent>
);
})()}
{comment.attachments && comment.attachments.length > 0 ? (
<CommentAttachments
attachments={comment.attachments}
teamName={teamName}
taskId={taskId}
onPreview={setPreviewImageUrl}
/>
) : null}
</div>
))}
</div>
{sortedComments.length > visibleComments.length ? (
@ -347,7 +349,7 @@ export const TaskCommentsSection = ({
<div className="relative">
<MentionableTextarea
id={`task-comment-${taskId}`}
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
placeholder="Add a comment... (Enter to send)"
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}

View file

@ -100,6 +100,7 @@ export const TaskDetailDialog = ({
const updateTaskFields = useStore((s) => s.updateTaskFields);
const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
// Inline editing: subject
const [editingSubject, setEditingSubject] = useState(false);
@ -289,6 +290,11 @@ export const TaskDetailDialog = ({
.map((t) => t.id);
const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange;
const leadName =
members.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead')?.name ?? 'team-lead';
const isLeadOwnedTask =
(currentTask.owner ?? '').trim().toLowerCase() === leadName.trim().toLowerCase() ||
(currentTask.owner ?? '').trim().toLowerCase() === 'team-lead';
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -647,10 +653,23 @@ export const TaskDetailDialog = ({
title="Execution Logs"
icon={<ScrollText size={14} />}
headerExtra={
logsRefreshing ? (
<span className="flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<Loader2 size={10} className="animate-spin" />
Updating...
logsRefreshing || executionPreviewOnline ? (
<span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{executionPreviewOnline ? (
<span
className="pointer-events-none relative inline-flex size-2 shrink-0"
title="Online"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
{logsRefreshing ? (
<span className="flex items-center gap-1">
<Loader2 size={10} className="animate-spin" />
Updating...
</span>
) : null}
</span>
) : null
}
@ -667,6 +686,11 @@ export const TaskDetailDialog = ({
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
onRefreshingChange={setLogsRefreshing}
// Only show a "latest messages" preview when this task is owned by a subagent.
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
// so filtering to "just the member messages" is unreliable and easy to mislead.
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
onPreviewOnlineChange={setExecutionPreviewOnline}
/>
</div>
</CollapsibleTeamSection>

View file

@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
SubagentRecentMessagesPreview,
type SubagentPreviewMessage,
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDuration } from '@renderer/utils/formatters';
import {
@ -14,6 +18,9 @@ import {
MessageSquare,
} from 'lucide-react';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types';
@ -28,6 +35,10 @@ interface MemberLogsTabProps {
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
/** Notifies parent when a background refresh starts/ends. */
onRefreshingChange?: (isRefreshing: boolean) => void;
/** Show last few subagent messages as a quick "where are we?" preview (task view only). */
showSubagentPreview?: boolean;
/** Notifies parent when preview looks "online" (recent output). */
onPreviewOnlineChange?: (isOnline: boolean) => void;
}
export const MemberLogsTab = ({
@ -38,6 +49,8 @@ export const MemberLogsTab = ({
taskStatus,
taskWorkIntervals,
onRefreshingChange,
showSubagentPreview = false,
onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => {
const intervalsKey = useMemo(
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
@ -52,6 +65,7 @@ export const MemberLogsTab = ({
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
const getRowId = useCallback((log: MemberLogSummary): string => {
return log.kind === 'subagent'
@ -82,6 +96,35 @@ export const MemberLogsTab = ({
return withIndex.map((x) => x.log);
}, [logs]);
const previewLog = useMemo((): MemberLogSummary | null => {
if (!showSubagentPreview || taskId == null) return null;
const candidates = sortedLogs.filter((l) => l.kind === 'subagent');
if (candidates.length === 0) return null;
if (taskOwner) {
const target = taskOwner.trim().toLowerCase();
const match = candidates.find((l) => (l.memberName ?? '').trim().toLowerCase() === target);
// When viewing task logs, this preview is intended to show the assigned owner's progress.
// If we can't confidently match a subagent log to the owner, don't show anything
// rather than risk showing a different member's activity (or a lead-attributed log).
return match ?? null;
}
return candidates[0] ?? null;
}, [showSubagentPreview, sortedLogs, taskId, taskOwner]);
const previewMessages = useMemo((): SubagentPreviewMessage[] => {
if (!previewChunks || previewChunks.length === 0) return [];
return extractSubagentPreviewMessages(previewChunks, 4);
}, [previewChunks]);
const previewOnline = useMemo((): boolean => {
const newest = previewMessages[0];
if (!newest) return false;
return Date.now() - newest.timestamp.getTime() <= 10_000;
}, [previewMessages]);
const expandedLogSummary = useMemo(() => {
if (!expandedId) return null;
return logs.find((log) => getRowId(log) === expandedId) ?? null;
@ -92,6 +135,14 @@ export const MemberLogsTab = ({
return () => onRefreshingChange?.(false);
}, [refreshing, onRefreshingChange]);
useEffect(() => {
onPreviewOnlineChange?.(previewOnline);
}, [onPreviewOnlineChange, previewOnline]);
useEffect(() => {
return () => onPreviewOnlineChange?.(false);
}, [onPreviewOnlineChange]);
useEffect(() => {
if (!expandedId) return;
if (expandedLogSummary) return;
@ -152,14 +203,68 @@ export const MemberLogsTab = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey drives refresh; deps intentionally minimal to avoid refetch loops
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
const fetchDetailForLog = useCallback(async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
return (d?.chunks ?? null) as EnhancedChunk[] | null;
const fetchDetailForLog = useCallback(
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
return (d?.chunks ?? null) as EnhancedChunk[] | null;
}
const d = await api.getSessionDetail(log.projectId, log.sessionId);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
},
[]
);
useEffect(() => {
if (!showSubagentPreview || taskId == null) {
setPreviewChunks(null);
return;
}
const d = await api.getSessionDetail(log.projectId, log.sessionId);
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
}, []);
if (!previewLog) {
setPreviewChunks(null);
return;
}
let cancelled = false;
const run = async (): Promise<void> => {
try {
const next = await fetchDetailForLog(previewLog);
if (cancelled) return;
setPreviewChunks(next ? [...next] : null);
} catch {
if (cancelled) return;
setPreviewChunks(null);
}
};
void run();
return () => {
cancelled = true;
};
}, [fetchDetailForLog, previewLog, showSubagentPreview, taskId]);
useEffect(() => {
if (!showSubagentPreview || taskId == null) return;
if (!previewLog) return;
const shouldAutoRefreshPreview = taskStatus === 'in_progress' || previewLog.isOngoing;
if (!shouldAutoRefreshPreview) return;
let cancelled = false;
const interval = setInterval(async () => {
try {
const next = await fetchDetailForLog(previewLog);
if (cancelled) return;
setPreviewChunks(next ? [...next] : null);
} catch {
// keep last successful preview
}
}, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [fetchDetailForLog, previewLog, showSubagentPreview, taskId, taskStatus]);
useEffect(() => {
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
@ -245,17 +350,19 @@ export const MemberLogsTab = ({
return (
<div className="w-full min-w-0 space-y-1.5">
{showSubagentPreview && previewLog && previewMessages.length > 0 ? (
<SubagentRecentMessagesPreview
messages={previewMessages}
memberName={previewLog.memberName ?? undefined}
/>
) : null}
{sortedLogs.map((log) => (
<LogCard
key={getRowId(log)}
log={log}
expanded={expandedId === getRowId(log)}
detailChunks={
expandedId === getRowId(log) ? detailChunks : null
}
detailLoading={
expandedId === getRowId(log) && detailLoading
}
detailChunks={expandedId === getRowId(log) ? detailChunks : null}
detailLoading={expandedId === getRowId(log) && detailLoading}
onToggle={() => void handleExpand(log)}
/>
))}
@ -358,3 +465,54 @@ function formatRelativeTime(isoString: string): string {
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function extractSubagentPreviewMessages(
chunks: EnhancedChunk[],
limit: number
): SubagentPreviewMessage[] {
const conversation = transformChunksToConversation(chunks, [], false);
const out: SubagentPreviewMessage[] = [];
// Collect newest-first and stop as soon as we have enough.
for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) {
const item = conversation.items[i];
if (item.type === 'ai') {
const enhanced = enhanceAIGroup(item.group);
const items = enhanced.displayItems ?? [];
for (let j = items.length - 1; j >= 0 && out.length < limit; j--) {
const di = items[j];
if (di.type === 'output' && di.content.trim()) {
out.push({
id: `${item.group.id}:output:${di.timestamp.toISOString()}:${j}`,
timestamp: di.timestamp,
kind: 'output',
label: 'Output',
content: di.content,
});
} else if (di.type === 'teammate_message') {
out.push({
id: `${item.group.id}:teammate:${di.teammateMessage.id}`,
timestamp: di.teammateMessage.timestamp,
kind: 'teammate_message',
label: `Message — ${di.teammateMessage.teammateId}`,
content: di.teammateMessage.content || di.teammateMessage.summary,
});
}
}
} else if (item.type === 'user') {
const text = item.group.content.rawText ?? item.group.content.text ?? '';
if (text.trim()) {
out.push({
id: `${item.group.id}:user:${item.group.timestamp.toISOString()}`,
timestamp: item.group.timestamp,
kind: 'user',
label: 'User',
content: text,
});
}
}
}
return out;
}

View file

@ -0,0 +1,80 @@
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { format } from 'date-fns';
export type SubagentPreviewMessageKind =
| 'output'
| 'text'
| 'tool_result'
| 'interruption'
| 'plan_exit'
| 'teammate_message'
| 'user'
| 'unknown';
export interface SubagentPreviewMessage {
id: string;
timestamp: Date;
kind: SubagentPreviewMessageKind;
/** Optional short label (e.g. tool name). */
label?: string;
content: string;
}
interface SubagentRecentMessagesPreviewProps {
messages: SubagentPreviewMessage[];
memberName?: string;
}
export const SubagentRecentMessagesPreview = ({
messages,
memberName,
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
if (!messages.length) return null;
return (
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
Latest messages{memberName ? `${memberName}` : ''}
</div>
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{format(messages[0].timestamp, 'h:mm:ss a')}
</div>
</div>
<div className="space-y-2">
{messages.map((m) => (
<div
key={m.id}
className="rounded border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-[10px] text-[var(--color-text-muted)]">
{m.label ? (
<span className="rounded bg-[var(--color-surface)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-secondary)]">
{m.label}
</span>
) : (
<span className="font-mono">{m.kind}</span>
)}
</div>
<div className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{format(m.timestamp, 'h:mm:ss a')}
</div>
</div>
{m.kind === 'tool_result' ? (
<pre className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] text-[var(--color-text)]">
{m.content}
</pre>
) : (
<div className="max-h-40 overflow-y-auto text-xs text-[var(--color-text)]">
<MarkdownViewer content={m.content} copyable />
</div>
)}
</div>
))}
</div>
</div>
);
};

View file

@ -13,7 +13,6 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
@ -49,7 +48,10 @@ const ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="relative flex shrink-0 cursor-default items-center justify-center" style={{ width: size, height: size }}>
<div
className="relative flex shrink-0 cursor-default items-center justify-center"
style={{ width: size, height: size }}
>
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
@ -189,7 +191,7 @@ export const MessageComposer = ({
const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
handleSend();
@ -290,7 +292,10 @@ export const MessageComposer = ({
>
{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)]" />
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
ref={recipientSearchRef}
type="text"
@ -392,7 +397,9 @@ export const MessageComposer = ({
) : null}
{!isTeamAlive ? (
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>Team offline</span>
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline
</span>
) : null}
</div>
@ -406,7 +413,7 @@ export const MessageComposer = ({
<MentionableTextarea
id={`compose-${teamName}`}
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`}
placeholder="Write a message... (Enter to send)"
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}

View file

@ -204,7 +204,7 @@ interface MentionableTextareaProps extends Omit<
projectPath?: string | null;
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
onFileChipInsert?: (chip: InlineChip) => void;
/** Called when Cmd+Enter (Mac) / Ctrl+Enter (Win/Linux) is pressed. */
/** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void;
}
@ -503,8 +503,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
// Composed key handler: Mod+Enter submit → chip logic → mention logic
const composedHandleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Mod+Enter (Cmd on Mac, Ctrl on Win/Linux) → submit
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && onModEnter) {
// Enter (without Shift) → submit; Shift+Enter → newline
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
e.preventDefault();
onModEnter();
return;