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
11 changes: 7 additions & 4 deletions chat-ui/src/components/assistant-message.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChatMessage, ThinkingBlockState, ToolCallState } from "@/lib/chat-types";
import { getAssistantTextBlocks } from "@/lib/chat-message-content";
import { Markdown } from "./markdown";
import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";
Expand All @@ -12,8 +13,8 @@ export function AssistantMessage({
toolCalls: ToolCallState[];
thinkingBlocks: ThinkingBlockState[];
}) {
const textContent =
message.content.find((b) => b.type === "text")?.text ?? "";
const textBlocks = getAssistantTextBlocks(message);
const hasText = textBlocks.length > 0;

const isStreaming = message.status === "streaming";

Expand All @@ -28,9 +29,11 @@ export function AssistantMessage({
<ToolCallCard key={tool.id} tool={tool} />
))}

{textContent && <Markdown content={textContent} />}
{textBlocks.map((textContent, index) => (
<Markdown key={`text-${index}`} content={textContent} />
))}

{isStreaming && !textContent && toolCalls.length === 0 && (
{isStreaming && !hasText && toolCalls.length === 0 && (
<div className="flex items-center gap-1.5 py-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-primary" />
<div className="h-2 w-2 animate-pulse rounded-full bg-primary [animation-delay:150ms]" />
Expand Down
27 changes: 17 additions & 10 deletions chat-ui/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function ChatInput({
onRemoveFile,
initialText,
}: {
onSend: (text: string) => void;
onSend: (text: string) => boolean | void | Promise<boolean | void>;
onStop: () => void;
isStreaming: boolean;
disabled?: boolean;
Expand All @@ -25,6 +25,7 @@ export function ChatInput({
initialText?: string;
}) {
const [text, setText] = useState(initialText ?? "");
const [isSubmitting, setIsSubmitting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -46,15 +47,21 @@ export function ChatInput({
}
}, [initialText]);

const handleSend = useCallback(() => {
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed || isStreaming) return;
onSend(trimmed);
setText("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
if (!trimmed || isStreaming || disabled || isSubmitting) return;
setIsSubmitting(true);
try {
const sent = await onSend(trimmed);
if (sent === false) return;
setText("");
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
} finally {
setIsSubmitting(false);
}
}, [text, isStreaming, onSend]);
}, [text, isStreaming, disabled, isSubmitting, onSend]);

const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
Expand Down Expand Up @@ -120,7 +127,7 @@ export function ChatInput({
}}
placeholder="Send a message..."
rows={1}
disabled={disabled}
disabled={disabled || isSubmitting}
enterKeyHint="send"
className="max-h-[200px] min-h-[36px] flex-1 resize-none bg-transparent px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
aria-label="Message input"
Expand All @@ -141,7 +148,7 @@ export function ChatInput({
variant="ghost"
size="icon"
onClick={handleSend}
disabled={!text.trim() || disabled}
disabled={!text.trim() || disabled || isSubmitting}
className="h-8 w-8 shrink-0 rounded-lg bg-primary text-primary-content hover:bg-primary/90 disabled:opacity-50"
aria-label="Send message"
>
Expand Down
4 changes: 2 additions & 2 deletions chat-ui/src/components/tool-call-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,11 @@ export function ToolCallCard({ tool }: { tool: ToolCallState }) {
const inputDetails = toolInputDetails(tool);
const output = tool.output ? redactSensitiveText(truncate(tool.output, TOOL_OUTPUT_DISPLAY_LIMIT)) : "";

const autoExpand = tool.state === "running" || tool.state === "result" || tool.state === "error" || tool.state === "blocked";
const autoExpand = tool.state === "error" || tool.state === "blocked";
const [isOpen, setIsOpen] = useState(autoExpand);

useEffect(() => {
if (tool.state === "running" || tool.state === "result" || tool.state === "error" || tool.state === "blocked") {
if (tool.state === "error" || tool.state === "blocked") {
setIsOpen(true);
}
}, [tool.state]);
Expand Down
55 changes: 54 additions & 1 deletion chat-ui/src/components/user-message.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,67 @@
import type { ChatMessage } from "@/lib/chat-types";
import { File, FileText } from "lucide-react";

export function UserMessage({ message }: { message: ChatMessage }) {
const text =
message.content.find((b) => b.type === "text")?.text ?? "";
const attachments = message.attachments ?? [];

return (
<div className="flex justify-end">
<div className="max-w-[78%] rounded-xl rounded-br-md border border-primary/20 bg-primary/10 px-3.5 py-2.5 text-foreground shadow-sm shadow-black/5">
<p className="whitespace-pre-wrap text-sm">{text}</p>
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.previewUrl}
target="_blank"
rel="noreferrer"
className="flex max-w-full items-center gap-1.5 rounded-md border border-primary/15 bg-background/70 px-2 py-1 text-xs text-foreground shadow-sm transition-colors hover:bg-background"
title={attachment.filename}
>
<AttachmentIcon mimeType={attachment.mimeType} previewUrl={attachment.previewUrl} filename={attachment.filename} />
<span className="min-w-0 max-w-[12rem] truncate">{attachment.filename}</span>
{attachment.sizeBytes != null && (
<span className="shrink-0 text-muted-foreground">{formatBytes(attachment.sizeBytes)}</span>
)}
</a>
))}
</div>
)}
{text && <p className="whitespace-pre-wrap text-sm">{text}</p>}
</div>
</div>
);
}

function AttachmentIcon({
mimeType,
previewUrl,
filename,
}: {
mimeType: string;
previewUrl: string;
filename: string;
}) {
if (mimeType.startsWith("image/")) {
return (
<span className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded border border-border bg-muted">
<img src={previewUrl} alt={filename} className="h-full w-full object-cover" />
</span>
);
}
if (mimeType === "application/pdf") {
return <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />;
}
if (mimeType.startsWith("text/") || mimeType === "application/json") {
return <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />;
}
return <File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />;
}

function formatBytes(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
70 changes: 59 additions & 11 deletions chat-ui/src/hooks/use-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,29 @@ export type PendingAttachment = {

export type AttachmentResult = {
id: string;
client_id?: string;
filename: string;
mime_type: string;
size: number;
preview_url: string;
};

export type UploadFilesResult = {
intendedCount: number;
acceptedIds: string[];
failedCount: number;
};

export function shouldBlockSendAfterUpload(result: UploadFilesResult): boolean {
return result.intendedCount > 0 && result.failedCount > 0;
}

export function useAttachments(): {
files: PendingAttachment[];
addFiles: (newFiles: File[]) => void;
removeFile: (id: string) => void;
clearFiles: () => void;
uploadFiles: (sessionId: string) => Promise<string[]>;
uploadFiles: (sessionId: string) => Promise<UploadFilesResult>;
hasFiles: boolean;
isUploading: boolean;
} {
Expand Down Expand Up @@ -103,15 +114,31 @@ export function useAttachments(): {
}, []);

const uploadFiles = useCallback(
async (sessionId: string): Promise<string[]> => {
const pending = filesRef.current.filter((f) => f.status === "pending");
if (pending.length === 0) return [];
async (sessionId: string): Promise<UploadFilesResult> => {
const current = filesRef.current;
const intendedCount = current.length;
const alreadyAcceptedIds = current
.map((file) => (file.status === "done" ? file.serverId : undefined))
.filter((id): id is string => typeof id === "string" && id.length > 0);
const pending = current.filter((f) => f.serverId === undefined && (f.status === "pending" || f.status === "error"));
if (intendedCount === 0) {
return { intendedCount: 0, acceptedIds: [], failedCount: 0 };
}
if (pending.length === 0) {
return {
intendedCount,
acceptedIds: alreadyAcceptedIds,
failedCount: intendedCount - alreadyAcceptedIds.length,
};
}

setFiles((prev) => prev.map((f) => (f.status === "pending" ? { ...f, status: "uploading" as const } : f)));
const pendingIds = new Set(pending.map((file) => file.id));
setFiles((prev) => prev.map((f) => (pendingIds.has(f.id) ? { ...f, status: "uploading" as const } : f)));

const formData = new FormData();
for (const p of pending) {
formData.append("file", p.file);
formData.append("client_id", p.id);
}

try {
Expand All @@ -133,12 +160,12 @@ export function useAttachments(): {
prev.map((f) => (f.status === "uploading" ? { ...f, status: "error" as const } : f)),
);
toast.error(errorMsg);
return [];
return { intendedCount, acceptedIds: alreadyAcceptedIds, failedCount: pending.length };
}

const body = (await res.json()) as {
attachments?: AttachmentResult[];
rejected?: Array<{ filename: string; reason: string; message: string }>;
rejected?: Array<{ client_id?: string; filename: string; reason: string; message: string }>;
};

if (body.rejected) {
Expand All @@ -147,19 +174,40 @@ export function useAttachments(): {
}
}

const serverIds = (body.attachments ?? []).map((a) => a.id);
const accepted = body.attachments ?? [];
const acceptedByClientId = new Map(
accepted
.filter((attachment) => typeof attachment.client_id === "string")
.map((attachment) => [attachment.client_id as string, attachment.id] as const),
);
const rejectedIds = new Set(
(body.rejected ?? [])
.map((rejection) => rejection.client_id)
.filter((id): id is string => typeof id === "string" && id.length > 0),
);

setFiles((prev) =>
prev.map((f) => (f.status === "uploading" ? { ...f, status: "done" as const } : f)),
prev.map((f) => {
if (!pendingIds.has(f.id)) return f;
const serverId = acceptedByClientId.get(f.id);
if (serverId) {
return { ...f, status: "done" as const, serverId };
}
return { ...f, status: "error" as const };
}),
);

return serverIds;
const acceptedIds = [...alreadyAcceptedIds, ...accepted.map((attachment) => attachment.id)];
const failedCount = pending.filter(
(file) => !acceptedByClientId.has(file.id) || rejectedIds.has(file.id),
).length;
return { intendedCount, acceptedIds, failedCount };
} catch {
setFiles((prev) =>
prev.map((f) => (f.status === "uploading" ? { ...f, status: "error" as const } : f)),
);
toast.error("Upload failed. Please try again.");
return [];
return { intendedCount, acceptedIds: alreadyAcceptedIds, failedCount: pending.length };
}
},
[],
Expand Down
22 changes: 4 additions & 18 deletions chat-ui/src/hooks/use-chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { runTimelineSummaryToView } from "@/lib/chat-activity";
import { parseMessageContentJson } from "@/lib/chat-message-content";
import { type ChatStore, beginRunActivity, createChatStore, dispatchFrame } from "@/lib/chat-store";
import type {
ChatMessage,
Expand Down Expand Up @@ -265,28 +266,13 @@ function buildTimelineViewMap(detail: SessionDetail): Map<string, RunTimelineVie
}

function messageRowToChatMessage(row: SessionDetail["messages"][number], runTimeline?: RunTimelineView): ChatMessage {
let contentBlocks: Array<{
type: string;
text?: string;
[key: string]: unknown;
}> = [];
try {
const parsed = JSON.parse(row.content_json);
if (typeof parsed === "string") {
contentBlocks = [{ type: "text", text: parsed }];
} else if (Array.isArray(parsed)) {
contentBlocks = parsed;
} else {
contentBlocks = [parsed];
}
} catch {
contentBlocks = [{ type: "text", text: row.content_json }];
}
const parsed = parseMessageContentJson(row.content_json, row.role);

return {
id: row.id,
role: row.role as "user" | "assistant",
content: contentBlocks,
content: parsed.contentBlocks,
attachments: parsed.attachments,
createdAt: row.created_at,
status: row.status as "committed" | "streaming" | "error",
stopReason: row.stop_reason,
Expand Down
Loading
Loading