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:
parent
dba2d98923
commit
748d6a7b81
15 changed files with 837 additions and 62 deletions
|
|
@ -19,6 +19,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
## What is this
|
## What is this
|
||||||
|
|
||||||
A new approach to task management with AI agents.
|
A new approach to task management with AI agents.
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import {
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||||
|
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
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 (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||||
if (typeof text !== 'string' || text.trim().length === 0)
|
if (typeof text !== 'string' || text.trim().length === 0)
|
||||||
return { success: false, error: 'Comment text must be non-empty' };
|
return { success: false, error: 'Comment text must be non-empty' };
|
||||||
if (text.trim().length > 2000)
|
if (text.trim().length > MAX_TEXT_LENGTH)
|
||||||
return { success: false, error: 'Comment exceeds 2000 characters' };
|
return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` };
|
||||||
|
|
||||||
const rawAttachments = Array.isArray(attachments) ? attachments : [];
|
const rawAttachments = Array.isArray(attachments) ? attachments : [];
|
||||||
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
||||||
.join('\n');
|
.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(
|
function buildMemberSpawnPrompt(
|
||||||
member: TeamCreateRequest['members'][number],
|
member: TeamCreateRequest['members'][number],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
|
@ -574,8 +584,10 @@ function buildPersistentLeadContext(opts: {
|
||||||
leadName: string;
|
leadName: string;
|
||||||
isSolo: boolean;
|
isSolo: boolean;
|
||||||
members: TeamCreateRequest['members'];
|
members: TeamCreateRequest['members'];
|
||||||
|
/** When true, emit a compact roster (name + role only, no workflows). Used for post-compact reminders. */
|
||||||
|
compact?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const { teamName, leadName, isSolo, members } = opts;
|
const { teamName, leadName, isSolo, members, compact } = opts;
|
||||||
const languageInstruction = getAgentLanguageInstruction();
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||||
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
|
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.`
|
`\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
|
const membersFooter = membersBlock
|
||||||
? `Members:\n${membersBlock}`
|
? `Members:\n${membersBlock}`
|
||||||
: 'Members: (none — solo team lead)';
|
: '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
|
// (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,
|
// from stdout and persist them to sentMessages.json under the correct team name,
|
||||||
// ensuring the UI and notifications show the right team.
|
// ensuring the UI and notifications show the right team.
|
||||||
if (
|
if (run.provisioningComplete && !run.silentUserDmForward) {
|
||||||
run.provisioningComplete &&
|
|
||||||
!run.silentUserDmForward &&
|
|
||||||
!run.suppressPostCompactReminderOutput
|
|
||||||
) {
|
|
||||||
this.captureSendMessageToUser(run, content ?? []);
|
this.captureSendMessageToUser(run, content ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3161,11 +3169,6 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setLeadActivity(run, 'idle');
|
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) {
|
if (run.leadRelayCapture) {
|
||||||
const capture = run.leadRelayCapture;
|
const capture = run.leadRelayCapture;
|
||||||
|
|
@ -3178,6 +3181,18 @@ export class TeamProvisioningService {
|
||||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||||
run.silentUserDmForwardClearHandle = null;
|
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) {
|
if (!run.provisioningComplete && !run.cancelRequested) {
|
||||||
void this.handleProvisioningTurnComplete(run);
|
void this.handleProvisioningTurnComplete(run);
|
||||||
}
|
}
|
||||||
|
|
@ -3328,6 +3343,7 @@ export class TeamProvisioningService {
|
||||||
leadName,
|
leadName,
|
||||||
isSolo,
|
isSolo,
|
||||||
members: run.request.members,
|
members: run.request.members,
|
||||||
|
compact: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort: fetch fresh task board snapshot.
|
// Best-effort: fetch fresh task board snapshot.
|
||||||
|
|
@ -3364,7 +3380,7 @@ export class TeamProvisioningService {
|
||||||
persistentContext,
|
persistentContext,
|
||||||
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
|
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)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React from 'react';
|
||||||
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
||||||
|
|
||||||
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
||||||
|
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
||||||
|
|
||||||
import type { Components } from 'react-markdown';
|
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
|
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||||
a: ({ href, children }) => (
|
a: ({ href, children }) => {
|
||||||
<a
|
if (href && isRelativeUrl(href)) {
|
||||||
href={href}
|
return <FileLink href={href}>{children}</FileLink>;
|
||||||
className="no-underline hover:underline"
|
}
|
||||||
style={{ color: 'var(--prose-link)' }}
|
return (
|
||||||
target="_blank"
|
<a
|
||||||
rel="noopener noreferrer"
|
href={href}
|
||||||
>
|
className="no-underline hover:underline"
|
||||||
{children}
|
style={{ color: 'var(--prose-link)' }}
|
||||||
</a>
|
target="_blank"
|
||||||
),
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// Strong/Bold — inline element, no hl()
|
// Strong/Bold — inline element, no hl()
|
||||||
strong: ({ children }) => (
|
strong: ({ children }) => (
|
||||||
|
|
|
||||||
146
src/renderer/components/chat/viewers/FileLink.tsx
Normal file
146
src/renderer/components/chat/viewers/FileLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from '../searchHighlightUtils';
|
} from '../searchHighlightUtils';
|
||||||
|
|
||||||
|
import { FileLink, isRelativeUrl } from './FileLink';
|
||||||
import { MermaidDiagram } from './MermaidDiagram';
|
import { MermaidDiagram } from './MermaidDiagram';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -72,18 +73,6 @@ function allowCustomProtocols(url: string): string {
|
||||||
return defaultUrlTransform(url);
|
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 */
|
/** Resolve a relative path to an absolute path given a base directory */
|
||||||
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
||||||
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
|
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
|
||||||
|
|
@ -255,6 +244,10 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
</TaskTooltip>
|
</TaskTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Relative file paths — open in built-in editor or copy path
|
||||||
|
if (href && isRelativeUrl(href)) {
|
||||||
|
return <FileLink href={href}>{children}</FileLink>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
|
|
||||||
import { MemberBadge } from '../MemberBadge';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
|
|
@ -39,8 +41,6 @@ interface QuotedMessage {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_MESSAGE_LENGTH = 50_000;
|
|
||||||
|
|
||||||
interface SendMessageDialogProps {
|
interface SendMessageDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -181,13 +181,12 @@ export const SendMessageDialog = ({
|
||||||
const trimmedText = textDraft.value.trim();
|
const trimmedText = textDraft.value.trim();
|
||||||
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
||||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
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 =
|
const canSend =
|
||||||
member.trim().length > 0 &&
|
member.trim().length > 0 &&
|
||||||
finalText.length > 0 &&
|
finalText.length > 0 &&
|
||||||
finalText.length <= MAX_MESSAGE_LENGTH &&
|
finalText.length <= MAX_TEXT_LENGTH &&
|
||||||
summary.trim().length > 0 &&
|
|
||||||
!sending &&
|
!sending &&
|
||||||
!attachmentsBlocked;
|
!attachmentsBlocked;
|
||||||
|
|
||||||
|
|
@ -201,10 +200,13 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const handleSubmit = (): void => {
|
const handleSubmit = (): void => {
|
||||||
if (!canSend) return;
|
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(
|
onSend(
|
||||||
member.trim(),
|
member.trim(),
|
||||||
finalText,
|
finalText,
|
||||||
summary.trim(),
|
effectiveSummary,
|
||||||
attachments.length > 0 ? attachments : undefined
|
attachments.length > 0 ? attachments : undefined
|
||||||
);
|
);
|
||||||
textDraft.clearDraft();
|
textDraft.clearDraft();
|
||||||
|
|
@ -400,7 +402,7 @@ export const SendMessageDialog = ({
|
||||||
onModEnter={handleSubmit}
|
onModEnter={handleSubmit}
|
||||||
minRows={4}
|
minRows={4}
|
||||||
maxRows={12}
|
maxRows={12}
|
||||||
maxLength={MAX_MESSAGE_LENGTH}
|
maxLength={MAX_TEXT_LENGTH}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
const MAX_COMMENT_LENGTH = 2000;
|
|
||||||
const MAX_ATTACHMENTS = 5;
|
const MAX_ATTACHMENTS = 5;
|
||||||
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||||
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
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 trimmed = draft.value.trim();
|
||||||
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
(trimmed.length > 0 || pendingAttachments.length > 0) &&
|
(trimmed.length > 0 || pendingAttachments.length > 0) &&
|
||||||
trimmed.length <= MAX_COMMENT_LENGTH &&
|
trimmed.length <= MAX_TEXT_LENGTH &&
|
||||||
!addingComment;
|
!addingComment;
|
||||||
|
|
||||||
const addFiles = useCallback((files: FileList | File[]) => {
|
const addFiles = useCallback((files: FileList | File[]) => {
|
||||||
|
|
@ -253,7 +254,7 @@ export const TaskCommentInput = ({
|
||||||
onModEnter={() => void handleSubmit()}
|
onModEnter={() => void handleSubmit()}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={8}
|
maxRows={8}
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
maxLength={MAX_TEXT_LENGTH}
|
||||||
disabled={addingComment}
|
disabled={addingComment}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
|
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');
|
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_COMMENT_LENGTH = 2000;
|
|
||||||
const INITIAL_VISIBLE_COMMENTS = 30;
|
const INITIAL_VISIBLE_COMMENTS = 30;
|
||||||
const VISIBLE_COMMENTS_STEP = 50;
|
const VISIBLE_COMMENTS_STEP = 50;
|
||||||
const MAX_COMMENTS_TO_RENDER = 2000;
|
const MAX_COMMENTS_TO_RENDER = 2000;
|
||||||
|
|
@ -131,8 +131,8 @@ export const TaskCommentsSection = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const trimmed = draft.value.trim();
|
const trimmed = draft.value.trim();
|
||||||
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment;
|
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !addingComment;
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
|
|
@ -365,7 +365,7 @@ export const TaskCommentsSection = ({
|
||||||
onModEnter={() => void handleSubmit()}
|
onModEnter={() => void handleSubmit()}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={8}
|
maxRows={8}
|
||||||
maxLength={MAX_COMMENT_LENGTH}
|
maxLength={MAX_TEXT_LENGTH}
|
||||||
disabled={addingComment}
|
disabled={addingComment}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
|
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
|
||||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import {
|
import {
|
||||||
buildMemberColorMap,
|
buildMemberColorMap,
|
||||||
KANBAN_COLUMN_DISPLAY,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
|
|
@ -56,7 +58,12 @@ import { TaskAttachments } from './TaskAttachments';
|
||||||
import { TaskCommentInput } from './TaskCommentInput';
|
import { TaskCommentInput } from './TaskCommentInput';
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type {
|
||||||
|
KanbanTaskState,
|
||||||
|
ResolvedTeamMember,
|
||||||
|
TaskAttachmentMeta,
|
||||||
|
TeamTaskWithKanban,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
interface TaskDetailDialogProps {
|
interface TaskDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -194,6 +201,22 @@ export const TaskDetailDialog = ({
|
||||||
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
|
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
|
||||||
}, [open, teamName, currentTask]);
|
}, [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
|
// Lazy-load task changes when dialog is open and task is completed
|
||||||
const isTaskCompleted = currentTask?.status === 'completed';
|
const isTaskCompleted = currentTask?.status === 'completed';
|
||||||
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
||||||
|
|
@ -549,20 +572,29 @@ export const TaskDetailDialog = ({
|
||||||
title="Attachments"
|
title="Attachments"
|
||||||
icon={<ImageIcon size={14} />}
|
icon={<ImageIcon size={14} />}
|
||||||
badge={
|
badge={
|
||||||
(currentTask.attachments?.length ?? 0) > 0
|
(currentTask.attachments?.length ?? 0) + commentImageAttachments.length > 0
|
||||||
? (currentTask.attachments?.length ?? 0)
|
? (currentTask.attachments?.length ?? 0) + commentImageAttachments.length
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
headerContentClassName="pl-6"
|
||||||
defaultOpen={(currentTask.attachments?.length ?? 0) > 0}
|
defaultOpen={
|
||||||
|
(currentTask.attachments?.length ?? 0) > 0 || commentImageAttachments.length > 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TaskAttachments
|
<TaskAttachments
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={currentTask.id}
|
taskId={currentTask.id}
|
||||||
attachments={currentTask.attachments ?? []}
|
attachments={currentTask.attachments ?? []}
|
||||||
/>
|
/>
|
||||||
|
{commentImageAttachments.length > 0 ? (
|
||||||
|
<CommentImagesGrid
|
||||||
|
items={commentImageAttachments}
|
||||||
|
teamName={teamName}
|
||||||
|
taskId={currentTask.id}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
{/* Changes */}
|
{/* Changes */}
|
||||||
|
|
@ -884,3 +916,116 @@ export const TaskDetailDialog = ({
|
||||||
</Dialog>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
|
import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -31,8 +33,6 @@ interface MessageComposerProps {
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_MESSAGE_LENGTH = 50_000;
|
|
||||||
|
|
||||||
/** Circular progress indicator for lead context usage. */
|
/** Circular progress indicator for lead context usage. */
|
||||||
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => {
|
||||||
const size = 26;
|
const size = 26;
|
||||||
|
|
@ -148,7 +148,7 @@ export const MessageComposer = ({
|
||||||
const canSend =
|
const canSend =
|
||||||
recipient.length > 0 &&
|
recipient.length > 0 &&
|
||||||
trimmed.length > 0 &&
|
trimmed.length > 0 &&
|
||||||
trimmed.length <= MAX_MESSAGE_LENGTH &&
|
trimmed.length <= MAX_TEXT_LENGTH &&
|
||||||
!sending &&
|
!sending &&
|
||||||
!attachmentsBlocked;
|
!attachmentsBlocked;
|
||||||
|
|
||||||
|
|
@ -235,7 +235,7 @@ export const MessageComposer = ({
|
||||||
[canAttach, draft.handlePaste]
|
[canAttach, draft.handlePaste]
|
||||||
);
|
);
|
||||||
|
|
||||||
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -427,7 +427,7 @@ export const MessageComposer = ({
|
||||||
onModEnter={handleSend}
|
onModEnter={handleSend}
|
||||||
minRows={2}
|
minRows={2}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
maxLength={MAX_MESSAGE_LENGTH}
|
maxLength={MAX_TEXT_LENGTH}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export * from './agentBlocks';
|
||||||
export * from './cache';
|
export * from './cache';
|
||||||
export * from './kanban';
|
export * from './kanban';
|
||||||
export * from './memberColors';
|
export * from './memberColors';
|
||||||
|
export * from './teamLimits';
|
||||||
export * from './trafficLights';
|
export * from './trafficLights';
|
||||||
export * from './triggerColors';
|
export * from './triggerColors';
|
||||||
export * from './window';
|
export * from './window';
|
||||||
|
|
|
||||||
2
src/shared/constants/teamLimits.ts
Normal file
2
src/shared/constants/teamLimits.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/** Maximum character length for messages and comments in team communication. */
|
||||||
|
export const MAX_TEXT_LENGTH = 50_000;
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
test/renderer/components/fileLink.test.ts
Normal file
93
test/renderer/components/fileLink.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue