(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 (
+
+
+ thumbUrl && onPreview(thumbUrl)}
+ >
+ {thumbUrl ? (
+

+ ) : (
+
+ )}
+
+ {item.attachment.filename}
+
+
+
+
+ {tooltipText}
+
+
+ );
+};
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index 88757314..41e598cb 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -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 (
diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts
index 4ea2882c..c6567b40 100644
--- a/src/shared/constants/index.ts
+++ b/src/shared/constants/index.ts
@@ -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';
diff --git a/src/shared/constants/teamLimits.ts b/src/shared/constants/teamLimits.ts
new file mode 100644
index 00000000..b759fe42
--- /dev/null
+++ b/src/shared/constants/teamLimits.ts
@@ -0,0 +1,2 @@
+/** Maximum character length for messages and comments in team communication. */
+export const MAX_TEXT_LENGTH = 50_000;
diff --git a/test/main/services/team/TeamProvisioningServicePostCompact.test.ts b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts
new file mode 100644
index 00000000..295e9150
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningServicePostCompact.test.ts
@@ -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();
+ 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);
+ });
+});
diff --git a/test/renderer/components/fileLink.test.ts b/test/renderer/components/fileLink.test.ts
new file mode 100644
index 00000000..8f1f2cf9
--- /dev/null
+++ b/test/renderer/components/fileLink.test.ts
@@ -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,hi
')).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);
+ });
+});