Skip to content
Merged
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
106 changes: 60 additions & 46 deletions frontend/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ interface Props {
initialText?: string;
cwd?: string;
voice?: UseVoiceReturn;
branch?: string;
isWorktree?: boolean;
}

export function ChatInput({
Expand All @@ -32,6 +34,8 @@ export function ChatInput({
initialText,
cwd,
voice,
branch,
isWorktree,
}: Props) {
const [text, setText] = useState(initialText || '');
const [images, setImages] = useState<ImageAttachment[]>([]);
Expand Down Expand Up @@ -150,6 +154,24 @@ export function ChatInput({

const canSend = text.trim() || images.length > 0;

const micProps = voice
? {
available: voice.available,
recording: voice.recording,
transcribing: voice.transcribing,
micBlocked: voice.micBlocked,
onRecordStart: voice.startRecording,
onRecordStop: async () => {
const transcript = await voice.stopRecording();
if (transcript) {
setText((prev) => (prev ? `${prev} ${transcript}` : transcript));
textareaRef.current?.focus();
}
},
onRecordCancel: voice.cancelRecording,
}
: null;

function handleInterrupt() {
if (!onInterrupt) return;
const trimmed = text.trim();
Expand Down Expand Up @@ -191,54 +213,46 @@ export function ChatInput({
{voice?.recording && voice.partialTranscript && (
<div className="voice-partial">{voice.partialTranscript}</div>
)}
<div className="chat-input-row">
<div className="chat-input-actions">
<button
className="chat-input-btn chat-input-btn--skills"
onClick={() => {
if (!text.startsWith('/')) setText('/');
setShowSlashPicker(true);
textareaRef.current?.focus();
}}
title="Skills"
>
/
</button>
<button
className="chat-input-btn chat-input-btn--attach"
onClick={() => fileInputRef.current?.click()}
disabled={images.length >= MAX_IMAGE_ATTACHMENTS}
title="Attach image"
<div className="chat-input-command-strip">
<button
className="chat-input-btn chat-input-btn--skills"
onClick={() => {
if (!text.startsWith('/')) setText('/');
setShowSlashPicker(true);
textareaRef.current?.focus();
}}
title="Skills"
>
/
</button>
<button
className="chat-input-btn chat-input-btn--attach"
onClick={() => fileInputRef.current?.click()}
disabled={images.length >= MAX_IMAGE_ATTACHMENTS}
title="Attach image"
>
+
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
capture="environment"
onChange={handleFileChange}
className="sr-only"
/>
{micProps && <MicButton {...micProps} />}
{branch && (
<span
className={`chat-input-branch${isWorktree ? ' chat-input-branch--wt' : ''}`}
title={branch}
>
+
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
multiple
capture="environment"
onChange={handleFileChange}
className="sr-only"
/>
</div>
{voice && (
<MicButton
available={voice.available}
recording={voice.recording}
transcribing={voice.transcribing}
micBlocked={voice.micBlocked}
onRecordStart={voice.startRecording}
onRecordStop={async () => {
const transcript = await voice.stopRecording();
if (transcript) {
setText((prev) => (prev ? `${prev} ${transcript}` : transcript));
textareaRef.current?.focus();
}
}}
onRecordCancel={voice.cancelRecording}
/>
{branch}
</span>
)}
</div>
<div className="chat-input-row">
<textarea
ref={textareaRef}
className="chat-input-field"
Expand Down
109 changes: 109 additions & 0 deletions frontend/src/components/__tests__/ChatInputCommandStrip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { ChatInput } from '../ChatInput';
import type { UseVoiceReturn } from '../../hooks/useVoice';

vi.mock('../SlashPicker', () => ({
SlashPicker: () => null,
}));

afterEach(() => cleanup());

function makeVoice(overrides: Partial<UseVoiceReturn> = {}): UseVoiceReturn {
return {
available: true,
recording: false,
transcribing: false,
partialTranscript: '',
micBlocked: false,
error: null,
startRecording: vi.fn(),
stopRecording: vi.fn(() => Promise.resolve('')),
cancelRecording: vi.fn(),
ttsAvailable: false,
ttsEnabled: false,
speaking: false,
voices: [],
selectedVoice: 'af_heart',
speak: vi.fn(),
stopSpeaking: vi.fn(),
setTtsEnabled: vi.fn(),
setVoice: vi.fn(),
...overrides,
};
}

const noop = () => true;
const noopVoid = () => {};

describe('ChatInput command strip', () => {
it('renders slash and attach buttons', () => {
render(<ChatInput onSend={noop} onStop={noopVoid} running={false} />);
expect(screen.getByTitle('Skills')).toBeTruthy();
expect(screen.getByTitle('Attach image')).toBeTruthy();
});

it('renders branch pill when branch is provided', () => {
const { container } = render(
<ChatInput onSend={noop} onStop={noopVoid} running={false} branch="main" />,
);
const pill = container.querySelector('.chat-input-branch');
expect(pill).toBeTruthy();
expect(pill?.textContent).toBe('main');
});

it('does not render branch pill when branch is undefined', () => {
const { container } = render(<ChatInput onSend={noop} onStop={noopVoid} running={false} />);
expect(container.querySelector('.chat-input-branch')).toBeNull();
});

it('applies worktree class when isWorktree is true', () => {
const { container } = render(
<ChatInput onSend={noop} onStop={noopVoid} running={false} branch="feat/test" isWorktree />,
);
expect(container.querySelector('.chat-input-branch--wt')).toBeTruthy();
});

it('sets title attribute on branch pill for long names', () => {
const longBranch = 'feat/command-strip-redesign-v2-with-extra-context';
const { container } = render(
<ChatInput onSend={noop} onStop={noopVoid} running={false} branch={longBranch} />,
);
const pill = container.querySelector('.chat-input-branch');
expect(pill?.getAttribute('title')).toBe(longBranch);
});

it('renders mic button in command strip when voice is available', () => {
const voice = makeVoice();
const { container } = render(
<ChatInput onSend={noop} onStop={noopVoid} running={false} voice={voice} />,
);
const strip = container.querySelector('.chat-input-command-strip');
const mic = strip?.querySelector('.mic-btn');
expect(mic).toBeTruthy();
});

it('keeps single mic button regardless of text input', () => {
const voice = makeVoice();
const { container } = render(
<ChatInput onSend={noop} onStop={noopVoid} running={false} voice={voice} />,
);

// Initially empty — one mic
const mics = container.querySelectorAll('.mic-btn');
expect(mics).toHaveLength(1);

// Type text — still one mic in same position
const textarea = container.querySelector('textarea')!;
fireEvent.change(textarea, { target: { value: 'hello' } });
expect(container.querySelectorAll('.mic-btn')).toHaveLength(1);
});

it('opens slash picker when / button is clicked', () => {
const { container } = render(<ChatInput onSend={noop} onStop={noopVoid} running={false} />);
fireEvent.click(screen.getByTitle('Skills'));
const textarea = container.querySelector('textarea');
expect(textarea?.value).toBe('/');
});
});
9 changes: 2 additions & 7 deletions frontend/src/pages/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
if (distFromBottom <= SCROLL_NEAR_BOTTOM_PX) {
el.scrollTop = el.scrollHeight;
}
}, [msgState.messages, msgState.current]);

Check warning on line 95 in frontend/src/pages/ChatView.tsx

View workflow job for this annotation

GitHub Actions / ci

React Hook useEffect has an unnecessary dependency: 'msgState.current'. Either exclude it or remove the dependency array. Mutable values like 'msgState.current' aren't valid dependencies because mutating them doesn't re-render the component

// Restore messages when navigating to an existing session.
// Fetch from the API (single source of truth — no localStorage cache).
Expand Down Expand Up @@ -269,13 +269,6 @@
onToggle={() => voice.setTtsEnabled(!voice.ttsEnabled)}
onVoiceChange={voice.setVoice}
/>
{msgState.branch && (
<span
className={`chat-header-branch${msgState.isWorktree ? ' chat-header-branch--wt' : ''}`}
>
{msgState.branch}
</span>
)}
{msgState.running && (
<button className="chat-header-stop" onClick={handleStop}>
Stop
Expand Down Expand Up @@ -354,6 +347,8 @@
running={msgState.running}
initialText={initialPrompt}
voice={voice}
branch={msgState.branch || undefined}
isWorktree={msgState.isWorktree}
/>
</div>
);
Expand Down
38 changes: 29 additions & 9 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -1659,6 +1659,34 @@ textarea:focus {
flex-shrink: 0;
}

.chat-input-command-strip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.25rem;
}

.chat-input-branch {
font-family: var(--code-font);
font-size: 0.65rem;
color: var(--text-dim);
background: var(--surface);
padding: 0.15rem 0.4rem;
border-radius: 4px;
border: 1px solid var(--border);
max-width: 7rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 0.5rem;
}

.chat-input-branch--wt {
color: var(--accent);
border-color: var(--accent);
background: rgba(108, 99, 255, 0.1);
}

.chat-input-row {
display: flex;
align-items: flex-end;
Expand All @@ -1667,15 +1695,7 @@ textarea:focus {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 22px;
padding: 0.35rem 0.35rem 0.35rem 0.35rem;
}

.chat-input-actions {
display: flex;
flex-direction: column;
gap: 0.15rem;
align-self: flex-end;
flex-shrink: 0;
padding: 0.35rem 0.35rem 0.35rem 0.75rem;
}

.chat-input-field {
Expand Down
Loading