feat: update task comment and message length limits to use centralized constant

- Replaced hardcoded maximum lengths for comments and messages with a centralized constant `MAX_TEXT_LENGTH` for consistency across components.
- Enhanced README to provide a brief overview of the project.
- Introduced a new function to build a compact members roster for improved team member display in reminders.
- Added support for displaying image attachments from comments in the task detail dialog.
This commit is contained in:
iliya 2026-03-07 01:04:00 +02:00
parent dba2d98923
commit 748d6a7b81
15 changed files with 837 additions and 62 deletions

View file

@ -19,6 +19,7 @@
</p>
<br />
## What is this
A new approach to task management with AI agents.

View file

@ -60,6 +60,7 @@ import {
} from '@preload/constants/ipcChannels';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
@ -2031,8 +2032,8 @@ async function handleAddTaskComment(
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
if (typeof text !== 'string' || text.trim().length === 0)
return { success: false, error: 'Comment text must be non-empty' };
if (text.trim().length > 2000)
return { success: false, error: 'Comment exceeds 2000 characters' };
if (text.trim().length > MAX_TEXT_LENGTH)
return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` };
const rawAttachments = Array.isArray(attachments) ? attachments : [];
if (rawAttachments.length > MAX_ATTACHMENTS) {

View file

@ -416,6 +416,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
.join('\n');
}
/** Compact roster: name + role only, no workflow details. Used for post-compact reminders. */
function buildCompactMembersRoster(members: TeamCreateRequest['members']): string {
return members
.map((member) => {
const rolePart = member.role?.trim() ? ` (${member.role.trim()})` : '';
return `- ${member.name}${rolePart}`;
})
.join('\n');
}
function buildMemberSpawnPrompt(
member: TeamCreateRequest['members'][number],
displayName: string,
@ -574,8 +584,10 @@ function buildPersistentLeadContext(opts: {
leadName: string;
isSolo: boolean;
members: TeamCreateRequest['members'];
/** When true, emit a compact roster (name + role only, no workflows). Used for post-compact reminders. */
compact?: boolean;
}): string {
const { teamName, leadName, isSolo, members } = opts;
const { teamName, leadName, isSolo, members, compact } = opts;
const languageInstruction = getAgentLanguageInstruction();
const agentBlockPolicy = buildAgentBlockUsagePolicy();
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
@ -599,7 +611,7 @@ function buildPersistentLeadContext(opts: {
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
: '';
const membersBlock = buildMembersPrompt(members);
const membersBlock = compact ? buildCompactMembersRoster(members) : buildMembersPrompt(members);
const membersFooter = membersBlock
? `Members:\n${membersBlock}`
: 'Members: (none — solo team lead)';
@ -3018,11 +3030,7 @@ export class TeamProvisioningService {
// (e.g., after session resume when teamContext is lost). We intercept the tool calls
// from stdout and persist them to sentMessages.json under the correct team name,
// ensuring the UI and notifications show the right team.
if (
run.provisioningComplete &&
!run.silentUserDmForward &&
!run.suppressPostCompactReminderOutput
) {
if (run.provisioningComplete && !run.silentUserDmForward) {
this.captureSendMessageToUser(run, content ?? []);
}
@ -3161,11 +3169,6 @@ export class TeamProvisioningService {
}
this.setLeadActivity(run, 'idle');
// Deferred post-compact context reinjection: inject durable rules on first idle after compact.
if (run.pendingPostCompactReminder && !run.postCompactReminderInFlight) {
void this.injectPostCompactReminder(run);
}
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
@ -3178,6 +3181,18 @@ export class TeamProvisioningService {
clearTimeout(run.silentUserDmForwardClearHandle);
run.silentUserDmForwardClearHandle = null;
}
// Deferred post-compact context reinjection: inject durable rules on first idle after compact.
// Placed AFTER leadRelayCapture/silentUserDmForward cleanup so a previously-deferred
// reminder can proceed now that the blocking conditions are cleared.
if (
run.provisioningComplete &&
run.pendingPostCompactReminder &&
!run.postCompactReminderInFlight
) {
void this.injectPostCompactReminder(run);
}
if (!run.provisioningComplete && !run.cancelRequested) {
void this.handleProvisioningTurnComplete(run);
}
@ -3328,6 +3343,7 @@ export class TeamProvisioningService {
leadName,
isSolo,
members: run.request.members,
compact: true,
});
// Best-effort: fetch fresh task board snapshot.
@ -3364,7 +3380,7 @@ export class TeamProvisioningService {
persistentContext,
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
``,
`Acknowledge briefly (1 sentence max) and continue with any pending work.`,
`This is a context-only reminder. Do NOT start new work or execute tasks in this turn. Reply with a single word: "OK".`,
]
.filter(Boolean)
.join('\n');

View file

@ -3,6 +3,7 @@ import React from 'react';
import { PROSE_BODY } from '@renderer/constants/cssVariables';
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
import { FileLink, isRelativeUrl } from './viewers/FileLink';
import type { Components } from 'react-markdown';
@ -77,17 +78,22 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
),
// Links — inline element, no hl(); parent block element's hl() descends here
a: ({ href, children }) => (
<a
href={href}
className="no-underline hover:underline"
style={{ color: 'var(--prose-link)' }}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
a: ({ href, children }) => {
if (href && isRelativeUrl(href)) {
return <FileLink href={href}>{children}</FileLink>;
}
return (
<a
href={href}
className="no-underline hover:underline"
style={{ color: 'var(--prose-link)' }}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
// Strong/Bold — inline element, no hl()
strong: ({ children }) => (

View file

@ -0,0 +1,146 @@
/**
* FileLink clickable file path link for markdown content.
* Opens the file in the built-in editor (team context) or copies the absolute path (session context).
*
* Follows the LocalImage pattern (MarkdownViewer.tsx) a standalone React component
* used inside react-markdown's `a` component factory.
*/
import React from 'react';
import { PROSE_LINK } from '@renderer/constants/cssVariables';
import { useStore } from '@renderer/store';
import type { AppState } from '@renderer/store/types';
import { Check, FileCode } from 'lucide-react';
// =============================================================================
// Exported utilities
// =============================================================================
/** Parse "path:line" format (e.g. "src/foo.ts:42") */
export function parsePathWithLine(href: string): { filePath: string; line: number | null } {
let decoded: string;
try {
decoded = decodeURIComponent(href);
} catch {
decoded = href;
}
const match = decoded.match(/^(.+?):(\d+)$/);
if (match) return { filePath: match[1], line: parseInt(match[2], 10) };
return { filePath: decoded, line: null };
}
/** Check if a URL is relative (not a protocol, not a hash, not data/mailto) */
export function isRelativeUrl(url: string): boolean {
return (
!!url &&
!url.startsWith('#') &&
!url.includes('://') &&
!url.startsWith('data:') &&
!url.startsWith('mailto:')
);
}
// =============================================================================
// Internal helpers
// =============================================================================
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
const parts = `${baseDir}/${relativeSrc}`.split('/');
const resolved: string[] = [];
for (const part of parts) {
if (part === '.' || part === '') continue;
if (part === '..') {
resolved.pop();
} else {
resolved.push(part);
}
}
return '/' + resolved.join('/');
}
/** Project path based on active tab context (avoids stale cross-tab state) */
function selectContextProjectPath(s: AppState): string | null {
const activeTab = s.openTabs.find((t) => t.id === s.activeTabId);
if (!activeTab) return null;
switch (activeTab.type) {
case 'team':
return s.selectedTeamData?.config.projectPath ?? null;
case 'session':
return s.sessionDetail?.session?.projectPath ?? null;
default:
return null;
}
}
function selectIsTeamTab(s: AppState): boolean {
const activeTab = s.openTabs.find((t) => t.id === s.activeTabId);
return activeTab?.type === 'team';
}
// =============================================================================
// Component
// =============================================================================
interface FileLinkProps {
href: string;
children: React.ReactNode;
}
export const FileLink = React.memo(function FileLink({
href,
children,
}: FileLinkProps): React.ReactElement {
const projectPath = useStore(selectContextProjectPath);
const isTeamTab = useStore(selectIsTeamTab);
const [copied, setCopied] = React.useState(false);
if (!projectPath) {
return (
<span className="font-mono text-xs" style={{ color: PROSE_LINK }}>
{children}
</span>
);
}
const { filePath: relativePath, line } = parsePathWithLine(href);
const absolutePath = resolveRelativePath(relativePath, projectPath);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
if (isTeamTab) {
const { revealFileInEditor, setPendingGoToLine } = useStore.getState();
if (line !== null) setPendingGoToLine(line);
revealFileInEditor(absolutePath);
} else {
void navigator.clipboard.writeText(absolutePath).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
() => {
// Clipboard API may not be available in all contexts
}
);
}
};
return (
<a
href={href}
onClick={handleClick}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-sm px-0.5 no-underline hover:underline"
style={{
color: PROSE_LINK,
backgroundColor: 'var(--path-highlight-bg)',
}}
title={isTeamTab ? absolutePath : `Click to copy: ${absolutePath}`}
>
<FileCode size={12} className="shrink-0 opacity-60" />
{children}
{copied && <Check size={10} className="shrink-0 text-green-400" />}
</a>
);
});

View file

@ -36,6 +36,7 @@ import {
type SearchContext,
} from '../searchHighlightUtils';
import { FileLink, isRelativeUrl } from './FileLink';
import { MermaidDiagram } from './MermaidDiagram';
// =============================================================================
@ -72,18 +73,6 @@ function allowCustomProtocols(url: string): string {
return defaultUrlTransform(url);
}
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
function isRelativeUrl(url: string): boolean {
return (
!!url &&
!url.startsWith('http://') &&
!url.startsWith('https://') &&
!url.startsWith('data:') &&
!url.startsWith('#') &&
!url.startsWith('mailto:')
);
}
/** Resolve a relative path to an absolute path given a base directory */
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
@ -255,6 +244,10 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
</TaskTooltip>
);
}
// Relative file paths — open in built-in editor or copy path
if (href && isRelativeUrl(href)) {
return <FileLink href={href}>{children}</FileLink>;
}
return (
<a
href={href}

View file

@ -28,6 +28,8 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { MemberBadge } from '../MemberBadge';
import type { InlineChip } from '@renderer/types/inlineChip';
@ -39,8 +41,6 @@ interface QuotedMessage {
text: string;
}
const MAX_MESSAGE_LENGTH = 50_000;
interface SendMessageDialogProps {
open: boolean;
teamName: string;
@ -181,13 +181,12 @@ export const SendMessageDialog = ({
const trimmedText = textDraft.value.trim();
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
const remaining = MAX_MESSAGE_LENGTH - finalText.length;
const remaining = MAX_TEXT_LENGTH - finalText.length;
const canSend =
member.trim().length > 0 &&
finalText.length > 0 &&
finalText.length <= MAX_MESSAGE_LENGTH &&
summary.trim().length > 0 &&
finalText.length <= MAX_TEXT_LENGTH &&
!sending &&
!attachmentsBlocked;
@ -201,10 +200,13 @@ export const SendMessageDialog = ({
const handleSubmit = (): void => {
if (!canSend) return;
// TODO: Research whether duplicating message as summary is correct — the team lead
// may only see the Summary field and not the full Message body. Need to verify.
const effectiveSummary = summary.trim() || trimmedText;
onSend(
member.trim(),
finalText,
summary.trim(),
effectiveSummary,
attachments.length > 0 ? attachments : undefined
);
textDraft.clearDraft();
@ -400,7 +402,7 @@ export const SendMessageDialog = ({
onModEnter={handleSubmit}
minRows={4}
maxRows={12}
maxLength={MAX_MESSAGE_LENGTH}
maxLength={MAX_TEXT_LENGTH}
disabled={sending}
cornerAction={
<button

View file

@ -10,10 +10,11 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
const MAX_COMMENT_LENGTH = 2000;
const MAX_ATTACHMENTS = 5;
const MAX_FILE_SIZE = 20 * 1024 * 1024;
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
@ -64,10 +65,10 @@ export const TaskCommentInput = ({
);
const trimmed = draft.value.trim();
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
const remaining = MAX_TEXT_LENGTH - trimmed.length;
const canSubmit =
(trimmed.length > 0 || pendingAttachments.length > 0) &&
trimmed.length <= MAX_COMMENT_LENGTH &&
trimmed.length <= MAX_TEXT_LENGTH &&
!addingComment;
const addFiles = useCallback((files: FileList | File[]) => {
@ -253,7 +254,7 @@ export const TaskCommentInput = ({
onModEnter={() => void handleSubmit()}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
maxLength={MAX_TEXT_LENGTH}
disabled={addingComment}
cornerAction={
<div className="flex items-center gap-1.5">

View file

@ -16,6 +16,7 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
@ -31,7 +32,6 @@ function normalizeLiteralNewlines(text: string): string {
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}
const MAX_COMMENT_LENGTH = 2000;
const INITIAL_VISIBLE_COMMENTS = 30;
const VISIBLE_COMMENTS_STEP = 50;
const MAX_COMMENTS_TO_RENDER = 2000;
@ -131,8 +131,8 @@ export const TaskCommentsSection = ({
);
const trimmed = draft.value.trim();
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment;
const remaining = MAX_TEXT_LENGTH - trimmed.length;
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !addingComment;
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
@ -365,7 +365,7 @@ export const TaskCommentsSection = ({
onModEnter={() => void handleSubmit()}
minRows={2}
maxRows={8}
maxLength={MAX_COMMENT_LENGTH}
maxLength={MAX_TEXT_LENGTH}
disabled={addingComment}
cornerAction={
<button

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
@ -21,6 +22,7 @@ import { Textarea } from '@renderer/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import {
buildMemberColorMap,
KANBAN_COLUMN_DISPLAY,
@ -56,7 +58,12 @@ import { TaskAttachments } from './TaskAttachments';
import { TaskCommentInput } from './TaskCommentInput';
import { TaskCommentsSection } from './TaskCommentsSection';
import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type {
KanbanTaskState,
ResolvedTeamMember,
TaskAttachmentMeta,
TeamTaskWithKanban,
} from '@shared/types';
interface TaskDetailDialogProps {
open: boolean;
@ -194,6 +201,22 @@ export const TaskDetailDialog = ({
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
}, [open, teamName, currentTask]);
// Collect image attachments from comments for the Attachments section
const commentImageAttachments = useMemo(() => {
const comments = currentTask?.comments ?? [];
const result: { attachment: TaskAttachmentMeta; commentText: string; commentAuthor: string }[] =
[];
for (const c of comments) {
if (!c.attachments) continue;
for (const att of c.attachments) {
if (isImageMimeType(att.mimeType)) {
result.push({ attachment: att, commentText: c.text, commentAuthor: c.author });
}
}
}
return result;
}, [currentTask?.comments]);
// Lazy-load task changes when dialog is open and task is completed
const isTaskCompleted = currentTask?.status === 'completed';
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
@ -549,20 +572,29 @@ export const TaskDetailDialog = ({
title="Attachments"
icon={<ImageIcon size={14} />}
badge={
(currentTask.attachments?.length ?? 0) > 0
? (currentTask.attachments?.length ?? 0)
(currentTask.attachments?.length ?? 0) + commentImageAttachments.length > 0
? (currentTask.attachments?.length ?? 0) + commentImageAttachments.length
: undefined
}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={(currentTask.attachments?.length ?? 0) > 0}
defaultOpen={
(currentTask.attachments?.length ?? 0) > 0 || commentImageAttachments.length > 0
}
>
<TaskAttachments
teamName={teamName}
taskId={currentTask.id}
attachments={currentTask.attachments ?? []}
/>
{commentImageAttachments.length > 0 ? (
<CommentImagesGrid
items={commentImageAttachments}
teamName={teamName}
taskId={currentTask.id}
/>
) : null}
</CollapsibleTeamSection>
{/* Changes */}
@ -884,3 +916,116 @@ export const TaskDetailDialog = ({
</Dialog>
);
};
// ---------------------------------------------------------------------------
// Comment images grid — accumulated images from task comments
// ---------------------------------------------------------------------------
interface CommentImageItem {
attachment: TaskAttachmentMeta;
commentText: string;
commentAuthor: string;
}
const CommentImagesGrid = ({
items,
teamName,
taskId,
}: {
items: CommentImageItem[];
teamName: string;
taskId: string;
}): React.JSX.Element => {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
return (
<div className="mt-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
<MessageSquare size={10} />
From comments
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<CommentImageThumbnail
key={item.attachment.id}
item={item}
teamName={teamName}
taskId={taskId}
onPreview={setPreviewUrl}
/>
))}
</div>
{previewUrl ? (
<ImageLightbox
open
onClose={() => setPreviewUrl(null)}
src={previewUrl}
alt="Comment attachment"
/>
) : null}
</div>
);
};
const CommentImageThumbnail = ({
item,
teamName,
taskId,
onPreview,
}: {
item: CommentImageItem;
teamName: string;
taskId: string;
onPreview: (dataUrl: string) => void;
}): React.JSX.Element => {
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const base64 = await getTaskAttachmentData(
teamName,
taskId,
item.attachment.id,
item.attachment.mimeType
);
if (!cancelled && base64) {
setThumbUrl(`data:${item.attachment.mimeType};base64,${base64}`);
}
} catch {
// ignore
}
})();
return () => {
cancelled = true;
};
}, [teamName, taskId, item.attachment.id, item.attachment.mimeType, getTaskAttachmentData]);
// Truncate comment text for tooltip
const tooltipText = `${item.commentAuthor}: ${item.commentText.length > 200 ? item.commentText.slice(0, 200) + '...' : item.commentText}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="group relative flex size-16 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => thumbUrl && onPreview(thumbUrl)}
>
{thumbUrl ? (
<img src={thumbUrl} alt={item.attachment.filename} className="size-full object-cover" />
) : (
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
)}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{item.attachment.filename}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px] text-xs">
{tooltipText}
</TooltipContent>
</Tooltip>
);
};

View file

@ -14,6 +14,8 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
@ -31,8 +33,6 @@ interface MessageComposerProps {
) => void;
}
const MAX_MESSAGE_LENGTH = 50_000;
/** Circular progress indicator for lead context usage. */
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
const size = 26;
@ -148,7 +148,7 @@ export const MessageComposer = ({
const canSend =
recipient.length > 0 &&
trimmed.length > 0 &&
trimmed.length <= MAX_MESSAGE_LENGTH &&
trimmed.length <= MAX_TEXT_LENGTH &&
!sending &&
!attachmentsBlocked;
@ -235,7 +235,7 @@ export const MessageComposer = ({
[canAttach, draft.handlePaste]
);
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
const remaining = MAX_TEXT_LENGTH - trimmed.length;
return (
<div
@ -427,7 +427,7 @@ export const MessageComposer = ({
onModEnter={handleSend}
minRows={2}
maxRows={6}
maxLength={MAX_MESSAGE_LENGTH}
maxLength={MAX_TEXT_LENGTH}
disabled={sending}
cornerAction={
<div className="flex items-center gap-2">

View file

@ -6,6 +6,7 @@ export * from './agentBlocks';
export * from './cache';
export * from './kanban';
export * from './memberColors';
export * from './teamLimits';
export * from './trafficLights';
export * from './triggerColors';
export * from './window';

View file

@ -0,0 +1,2 @@
/** Maximum character length for messages and comments in team communication. */
export const MAX_TEXT_LENGTH = 50_000;

View file

@ -0,0 +1,368 @@
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let tempClaudeRoot = '';
let tempTeamsBase = '';
let tempTasksBase = '';
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: { resolve: vi.fn() },
}));
vi.mock('@main/utils/childProcess', () => ({
spawnCli: vi.fn(),
killProcessTree: vi.fn(),
}));
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
return {
...actual,
getAutoDetectedClaudeBasePath: () => tempClaudeRoot,
getClaudeBasePath: () => tempClaudeRoot,
getTeamsBasePath: () => tempTeamsBase,
getTasksBasePath: () => tempTasksBase,
};
});
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess';
function createFakeChild() {
const writeSpy = vi.fn((_data: unknown, cb?: (err?: Error | null) => void) => {
if (typeof cb === 'function') cb(null);
return true;
});
const endSpy = vi.fn();
const child = Object.assign(new EventEmitter(), {
pid: 12345,
stdin: { writable: true, write: writeSpy, end: endSpy },
stdout: new EventEmitter(),
stderr: new EventEmitter(),
kill: vi.fn(),
});
return { child, writeSpy, endSpy };
}
/** Create a TeamProvisioningService with a running lead process (post-provisioning). */
async function setupRunningTeam(teamName: string) {
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
fs.writeFileSync(
path.join(teamDir, 'config.json'),
JSON.stringify({
name: teamName,
description: 'Test team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'teammate', role: 'developer' },
],
}),
'utf8'
);
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child, writeSpy } = createFakeChild();
vi.mocked(spawnCli).mockReturnValue(child as any);
const svc = new TeamProvisioningService();
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
env: { ANTHROPIC_API_KEY: 'test' },
authSource: 'anthropic_api_key',
}));
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
members: [{ name: 'alice', role: 'developer' }],
source: 'config-fallback',
warning: undefined,
}));
(svc as any).pathExists = vi.fn(async () => false);
(svc as any).startFilesystemMonitor = vi.fn();
const { runId } = await svc.launchTeam(
{ teamName, cwd: process.cwd(), clearContext: true } as any,
() => {}
);
// Get the run object
const run = (svc as any).runs.get(runId);
if (!run) throw new Error('Run not found');
// Simulate provisioning complete (skip the full provisioning flow)
run.provisioningComplete = true;
run.leadActivityState = 'idle';
return { svc, run, runId, child, writeSpy };
}
describe('TeamProvisioningService post-compact lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks();
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-compact-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
fs.mkdirSync(tempTeamsBase, { recursive: true });
fs.mkdirSync(tempTasksBase, { recursive: true });
});
afterEach(() => {
try {
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
} catch {
// ignore
}
});
it('compact_boundary sets pendingPostCompactReminder when provisioning is complete', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-1');
expect(run.pendingPostCompactReminder).toBe(false);
// Simulate compact_boundary
(svc as any).handleStreamJsonMessage(run, {
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'auto', pre_tokens: 100000 },
});
expect(run.pendingPostCompactReminder).toBe(true);
expect(run.postCompactReminderInFlight).toBe(false);
await svc.cancelProvisioning(runId);
});
it('compact_boundary does NOT set pending before provisioning complete', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-2');
run.provisioningComplete = false;
(svc as any).handleStreamJsonMessage(run, {
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'auto' },
});
expect(run.pendingPostCompactReminder).toBe(false);
run.provisioningComplete = true;
await svc.cancelProvisioning(runId);
});
it('compact_boundary does NOT set pending when reminder is already in-flight', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-3');
run.postCompactReminderInFlight = true;
(svc as any).handleStreamJsonMessage(run, {
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'auto' },
});
// Should NOT be set because in-flight
expect(run.pendingPostCompactReminder).toBe(false);
run.postCompactReminderInFlight = false;
await svc.cancelProvisioning(runId);
});
it('multiple compacts coalesce to one pending reminder', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-4');
// 3 compact_boundary events
for (let i = 0; i < 3; i++) {
(svc as any).handleStreamJsonMessage(run, {
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'auto' },
});
}
expect(run.pendingPostCompactReminder).toBe(true);
expect(run.postCompactReminderInFlight).toBe(false);
await svc.cancelProvisioning(runId);
});
it('injectPostCompactReminder defers when leadRelayCapture is active', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-5');
run.pendingPostCompactReminder = true;
// Simulate active relay capture
run.leadRelayCapture = {
leadName: 'team-lead',
startedAt: new Date().toISOString(),
textParts: [],
settled: false,
idleHandle: null,
idleMs: 800,
resolveOnce: vi.fn(),
rejectOnce: vi.fn(),
timeoutHandle: setTimeout(() => {}, 60000),
};
await (svc as any).injectPostCompactReminder(run);
// Should re-arm pending (deferred), NOT inject
expect(run.pendingPostCompactReminder).toBe(true);
expect(run.postCompactReminderInFlight).toBe(false);
clearTimeout(run.leadRelayCapture.timeoutHandle);
run.leadRelayCapture = null;
await svc.cancelProvisioning(runId);
});
it('injectPostCompactReminder defers when silentUserDmForward is active', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-6');
run.pendingPostCompactReminder = true;
run.silentUserDmForward = { target: 'alice', startedAt: new Date().toISOString() };
await (svc as any).injectPostCompactReminder(run);
expect(run.pendingPostCompactReminder).toBe(true);
expect(run.postCompactReminderInFlight).toBe(false);
run.silentUserDmForward = null;
await svc.cancelProvisioning(runId);
});
it('injectPostCompactReminder skips when lead is not idle', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-7');
run.pendingPostCompactReminder = true;
run.leadActivityState = 'active';
await (svc as any).injectPostCompactReminder(run);
// Should re-arm pending
expect(run.pendingPostCompactReminder).toBe(true);
expect(run.postCompactReminderInFlight).toBe(false);
await svc.cancelProvisioning(runId);
});
it('injectPostCompactReminder sends context-only reminder (no "continue with pending work")', async () => {
const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-8');
run.pendingPostCompactReminder = true;
// Reset write spy calls from provisioning
writeSpy.mockClear();
await (svc as any).injectPostCompactReminder(run);
expect(run.pendingPostCompactReminder).toBe(false);
expect(run.postCompactReminderInFlight).toBe(true);
expect(run.suppressPostCompactReminderOutput).toBe(true);
// Verify the reminder was written to stdin
expect(writeSpy).toHaveBeenCalledTimes(1);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
const parsed = JSON.parse(payload) as {
type: string;
message?: { role: string; content: { type: string; text?: string }[] };
};
const text = parsed.message?.content?.[0]?.text ?? '';
// Should NOT contain "continue with any pending work"
expect(text).not.toContain('continue with any pending work');
// Should be context-only
expect(text).toContain('Do NOT start new work');
expect(text).toContain('Reply with a single word');
// Should contain persistent context
expect(text).toContain('Constraints:');
expect(text).toContain('Do NOT call TeamDelete');
await svc.cancelProvisioning(runId);
});
it('reminder uses compact roster (no workflow details)', async () => {
const { svc, run, runId, writeSpy } = await setupRunningTeam('compact-test-9');
run.pendingPostCompactReminder = true;
// Add workflow to member to verify it's NOT included in compact roster
run.request.members = [
{
name: 'alice',
role: 'developer',
workflow: 'Very long workflow instructions that should NOT appear in post-compact reminder',
},
];
writeSpy.mockClear();
await (svc as any).injectPostCompactReminder(run);
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
const parsed = JSON.parse(payload) as {
type: string;
message?: { role: string; content: { type: string; text?: string }[] };
};
const text = parsed.message?.content?.[0]?.text ?? '';
// Should have alice name + role
expect(text).toContain('alice');
// Should NOT have full workflow
expect(text).not.toContain('Very long workflow instructions');
expect(text).not.toContain('BEGIN WORKFLOW');
await svc.cancelProvisioning(runId);
});
it('clearPostCompactReminderState resets all 3 flags', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-10');
run.pendingPostCompactReminder = true;
run.postCompactReminderInFlight = true;
run.suppressPostCompactReminderOutput = true;
// Access the module-level function through cleanupRun which calls it
(svc as any).cleanupRun(run);
// After cleanupRun, the run is removed from maps, but we can check the object
expect(run.pendingPostCompactReminder).toBe(false);
expect(run.postCompactReminderInFlight).toBe(false);
expect(run.suppressPostCompactReminderOutput).toBe(false);
});
it('result.success clears in-flight state and suppress flag', async () => {
const { svc, run, runId } = await setupRunningTeam('compact-test-11');
run.postCompactReminderInFlight = true;
run.suppressPostCompactReminderOutput = true;
// Simulate result.success
(svc as any).handleStreamJsonMessage(run, {
type: 'result',
subtype: 'success',
result: {},
});
expect(run.postCompactReminderInFlight).toBe(false);
expect(run.suppressPostCompactReminderOutput).toBe(false);
});
it('result.error clears in-flight state (strict drop-after-attempt)', async () => {
const { svc, run } = await setupRunningTeam('compact-test-12');
run.postCompactReminderInFlight = true;
run.suppressPostCompactReminderOutput = true;
// Simulate result.error post-provisioning
// Expected warnings from logger.warn — suppress them so setup.ts afterEach doesn't fail
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(svc as any).handleStreamJsonMessage(run, {
type: 'result',
subtype: 'error',
error: 'test error',
});
warnSpy.mockRestore();
expect(run.postCompactReminderInFlight).toBe(false);
expect(run.suppressPostCompactReminderOutput).toBe(false);
// Should NOT re-arm pending (strict drop)
expect(run.pendingPostCompactReminder).toBe(false);
});
});

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { isRelativeUrl, parsePathWithLine } from '@renderer/components/chat/viewers/FileLink';
describe('parsePathWithLine', () => {
it('returns filePath and null line for simple path', () => {
expect(parsePathWithLine('src/foo.ts')).toEqual({ filePath: 'src/foo.ts', line: null });
});
it('parses path:line format', () => {
expect(parsePathWithLine('src/foo.ts:42')).toEqual({ filePath: 'src/foo.ts', line: 42 });
});
it('handles line number 0', () => {
expect(parsePathWithLine('src/foo.ts:0')).toEqual({ filePath: 'src/foo.ts', line: 0 });
});
it('handles ./ prefix with line number', () => {
expect(parsePathWithLine('./src/foo.ts:10')).toEqual({ filePath: './src/foo.ts', line: 10 });
});
it('returns filePath for root-level file', () => {
expect(parsePathWithLine('package.json')).toEqual({ filePath: 'package.json', line: null });
});
it('decodes percent-encoded paths', () => {
expect(parsePathWithLine('src/foo%20bar.ts')).toEqual({
filePath: 'src/foo bar.ts',
line: null,
});
});
it('decodes percent-encoded path with line number', () => {
expect(parsePathWithLine('src/foo%20bar.ts:5')).toEqual({
filePath: 'src/foo bar.ts',
line: 5,
});
});
it('handles malformed percent-encoding gracefully', () => {
expect(parsePathWithLine('src/%ZZfoo.ts')).toEqual({
filePath: 'src/%ZZfoo.ts',
line: null,
});
});
it('handles deeply nested paths', () => {
expect(parsePathWithLine('src/renderer/components/chat/viewers/FileLink.tsx:120')).toEqual({
filePath: 'src/renderer/components/chat/viewers/FileLink.tsx',
line: 120,
});
});
});
describe('isRelativeUrl', () => {
it('returns true for relative paths', () => {
expect(isRelativeUrl('src/foo.ts')).toBe(true);
expect(isRelativeUrl('./relative/path.ts')).toBe(true);
expect(isRelativeUrl('../parent/file.ts')).toBe(true);
expect(isRelativeUrl('package.json')).toBe(true);
expect(isRelativeUrl('README.md')).toBe(true);
});
it('returns false for http/https URLs', () => {
expect(isRelativeUrl('http://example.com')).toBe(false);
expect(isRelativeUrl('https://example.com')).toBe(false);
expect(isRelativeUrl('https://github.com/foo/bar')).toBe(false);
});
it('returns false for custom protocol URLs', () => {
expect(isRelativeUrl('task://123')).toBe(false);
expect(isRelativeUrl('mention://color/name')).toBe(false);
expect(isRelativeUrl('ftp://server/file')).toBe(false);
});
it('returns false for data: URLs', () => {
expect(isRelativeUrl('data:text/html,<h1>hi</h1>')).toBe(false);
expect(isRelativeUrl('data:image/png;base64,abc')).toBe(false);
});
it('returns false for hash fragments', () => {
expect(isRelativeUrl('#heading')).toBe(false);
expect(isRelativeUrl('#')).toBe(false);
});
it('returns false for mailto: links', () => {
expect(isRelativeUrl('mailto:a@b.com')).toBe(false);
});
it('returns false for empty string', () => {
expect(isRelativeUrl('')).toBe(false);
});
});