feat: Slice 1 polish - MiMo V2.5 model, visible reasoning, image attachments, mobile keyboard fix
- Switch conversation agent to xiaomi/mimo-v2.5 (multimodal: text + image) - Render native reasoning parts as live 'Thinking trace' (streaming open, collapsed after completion); inline <think> extraction for streaming models - Image attachments: picker (up to 4, 10MB each), base64 to Flue AgentPromptImage, authenticated blob-URL replay for historical images - Mobile keyboard viewport fix: visual-viewport hook, fixed shell, interactive-widget=resizes-content, header pinned, composer follows keyboard - Conversation to Signal to proposed Work: Convex persistence, Effect validation in @code/work-os, Work cards with exact source provenance - Streamdown markdown + Mermaid chart rendering in chat messages - Flue tool turns hidden, reasoning-containing turns remain visible - Frontend regression tests: keyboard viewport, responsive shell, attachment overflow, authenticated images, reasoning traces, transforms - .env.example updated to xiaomi/mimo-v2.5 config
This commit is contained in:
@@ -55,6 +55,25 @@ describe("useChatAgent", () => {
|
||||
});
|
||||
|
||||
await chat.sendMessage("ready");
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready", undefined);
|
||||
});
|
||||
|
||||
test("forwards image attachments through to the Flue agent", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
const images = [
|
||||
{
|
||||
data: "abc",
|
||||
filename: "a.png",
|
||||
mimeType: "image/png",
|
||||
type: "image" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const chat = useChatAgent();
|
||||
await chat.sendMessage("describe this", { images });
|
||||
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("describe this", {
|
||||
images,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SendMessageOptions } from "@flue/react";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
@@ -13,14 +14,17 @@ export const useOrganizationChatAgent = (
|
||||
id: organization.organizationId,
|
||||
});
|
||||
|
||||
const sendMessage = async (message: string): Promise<void> => {
|
||||
const sendMessage = async (
|
||||
message: string,
|
||||
options?: SendMessageOptions
|
||||
): Promise<void> => {
|
||||
if (!organization.organizationId) {
|
||||
throw (
|
||||
organization.error ??
|
||||
new Error("Personal organization is still being prepared")
|
||||
);
|
||||
}
|
||||
await agent.sendMessage(message);
|
||||
await agent.sendMessage(message, options);
|
||||
};
|
||||
|
||||
let { status } = agent;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
|
||||
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||
import { generateBrowserRequestId } from "@/lib/flue-transport";
|
||||
|
||||
export const useChatImages = () => {
|
||||
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||
const [error, setError] = useState<string>();
|
||||
const previewUrls = useRef(new Set<string>());
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const previewUrl of previewUrls.current) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addFiles = (files: FileList | null) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
setImages((current) => {
|
||||
const remaining = MAX_CHAT_IMAGES - current.length;
|
||||
if (remaining <= 0) {
|
||||
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
|
||||
return current;
|
||||
}
|
||||
const next = [...current];
|
||||
for (const file of [...files].slice(0, remaining)) {
|
||||
const validation = validateChatImage(file);
|
||||
if (!validation.accepted) {
|
||||
setError(validation.message);
|
||||
continue;
|
||||
}
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
previewUrls.current.add(previewUrl);
|
||||
next.push({
|
||||
file,
|
||||
id: generateBrowserRequestId(),
|
||||
previewUrl,
|
||||
});
|
||||
}
|
||||
if (files.length > remaining) {
|
||||
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
setImages((current) => {
|
||||
const removed = current.find((image) => image.id === id);
|
||||
if (removed) {
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
previewUrls.current.delete(removed.previewUrl);
|
||||
}
|
||||
return current.filter((image) => image.id !== id);
|
||||
});
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
for (const previewUrl of previewUrls.current) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
previewUrls.current.clear();
|
||||
setImages([]);
|
||||
setError(undefined);
|
||||
};
|
||||
|
||||
return { addFiles, clear, error, handleRemove: remove, images } as const;
|
||||
};
|
||||
Reference in New Issue
Block a user