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>
|
||||
|
||||
<br />
|
||||
|
||||
## What is this
|
||||
|
||||
A new approach to task management with AI agents.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
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,
|
||||
} 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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
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