From 748d6a7b81709cc5767e9ea21ed3c1af16939239 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 01:04:00 +0200 Subject: [PATCH] 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. --- README.md | 1 + src/main/ipc/teams.ts | 5 +- .../services/team/TeamProvisioningService.ts | 42 +- .../components/chat/markdownComponents.tsx | 28 +- .../components/chat/viewers/FileLink.tsx | 146 +++++++ .../chat/viewers/MarkdownViewer.tsx | 17 +- .../team/dialogs/SendMessageDialog.tsx | 16 +- .../team/dialogs/TaskCommentInput.tsx | 9 +- .../team/dialogs/TaskCommentsSection.tsx | 8 +- .../team/dialogs/TaskDetailDialog.tsx | 153 +++++++- .../team/messages/MessageComposer.tsx | 10 +- src/shared/constants/index.ts | 1 + src/shared/constants/teamLimits.ts | 2 + ...TeamProvisioningServicePostCompact.test.ts | 368 ++++++++++++++++++ test/renderer/components/fileLink.test.ts | 93 +++++ 15 files changed, 837 insertions(+), 62 deletions(-) create mode 100644 src/renderer/components/chat/viewers/FileLink.tsx create mode 100644 src/shared/constants/teamLimits.ts create mode 100644 test/main/services/team/TeamProvisioningServicePostCompact.test.ts create mode 100644 test/renderer/components/fileLink.test.ts diff --git a/README.md b/README.md index e3be5726..a2bf10d5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@


+ ## What is this A new approach to task management with AI agents. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 434f761b..20919acf 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 02894f3d..8a098bb8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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'); diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index a78f969f..f093a572 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -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 }) => ( - - {children} - - ), + a: ({ href, children }) => { + if (href && isRelativeUrl(href)) { + return {children}; + } + return ( + + {children} + + ); + }, // Strong/Bold — inline element, no hl() strong: ({ children }) => ( diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx new file mode 100644 index 00000000..34dd9522 --- /dev/null +++ b/src/renderer/components/chat/viewers/FileLink.tsx @@ -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 ( + + {children} + + ); + } + + 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 ( + + + {children} + {copied && } + + ); +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 460640b0..d95a0baf 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 ); } + // Relative file paths — open in built-in editor or copy path + if (href && isRelativeUrl(href)) { + return {children}; + } return ( 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={