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
43 changes: 2 additions & 41 deletions frontend/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,8 @@ import {
type KeyboardEvent,
type ChangeEvent,
} from 'react';

export interface ImageAttachment {
data: string;
mediaType: string;
preview: string;
}
import type { ImageAttachment } from '../types/chat';
import { resizeImage } from '../lib/resizeImage';

interface Props {
onSend: (text: string, images?: ImageAttachment[]) => void;
Expand All @@ -21,41 +17,6 @@ interface Props {
}

const MAX_IMAGES = 4;
const MAX_DIMENSION = 1600;

function resizeImage(file: File): Promise<ImageAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
const scale = MAX_DIMENSION / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('Canvas not supported'));

ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL(file.type || 'image/jpeg', 0.85);
const [header, data] = dataUrl.split(',');
const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';

resolve({ data, mediaType, preview: dataUrl });
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = reader.result as string;
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}

export function ChatInput({ onSend, onStop, running, initialText }: Props) {
const [text, setText] = useState(initialText || '');
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Message } from '../pages/ChatView';
import type { Message } from '../types/chat';

interface Props {
message: Message;
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/PermissionBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { truncate } from '../lib/truncate';

interface Props {
permId: string;
Expand Down Expand Up @@ -38,9 +39,7 @@ export function PermissionBanner({ permId, toolName, toolInput, onRespond }: Pro
<div className="perm-banner">
<div className="perm-banner-info">
<span className="perm-banner-tool">{toolName}</span>
<pre className="perm-banner-input">
{toolInput.length > 200 ? toolInput.slice(0, 200) + '...' : toolInput}
</pre>
<pre className="perm-banner-input">{truncate(toolInput, 200)}</pre>
<span className="perm-banner-timer">Auto-deny in {remaining}s</span>
</div>
<div className="perm-banner-actions">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ToolGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { ToolPill } from './ToolPill';
import type { Message } from '../pages/ChatView';
import type { Message } from '../types/chat';

interface Props {
tools: Message[];
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/ToolPill.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { Message } from '../pages/ChatView';
import type { Message } from '../types/chat';
import { truncate } from '../lib/truncate';

interface Props {
message: Message;
Expand All @@ -9,7 +10,7 @@ export function ToolPill({ message }: Props) {
const [expanded, setExpanded] = useState(false);
const done = message.toolResult !== undefined;
const input = message.toolInput || '';
const truncatedInput = input.length > 60 ? input.slice(0, 60) + '...' : input;
const truncatedInput = truncate(input, 60);

return (
<div className={`tool-pill ${done ? 'tool-pill--done' : 'tool-pill--running'}`}>
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/formatTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
29 changes: 29 additions & 0 deletions frontend/src/lib/groupMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Message, GroupedItem } from '../types/chat';

export function groupMessages(messages: Message[]): GroupedItem[] {
const result: GroupedItem[] = [];
let toolBuffer: Message[] = [];

function flushTools() {
if (toolBuffer.length === 0) return;
if (toolBuffer.length >= 3) {
result.push({ type: 'tool-group', tools: toolBuffer });
} else {
for (const t of toolBuffer) {
result.push({ type: 'message', message: t });
}
}
toolBuffer = [];
}

for (const msg of messages) {
if (msg.role === 'tool') {
toolBuffer.push(msg);
} else {
flushTools();
result.push({ type: 'message', message: msg });
}
}
flushTools();
return result;
}
37 changes: 37 additions & 0 deletions frontend/src/lib/resizeImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ImageAttachment } from '../types/chat';

const MAX_DIMENSION = 1600;

export function resizeImage(file: File): Promise<ImageAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
const scale = MAX_DIMENSION / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return reject(new Error('Canvas not supported'));

ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL(file.type || 'image/jpeg', 0.85);
const [header, data] = dataUrl.split(',');
const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';

resolve({ data, mediaType, preview: dataUrl });
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = reader.result as string;
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
3 changes: 3 additions & 0 deletions frontend/src/lib/truncate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function truncate(str: string, max: number): string {
return str.length > max ? str.slice(0, max) + '...' : str;
}
Loading
Loading