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:
-Puter
2026-07-27 12:22:54 +05:30
parent 58ff7942bd
commit d8383a788e
55 changed files with 6487 additions and 2006 deletions

View File

@@ -15,6 +15,7 @@
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*",
"@code/work-os": "workspace:*",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
"@react-router/fs-routes": "^8.1.0",

View File

@@ -0,0 +1,162 @@
import { useConvexAccessToken } from "@code/auth/web";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@code/ui/components/attachment";
import type { FlueConversationPart } from "@flue/react";
import { ImageIcon, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PendingChatImage } from "@/lib/chat/attachments";
type FilePart = Extract<FlueConversationPart, { type: "file" }>;
const isDirectlyRenderableUrl = (url: string): boolean =>
url.startsWith("blob:") || url.startsWith("data:");
const useAuthenticatedImageSource = (url?: string): string | undefined => {
const resolveAccessToken = useConvexAccessToken();
const [loaded, setLoaded] = useState<{
readonly source: string;
readonly url: string;
}>();
useEffect(() => {
if (!url || isDirectlyRenderableUrl(url)) {
return;
}
let active = true;
let objectUrl: string | undefined;
const load = async (): Promise<void> => {
try {
const accessToken = await resolveAccessToken();
const response = await fetch(url, {
headers: accessToken
? { authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
return;
}
objectUrl = URL.createObjectURL(await response.blob());
if (active) {
setLoaded({ source: objectUrl, url });
}
} catch {
// The attachment remains represented by its filename and media type.
}
};
void load();
return () => {
active = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [resolveAccessToken, url]);
if (url && isDirectlyRenderableUrl(url)) {
return url;
}
return loaded && loaded.url === url ? loaded.source : undefined;
};
const ImagePreview = ({
alt,
mediaType,
url,
}: {
readonly alt: string;
readonly mediaType: string;
readonly url?: string;
}) => {
const source = useAuthenticatedImageSource(url);
return (
<AttachmentMedia
variant={mediaType.startsWith("image/") ? "image" : "icon"}
>
{source && mediaType.startsWith("image/") ? (
<img alt={alt} className="size-full object-cover" src={source} />
) : (
<ImageIcon className="size-4" />
)}
</AttachmentMedia>
);
};
export const PendingChatAttachments = ({
images,
onRemove,
}: {
readonly images: readonly PendingChatImage[];
readonly onRemove: (id: string) => void;
}) => (
<AttachmentGroup className="max-w-full gap-2 pb-2">
{images.map((image) => (
<Attachment
className="w-24 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={image.id}
orientation="vertical"
size="sm"
>
<ImagePreview
alt={image.file.name}
mediaType={image.file.type}
url={image.previewUrl}
/>
<AttachmentContent>
<AttachmentTitle>{image.file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${image.file.name}`}
className="bg-white/90 hover:bg-white"
onClick={() => onRemove(image.id)}
>
<X className="size-3.5" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
);
export const MessageAttachments = ({
parts,
}: {
readonly parts: readonly FilePart[];
}) => {
if (parts.length === 0) {
return null;
}
return (
<AttachmentGroup className="mb-2 max-w-full gap-2">
{parts.map((part, index) => {
const title = part.filename ?? `Image ${index + 1}`;
return (
<Attachment
className="min-w-0 max-w-48 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
key={part.id ?? `${part.mediaType}:${index}`}
size="sm"
>
<ImagePreview
alt={title}
mediaType={part.mediaType}
url={part.url}
/>
<AttachmentContent>
<AttachmentTitle>{title}</AttachmentTitle>
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
</AttachmentContent>
</Attachment>
);
})}
</AttachmentGroup>
);
};

View File

@@ -7,26 +7,63 @@ import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import type { FlueConversationMessage } from "@flue/react";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types";
import {
getMessageText,
getReasoningText,
hasToolActivity,
isReasoningStreaming,
isMessageStreaming,
} from "@/lib/chat/transforms";
import type {
AssistantResponseState,
ChatMessageProps,
} from "@/lib/chat/types";
import { AssistantIdentity } from "./assistant-identity";
import { MessageAttachments } from "./chat-attachments";
import { ChatToolCall } from "./chat-tool-call";
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
if (message.parts.length === 0 && !isStreaming) {
const ReasoningTrace = ({
isStreaming,
text,
}: {
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (!text) {
return null;
}
return (
<details
className="mb-3 border-l-2 border-[#b9b5aa] pl-3 text-[#5f5d55]"
open={isStreaming ? true : undefined}
>
<summary className="cursor-pointer select-none text-xs font-medium text-[#69675e]">
{isStreaming ? "Thinking live" : "Thinking trace"}
</summary>
<MessageResponse
className="chat-reasoning mt-2 min-w-0 text-xs leading-5"
isAnimating={isStreaming}
>
{text}
</MessageResponse>
</details>
);
};
let content: ReactNode = text;
if (!isUser) {
content = text ? (
const AssistantText = ({
hasReasoning,
isStreaming,
text,
}: {
readonly hasReasoning: boolean;
readonly isStreaming: boolean;
readonly text: string;
}) => {
if (text) {
return (
<MessageResponse
caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0"
@@ -34,34 +71,103 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
>
{text}
</MessageResponse>
) : (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
<span />
<span />
</div>
);
}
if (hasReasoning) {
return null;
}
return (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
<span />
<span />
</div>
);
};
const assistantState = (
isStreaming: boolean,
reasoningStreaming: boolean,
text: string
): AssistantResponseState | undefined => {
if (reasoningStreaming && !text) {
return "thinking";
}
return isStreaming ? "writing" : undefined;
};
const ToolActivity = ({
hidden,
message,
}: {
readonly hidden: boolean;
readonly message: FlueConversationMessage;
}) => {
if (hidden) {
return null;
}
return message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
);
};
export const ChatMessage = ({
hideToolActivity = false,
message,
}: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
const reasoning = getReasoningText(message);
const reasoningStreaming = isReasoningStreaming(message);
const fileParts = message.parts.filter((part) => part.type === "file");
if (
hideToolActivity &&
!isUser &&
hasToolActivity(message) &&
!text &&
!reasoning
) {
return null;
}
if (message.parts.length === 0 && !isStreaming) {
return null;
}
const sender = isUser ? "user" : "assistant";
const content = isUser ? (
text
) : (
<AssistantText
hasReasoning={Boolean(reasoning)}
isStreaming={isStreaming}
text={text}
/>
);
const identityState = assistantState(isStreaming, reasoningStreaming, text);
return (
<Message className="max-w-full gap-0" from={message.role}>
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
{isUser ? null : <AssistantIdentity state={identityState} />}
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
<MessageAttachments parts={fileParts} />
{isUser ? null : (
<ReasoningTrace
isStreaming={reasoningStreaming}
text={reasoning}
/>
)}
<ToolActivity hidden={hideToolActivity} message={message} />
{content}
</MobileChatBubble>
</div>

View File

@@ -0,0 +1,416 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import { projectWorkNotices } from "@code/work-os";
import {
ChevronRight,
FolderGit2,
LoaderCircle,
Menu,
MessageSquareText,
ImagePlus,
Send,
Sparkles,
X,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
import { chatImageToPromptImage } from "@/lib/chat/attachments";
import {
buildSliceOneTimeline,
findSourceMessageTarget,
} from "@/lib/slice-one/presentation";
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = [];
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: SliceWork;
}
const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
return (
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
Proposed Work
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
</h2>
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
{work.objective}
</p>
</div>
</div>
<button
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
onClick={() => setSourcesOpen((open) => !open)}
type="button"
>
<span>
{sources.length} exact source{" "}
{sources.length === 1 ? "message" : "messages"}
</span>
<ChevronRight
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
/>
</button>
{sourcesOpen ? (
<div className="mt-3 space-y-2">
{sources.map((source) => (
<button
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
key={source.messageId}
onClick={() => onSourceSelect(source.rawText)}
type="button"
>
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
</article>
);
};
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const attachments = useChatImages();
const imageInput = useRef<HTMLInputElement>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const works = slice.works ?? EMPTY_WORKS;
const workById = useMemo(
() => new Map(works.map((work) => [String(work._id), work])),
[works]
);
const notices = useMemo(() => projectWorkNotices(works), [works]);
const timeline = useMemo(
() => buildSliceOneTimeline(slice.agent.messages, notices),
[notices, slice.agent.messages]
);
const busy =
slice.agent.status === "submitted" || slice.agent.status === "streaming";
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() => {
document
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
if (slice.projects === undefined) {
return (
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin" />
</div>
);
}
if (!slice.selectedProject) {
return (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
<form
className="w-full max-w-sm"
onSubmit={(event) => {
event.preventDefault();
void slice.connectRepository();
}}
>
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Slice 1 turns actionable conversation into proposed Work with exact
provenance.
</p>
<input
aria-label="Public Git repository URL"
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => slice.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={slice.repository}
/>
{slice.error ? (
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={slice.pending}
type="submit"
>
{slice.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{slice.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);
}
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
const images = await Promise.all(
attachments.images.map(chatImageToPromptImage)
);
await slice.agent.sendMessage(message, { images });
setDraft("");
attachments.clear();
};
return (
<main
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
style={viewportStyle}
>
<section className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
onChange={(event) => slice.selectProject(event.target.value)}
value={slice.selectedProject.id}
>
{slice.projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<p className="text-[10px] uppercase text-[#858277]">
Conversation to proposed Work
</p>
</div>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
</header>
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{slice.agent.historyReady && timeline.length === 0 ? (
<div className="grid min-h-[55vh] place-items-center text-center">
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">
What should move forward?
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays
conversation.
</p>
</div>
</div>
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId);
return work ? (
<div
className="chat-message ml-7"
key={`notice-${item.notice.eventId}`}
>
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
Work proposed from this conversation
</p>
<WorkCard
onSourceSelect={revealSourceMessage}
work={work}
/>
</div>
) : null;
}
return (
<div
className={`rounded-sm transition-colors duration-300 ${
highlightedMessageId === item.message.id
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
: ""
}`}
id={`slice-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage hideToolActivity message={item.message} />
</div>
);
})}
{slice.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{attachments.images.length > 0 ? (
<div className="mx-auto max-w-2xl">
<PendingChatAttachments
images={attachments.images}
onRemove={attachments.handleRemove}
/>
</div>
) : null}
{slice.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{slice.agent.error.message}
</p>
) : null}
{attachments.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{attachments.error}
</p>
) : null}
<div className="mx-auto flex max-w-2xl items-end gap-2">
<input
accept="image/*"
aria-label="Attach images"
className="sr-only"
multiple
onChange={(event) => {
attachments.addFiles(event.target.files);
event.target.value = "";
}}
ref={imageInput}
type="file"
/>
<Button
aria-label="Attach images"
className="size-11 shrink-0"
disabled={busy}
onClick={() => imageInput.current?.click()}
size="icon"
type="button"
variant="outline"
>
<ImagePlus className="size-4" />
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
placeholder="Describe an outcome or problem…"
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0"
disabled={!draft.trim() || busy}
onClick={() => void send()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</section>
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
<h2 className="text-sm font-semibold">Proposed Work</h2>
<p className="mb-4 text-xs text-[#747168]">
{works.length} durable outcomes
</p>
<div className="space-y-3">
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</aside>
{drawerOpen ? (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
<button
aria-label="Close Work drawer"
className="absolute inset-0"
onClick={() => setDrawerOpen(false)}
type="button"
/>
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
<button
aria-label="Close Work drawer"
className="grid size-9 place-items-center"
onClick={() => setDrawerOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{works.length === 0 ? (
<p className="py-12 text-center text-sm text-[#747168]">
Actionable messages will appear here.
</p>
) : null}
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -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,
});
});
});

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -0,0 +1,72 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
export const useSliceOne = () => {
const projects = useQuery(api.projects.list);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useChatAgent();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>();
const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? selectedProjectId
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
api.works.listForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const selectedProject = useMemo(
() =>
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null,
[activeProjectId, projects]
);
const connectRepository = async () => {
const repositoryUrl = repository.trim();
if (!repositoryUrl || pending) {
return;
}
setPending(true);
setError(undefined);
try {
const project = await importPublicGit({ repositoryUrl });
setSelectedProjectId(project.id as unknown as Id<"projects">);
setRepository("");
} catch (caughtError) {
setError(toError(caughtError));
} finally {
setPending(false);
}
};
const selectProject = (projectId: string) => {
setSelectedProjectId(projectId as unknown as Id<"projects">);
};
return {
agent,
connectRepository,
error,
pending,
projects,
repository,
selectProject,
selectedProject,
setRepository,
works,
} as const;
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Slice 1 visual viewport", () => {
test("shrinks the application surface to the keyboard-visible height", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
height: "500px",
transform: undefined,
});
});
test("tracks a panned visual viewport without scrolling the document", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 44 })).toEqual({
height: "500px",
transform: "translateY(44px)",
});
});
test("uses a dynamic viewport fallback before browser measurement", () => {
expect(visualViewportStyle()).toEqual({
height: "100dvh",
transform: undefined,
});
});
});

View File

@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
export interface VisualViewportFrame {
readonly height: number;
readonly offsetTop: number;
}
const readVisualViewport = (): VisualViewportFrame => ({
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
offsetTop: Math.round(window.visualViewport?.offsetTop ?? 0),
});
export const visualViewportStyle = (
frame?: VisualViewportFrame
): CSSProperties => ({
height: frame ? `${frame.height}px` : "100dvh",
transform: frame?.offsetTop ? `translateY(${frame.offsetTop}px)` : undefined,
});
export const useVisualViewportStyle = (): CSSProperties => {
const [frame, setFrame] = useState<VisualViewportFrame>();
useEffect(() => {
const root = document.documentElement;
const viewport = window.visualViewport;
let animationFrame = 0;
const update = () => {
cancelAnimationFrame(animationFrame);
animationFrame = requestAnimationFrame(() => {
setFrame(readVisualViewport());
});
};
root.classList.add("slice-one-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("slice-one-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
};
}, []);
return visualViewportStyle(frame);
};

View File

@@ -6,6 +6,13 @@ body {
min-height: 100%;
}
html.slice-one-viewport-lock,
html.slice-one-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
.ai-conversation-mobile > div {
scrollbar-gutter: auto !important;
}
@@ -96,6 +103,12 @@ body {
color: var(--foreground);
}
.slice-one-surface .chat-markdown,
.slice-one-surface .chat-reasoning,
.slice-one-surface .thinking-line {
color: #232321;
}
.chat-markdown > :first-child {
margin-top: 0;
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import {
MAX_CHAT_IMAGE_BYTES,
bytesToBase64,
validateChatImage,
} from "./attachments";
describe("chat image attachments", () => {
test("accepts supported image payloads within the upload limit", () => {
expect(validateChatImage({ size: 1024, type: "image/png" })).toEqual({
accepted: true,
});
});
test("rejects non-images and oversized images before transport", () => {
expect(validateChatImage({ size: 1024, type: "text/plain" }).accepted).toBe(
false
);
expect(
validateChatImage({
size: MAX_CHAT_IMAGE_BYTES + 1,
type: "image/jpeg",
}).accepted
).toBe(false);
});
test("encodes image bytes for the Flue prompt contract", () => {
expect(bytesToBase64(new Uint8Array([90, 111, 112, 117]))).toBe("Wm9wdQ==");
});
});

View File

@@ -0,0 +1,47 @@
import type { AgentPromptImage } from "@flue/react";
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
export interface PendingChatImage {
readonly file: File;
readonly id: string;
readonly previewUrl: string;
}
export interface ImageValidationResult {
readonly accepted: boolean;
readonly message?: string;
}
export const validateChatImage = (
file: Pick<File, "size" | "type">
): ImageValidationResult => {
if (!file.type.startsWith("image/")) {
return { accepted: false, message: "Only image files can be attached" };
}
if (file.size > MAX_CHAT_IMAGE_BYTES) {
return {
accepted: false,
message: "Images must be 10 MB or smaller",
};
}
return { accepted: true };
};
export const bytesToBase64 = (bytes: Uint8Array): string => {
let binary = "";
for (const byte of bytes) {
binary += String.fromCodePoint(byte);
}
return btoa(binary);
};
export const chatImageToPromptImage = async (
image: PendingChatImage
): Promise<AgentPromptImage> => ({
data: bytesToBase64(new Uint8Array(await image.file.arrayBuffer())),
filename: image.file.name,
mimeType: image.file.type,
type: "image",
});

View File

@@ -0,0 +1,78 @@
import type { FlueConversationMessage } from "@flue/react";
import { describe, expect, test } from "vitest";
import {
extractThinkingMarkup,
getReasoningText,
hasToolActivity,
isReasoningStreaming,
} from "./transforms";
const message = (
parts: FlueConversationMessage["parts"]
): FlueConversationMessage => ({
id: "message-1",
parts,
role: "assistant",
});
describe("hasToolActivity", () => {
test("detects an orchestration turn", () => {
expect(
hasToolActivity(
message([
{
input: { projectId: "project-1" },
state: "input-available",
toolCallId: "tool-1",
toolName: "list_proposed_work",
type: "dynamic-tool",
},
])
)
).toBe(true);
});
test("keeps a product-facing assistant response", () => {
expect(
hasToolActivity(
message([
{
state: "done",
text: "Captured the signal and proposed Work.",
type: "text",
},
])
)
).toBe(false);
});
});
describe("reasoning traces", () => {
test("extracts completed and streaming MiniMax think blocks", () => {
expect(
extractThinkingMarkup(
"<think>First thought</think><think>Current thought"
)
).toBe("First thought\n\nCurrent thought");
});
test("combines native reasoning with inline thinking", () => {
expect(
getReasoningText(
message([
{ state: "streaming", text: "Native thought", type: "reasoning" },
{ state: "streaming", text: "<think>Inline thought", type: "text" },
])
)
).toBe("Native thought\n\nInline thought");
});
test("recognizes an open streamed think block as live reasoning", () => {
expect(
isReasoningStreaming(
message([{ state: "streaming", text: "<think>Working", type: "text" }])
)
).toBe(true);
});
});

View File

@@ -15,13 +15,59 @@ export const stripThinkingMarkup = (text: string): string => {
return visibleText.replaceAll("</think>", "").trimStart();
};
export const extractThinkingMarkup = (text: string): string => {
const completed = [
...text.matchAll(/<think>(?<content>[\s\S]*?)<\/think>/giu),
]
.map((match) => match.groups?.content?.trim())
.filter((value): value is string => Boolean(value));
const lowerText = text.toLowerCase();
const openTagIndex = lowerText.lastIndexOf("<think>");
const closeTagIndex = lowerText.lastIndexOf("</think>");
if (openTagIndex > closeTagIndex) {
const active = text.slice(openTagIndex + "<think>".length).trim();
if (active) {
completed.push(active);
}
}
return completed.join("\n\n");
};
export const getRawMessageText = (message: FlueConversationMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
export const getMessageText = (message: FlueConversationMessage): string =>
stripThinkingMarkup(
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
stripThinkingMarkup(getRawMessageText(message));
export const getReasoningText = (message: FlueConversationMessage): string => {
const nativeReasoning = message.parts
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
.join("\n");
const inlineReasoning = extractThinkingMarkup(getRawMessageText(message));
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
};
export const isReasoningStreaming = (
message: FlueConversationMessage
): boolean => {
const nativeReasoningStreaming = message.parts.some(
(part) => part.type === "reasoning" && part.state === "streaming"
);
const rawText = getRawMessageText(message).toLowerCase();
const inlineReasoningStreaming =
rawText.lastIndexOf("<think>") > rawText.lastIndexOf("</think>") &&
message.parts.some(
(part) => part.type === "text" && part.state === "streaming"
);
return nativeReasoningStreaming || inlineReasoningStreaming;
};
export const hasToolActivity = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => part.type === "dynamic-tool");
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => {

View File

@@ -1,4 +1,5 @@
import type {
AgentPromptImage,
AgentStatus,
FlueConversationMessage,
FlueConversationPart,
@@ -8,7 +9,10 @@ export interface ChatAgentState {
error?: Error;
historyReady: boolean;
messages: FlueConversationMessage[];
sendMessage: (message: string) => Promise<void>;
sendMessage: (
message: string,
options?: { readonly images?: AgentPromptImage[] }
) => Promise<void>;
status: AgentStatus;
}
@@ -30,6 +34,7 @@ export interface ChatHeaderProps {
}
export interface ChatMessageProps {
hideToolActivity?: boolean;
message: FlueConversationMessage;
}

View File

@@ -1,7 +1,7 @@
import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport";
import { createFlueFetch, generateBrowserRequestId } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
const GLOBAL_RECEIVER_FETCH = function globalReceiverFetch(this: unknown) {
@@ -118,6 +118,12 @@ describe("createFlueFetch", () => {
);
});
test("generates a UUID without requiring crypto.randomUUID", () => {
expect(generateBrowserRequestId()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
);
});
test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {

View File

@@ -4,6 +4,21 @@ interface FlueFetchOptions {
readonly generateRequestId?: () => string;
}
export const generateBrowserRequestId = (): string => {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = ((bytes[6] ?? 0) % 16) + 64;
bytes[8] = ((bytes[8] ?? 0) % 64) + 128;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
};
const addRequestContext = async (
response: Response,
method: string,
@@ -40,7 +55,7 @@ const addRequestContext = async (
export const createFlueFetch = ({
baseUrl,
fetchImpl = fetch,
generateRequestId = () => crypto.randomUUID(),
generateRequestId = generateBrowserRequestId,
}: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, "");

View File

@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Slice 1 frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const root = source("../../root.tsx");
const styles = source("../../index.css");
expect(root).toContain("interactive-widget=resizes-content");
expect(page).toContain("style={viewportStyle}");
expect(page).toContain("fixed inset-x-0 top-0");
expect(page).not.toContain("slice-one-surface flex h-svh");
expect(styles).toContain("html.slice-one-viewport-lock body");
expect(styles).toContain("overflow: hidden");
});
test("keeps the responsive shell shrinkable with a pinned composer", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t');
expect(page).toContain(
'className="mx-auto flex max-w-2xl items-end gap-2"'
);
});
test("keeps attachment strips contained and markdown capabilities enabled", () => {
const attachment = source("../../components/chat/chat-attachments.tsx");
const attachmentPrimitive = source(
"../../../../../packages/ui/src/components/attachment.tsx"
);
const chatMessage = source("../../components/chat/chat-message.tsx");
const messageRenderer = source(
"../../../../../packages/ui/src/components/ai-elements/message.tsx"
);
expect(attachment).toContain('className="max-w-full');
expect(attachment).toContain("useAuthenticatedImageSource");
expect(attachment).toContain(`authorization: \`Bearer \${accessToken}\``);
expect(attachmentPrimitive).toContain("overflow-x-auto");
expect(chatMessage).toContain("<MessageAttachments parts={fileParts} />");
expect(chatMessage).toContain("<MessageResponse");
expect(messageRenderer).toContain("const streamdownPlugins = {");
expect(messageRenderer).toContain("mermaid");
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
});
});

View File

@@ -0,0 +1,62 @@
import type { WorkNotice } from "@code/work-os";
import type { FlueConversationMessage } from "@flue/react";
import { describe, expect, test } from "vitest";
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
const textMessage = (
id: string,
role: "assistant" | "user",
text: string
): FlueConversationMessage => ({
id,
parts: [{ state: "done", text, type: "text" }],
role,
});
const notice: WorkNotice = {
createdAt: 10,
eventId: "event-1",
sourceMessageIds: ["source-1"],
sourceTexts: ["Build the phone flow."],
workId: "work-1",
};
describe("Slice 1 presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildSliceOneTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
{
id: "tool-1",
parts: [
{
input: {},
state: "input-available",
toolCallId: "call-1",
toolName: "create_signal",
type: "dynamic-tool",
},
],
role: "assistant",
},
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
],
[notice]
);
expect(
timeline.map((item) =>
item.kind === "message" ? item.message.id : item.notice.workId
)
).toEqual(["user-1", "assistant-1", "work-1"]);
});
test("finds the exact source message without trimming it", () => {
const messages = [textMessage("user-1", "user", " Exact source ")];
expect(findSourceMessageTarget(messages, " Exact source ")).toBe(
"user-1"
);
expect(findSourceMessageTarget(messages, "Exact source")).toBeUndefined();
});
});

View File

@@ -0,0 +1,84 @@
import type { WorkNotice } from "@code/work-os";
import type { FlueConversationMessage } from "@flue/react";
import {
getMessageText,
getRawMessageText,
getReasoningText,
hasToolActivity,
} from "@/lib/chat/transforms";
export type SliceTimelineItem =
| {
readonly kind: "message";
readonly message: FlueConversationMessage;
}
| { readonly kind: "work"; readonly notice: WorkNotice };
export const isSliceOneVisibleMessage = (
message: FlueConversationMessage
): boolean =>
message.role === "user" ||
!hasToolActivity(message) ||
getMessageText(message).length > 0 ||
getReasoningText(message).length > 0;
const targetIndexForNotice = (
messages: readonly FlueConversationMessage[],
notice: WorkNotice
): number => {
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
const index = messages.findIndex(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
);
return index === -1 ? [] : [index];
});
if (sourceIndexes.length === 0) {
return messages.length - 1;
}
const sourceIndex = Math.max(...sourceIndexes);
const responseOffset = messages
.slice(sourceIndex + 1)
.findIndex(
(message) => message.role === "assistant" && !hasToolActivity(message)
);
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildSliceOneTimeline = (
allMessages: readonly FlueConversationMessage[],
notices: readonly WorkNotice[]
): readonly SliceTimelineItem[] => {
const messages = allMessages.filter(isSliceOneVisibleMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
const atTarget = noticesByMessageIndex.get(targetIndex) ?? [];
atTarget.push(notice);
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: SliceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {
timeline.push({ kind: "work", notice });
}
}
if (messages.length === 0) {
for (const notice of noticesByMessageIndex.get(-1) ?? []) {
timeline.push({ kind: "work", notice });
}
}
return timeline;
};
export const findSourceMessageTarget = (
messages: readonly FlueConversationMessage[],
sourceText: string
): string | undefined =>
messages.find(
(message) =>
message.role === "user" && getRawMessageText(message) === sourceText
)?.id;

View File

@@ -64,7 +64,10 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<Meta />
<Links />
</head>

View File

@@ -1,5 +1,5 @@
import { WorkOsPage } from "@/components/work-os/work-os-page";
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function Dashboard() {
return <WorkOsPage />;
return <SliceOnePage />;
}

View File

@@ -1,4 +1,4 @@
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
import type { Route } from "./+types/page";
@@ -8,5 +8,5 @@ export const meta = (_args: Route.MetaArgs) => [
];
export default function MobileChatPage() {
return <MobileFlowPage screen="stack-home" />;
return <SliceOnePage />;
}

View File

@@ -1,5 +1,5 @@
import { Navigate } from "react-router";
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function MobileLandingRedirect() {
return <Navigate replace to="/chat" />;
return <SliceOnePage />;
}