Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions app/desktop/src/__tests__/attachment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import {
formatBytes,
formatAttachmentContext,
shouldPreviewBrowserFile,
browserFilesToAttachments,
pathBasename,
type PromptAttachment,
} from '@/utils/attachment';

describe('attachment utils', () => {
describe('formatBytes', () => {
it('returns undefined for null/undefined', () => {
expect(formatBytes(undefined)).toBeUndefined();
expect(formatBytes(null as any)).toBeUndefined();
});

it('formats bytes', () => {
expect(formatBytes(500)).toBe('500 B');
});

it('formats kilobytes', () => {
expect(formatBytes(1536)).toBe('1.5 KB');
});

it('formats megabytes', () => {
expect(formatBytes(2 * 1024 * 1024)).toBe('2.0 MB');
});
});

describe('formatAttachmentContext', () => {
it('returns empty string for empty array', () => {
expect(formatAttachmentContext([])).toBe('');
});

it('formats a single attachment', () => {
const attachments: PromptAttachment[] = [
{ id: '1', name: 'test.ts', source: 'browser', size: 1024 },
];
const result = formatAttachmentContext(attachments);
expect(result).toContain('test.ts');
expect(result).toContain('1.0 KB');
expect(result).toContain('Browser file picker');
});

it('includes path for desktop attachments', () => {
const attachments: PromptAttachment[] = [
{ id: '1', name: 'file.ts', source: 'desktop', path: '/home/user/file.ts' },
];
const result = formatAttachmentContext(attachments);
expect(result).toContain('/home/user/file.ts');
});
});

describe('shouldPreviewBrowserFile', () => {
it('returns true for text/ types', () => {
const file = new File([''], 'a.csv', { type: 'text/csv' });
expect(shouldPreviewBrowserFile(file)).toBe(true);
});

it('returns true for known extensions', () => {
const file = new File([''], 'a.tsx', { type: '' });
expect(shouldPreviewBrowserFile(file)).toBe(true);
});

it('returns false for unknown binary types', () => {
const file = new File([''], 'image.png', { type: 'image/png' });
expect(shouldPreviewBrowserFile(file)).toBe(false);
});
});

describe('browserFilesToAttachments', () => {
it('converts files to attachments', async () => {
const files = [
new File(['hello'], 'test.txt', { type: 'text/plain' }),
];
const result = await browserFilesToAttachments(files);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('test.txt');
expect(result[0].source).toBe('browser');
expect(result[0].contentPreview).toBe('hello');
});
});

describe('pathBasename', () => {
it('extracts filename from unix path', () => {
expect(pathBasename('/home/user/file.ts')).toBe('file.ts');
});

it('extracts filename from windows path', () => {
expect(pathBasename('C:\\Users\\file.ts')).toBe('file.ts');
});

it('returns input when no separator', () => {
expect(pathBasename('file.ts')).toBe('file.ts');
});
});
});
136 changes: 136 additions & 0 deletions app/desktop/src/__tests__/useComposerCore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { useComposerCore } from '@/hooks/useComposerCore';

describe('useComposerCore', () => {
describe('canSend', () => {
it('allows sending by default', () => {
const { result } = renderHook(() => useComposerCore());
expect(result.current.canSend).toBe(true);
});

it('blocks sending when disabled', () => {
const { result } = renderHook(() => useComposerCore({ disabled: true }));
expect(result.current.canSend).toBe(false);
});

it('blocks sending when streaming', () => {
const { result } = renderHook(() => useComposerCore({ isStreaming: true }));
expect(result.current.canSend).toBe(false);
});

it('blocks sending when starting', () => {
const { result } = renderHook(() => useComposerCore({ isStarting: true }));
expect(result.current.canSend).toBe(false);
});

it('blocks sending when multiple states are true', () => {
const { result } = renderHook(() => useComposerCore({ disabled: true, isStreaming: true }));
expect(result.current.canSend).toBe(false);
});
});

describe('trimForSend', () => {
it('returns trimmed text when canSend is true', () => {
const { result } = renderHook(() => useComposerCore());
expect(result.current.trimForSend(' hello ')).toBe('hello');
});

it('returns null for whitespace-only text', () => {
const { result } = renderHook(() => useComposerCore());
expect(result.current.trimForSend(' ')).toBeNull();
});

it('returns null for empty text', () => {
const { result } = renderHook(() => useComposerCore());
expect(result.current.trimForSend('')).toBeNull();
});

it('returns null when disabled', () => {
const { result } = renderHook(() => useComposerCore({ disabled: true }));
expect(result.current.trimForSend('hello')).toBeNull();
});

it('returns null when streaming', () => {
const { result } = renderHook(() => useComposerCore({ isStreaming: true }));
expect(result.current.trimForSend('hello')).toBeNull();
});

it('returns null when starting', () => {
const { result } = renderHook(() => useComposerCore({ isStarting: true }));
expect(result.current.trimForSend('hello')).toBeNull();
});
});

describe('handleEnterKey', () => {
it('calls sendAction on Enter without Shift', () => {
const { result } = renderHook(() => useComposerCore());
const sendAction = vi.fn();
const e = { key: 'Enter', shiftKey: false, preventDefault: vi.fn() } as unknown as React.KeyboardEvent;

const handled = result.current.handleEnterKey(e, sendAction);

expect(handled).toBe(true);
expect(sendAction).toHaveBeenCalledOnce();
});

it('does not call sendAction on Shift+Enter', () => {
const { result } = renderHook(() => useComposerCore());
const sendAction = vi.fn();
const e = { key: 'Enter', shiftKey: true, preventDefault: vi.fn() } as unknown as React.KeyboardEvent;

const handled = result.current.handleEnterKey(e, sendAction);

expect(handled).toBe(false);
expect(sendAction).not.toHaveBeenCalled();
});

it('does not call sendAction for non-Enter keys', () => {
const { result } = renderHook(() => useComposerCore());
const sendAction = vi.fn();
const e = { key: 'Tab', shiftKey: false, preventDefault: vi.fn() } as unknown as React.KeyboardEvent;

const handled = result.current.handleEnterKey(e, sendAction);

expect(handled).toBe(false);
expect(sendAction).not.toHaveBeenCalled();
});

it('prevents default on Enter', () => {
const { result } = renderHook(() => useComposerCore());
const preventDefault = vi.fn();
const e = { key: 'Enter', shiftKey: false, preventDefault } as unknown as React.KeyboardEvent;

result.current.handleEnterKey(e, vi.fn());

expect(preventDefault).toHaveBeenCalledOnce();
});
});

describe('autoResize', () => {
it('sets textarea height to scrollHeight', () => {
const { result } = renderHook(() => useComposerCore());
const textarea = document.createElement('textarea');
Object.defineProperty(textarea, 'scrollHeight', { value: 120 });
vi.spyOn(textarea.style, 'height', 'set');

result.current.autoResize(textarea);

expect(textarea.style.height).toBe('120px');
});
});

describe('clearTextarea', () => {
it('clears value and resets height', () => {
const { result } = renderHook(() => useComposerCore());
const textarea = document.createElement('textarea');
textarea.value = 'some text';

result.current.clearTextarea(textarea);

expect(textarea.value).toBe('');
expect(textarea.style.height).toBe('auto');
});
});
});
39 changes: 16 additions & 23 deletions app/desktop/src/components/IM/IMMessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { useState, useRef, useCallback, memo, useMemo } from 'react';
import { Send } from 'lucide-react';
import type { AgentInfo } from '@shared/types';
import { useMention, type MentionItem } from '@/hooks/useMention';
import { useComposerCore } from '@/hooks/useComposerCore';
import MentionPopover from '@/components/MentionPopover';
import type { IMMessageMention } from './types';
import type { IMMessageMention, ComposerPayload } from './types';
import styles from './IMMessageInput.module.css';

const MAX_CHARS = 2000;
Expand All @@ -17,10 +18,7 @@ interface IMMessageInputProps {
agents?: AgentInfo[];
}

export interface SendPayload {
content: string;
mentions?: IMMessageMention[];
}
export interface SendPayload extends ComposerPayload {}

const IMMessageInput = memo(function IMMessageInput({
onSend,
Expand All @@ -32,6 +30,8 @@ const IMMessageInput = memo(function IMMessageInput({
const [mentionedAgents, setMentionedAgents] = useState<IMMessageMention[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const { autoResize, clearTextarea, handleEnterKey, trimForSend } = useComposerCore({ disabled });

// Build mention items from the agents prop
const mentionItems = useMemo<MentionItem[]>(
() =>
Expand Down Expand Up @@ -73,17 +73,16 @@ const IMMessageInput = memo(function IMMessageInput({
} = useMention({ agents, items: mentionItems, onSelectAgent });

const handleSend = useCallback(async () => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
const trimmed = trimForSend(value);
if (trimmed === null) return;
const currentMentions = mentionedAgents.length > 0 ? [...mentionedAgents] : undefined;
const accepted = await onSend(trimmed, currentMentions);
if (accepted === false) return;
setValue('');
setMentionedAgents([]);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [value, disabled, mentionedAgents, onSend]);
const ta = textareaRef.current;
if (ta) clearTextarea(ta);
}, [value, mentionedAgents, onSend, trimForSend, clearTextarea]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
Expand All @@ -92,28 +91,22 @@ const IMMessageInput = memo(function IMMessageInput({
const consumed = mentionHandleKeyDown(e);
if (consumed) return;
}
// Send on Enter (no shift)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
handleEnterKey(e, handleSend);
},
[handleSend, mentionOpen, mentionHandleKeyDown],
[handleSend, mentionOpen, mentionHandleKeyDown, handleEnterKey],
);

const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
// Auto-resize
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
// Check for @ mentions
autoResize(e.target);
mentionHandleInput();
},
[mentionHandleInput],
[mentionHandleInput, autoResize],
);

const overLimit = value.length > MAX_CHARS;
const sendDisabled = disabled || value.trim().length === 0;

return (
<div className={styles.root}>
Expand Down Expand Up @@ -163,7 +156,7 @@ const IMMessageInput = memo(function IMMessageInput({
<button
className={styles.sendBtn}
onClick={handleSend}
disabled={disabled || value.trim().length === 0}
disabled={sendDisabled}
aria-label="Send message"
title="Send message"
>
Expand Down
2 changes: 1 addition & 1 deletion app/desktop/src/components/IM/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ export { default as IMMessageView } from './IMMessageView';
export { default as IMMessageInput } from './IMMessageInput';
export { default as IMContactList } from './IMContactList';
export { default as IMBlockRenderer } from './IMBlockRenderer';
export type { IMMessage, IMMessageMention, IMContact, AuthorityType } from './types';
export type { IMMessage, IMMessageMention, IMContact, AuthorityType, ComposerPayload } from './types';
6 changes: 6 additions & 0 deletions app/desktop/src/components/IM/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export interface IMMessageWithHubState extends IMMessage {
hubError?: string;
}

/** Richer payload built by IMMessageInput internally. Backward-compatible with onSend(content, mentions). */
export interface ComposerPayload {
content: string;
mentions?: IMMessageMention[];
}

export interface IMContact {
id: string;
name: string;
Expand Down
Loading
Loading