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:
@@ -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",
|
||||
|
||||
162
apps/web/src/components/chat/chat-attachments.tsx
Normal file
162
apps/web/src/components/chat/chat-attachments.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
416
apps/web/src/components/slice-one/slice-one-page.tsx
Normal file
416
apps/web/src/components/slice-one/slice-one-page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
76
apps/web/src/hooks/chat/use-chat-images.ts
Normal file
76
apps/web/src/hooks/chat/use-chat-images.ts
Normal 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;
|
||||
};
|
||||
72
apps/web/src/hooks/slice-one/use-slice-one.ts
Normal file
72
apps/web/src/hooks/slice-one/use-slice-one.ts
Normal 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;
|
||||
};
|
||||
26
apps/web/src/hooks/slice-one/use-visual-viewport.test.ts
Normal file
26
apps/web/src/hooks/slice-one/use-visual-viewport.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
52
apps/web/src/hooks/slice-one/use-visual-viewport.ts
Normal file
52
apps/web/src/hooks/slice-one/use-visual-viewport.ts
Normal 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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
31
apps/web/src/lib/chat/attachments.test.ts
Normal file
31
apps/web/src/lib/chat/attachments.test.ts
Normal 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==");
|
||||
});
|
||||
});
|
||||
47
apps/web/src/lib/chat/attachments.ts
Normal file
47
apps/web/src/lib/chat/attachments.ts
Normal 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",
|
||||
});
|
||||
78
apps/web/src/lib/chat/transforms.test.ts
Normal file
78
apps/web/src/lib/chat/transforms.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, "");
|
||||
|
||||
|
||||
53
apps/web/src/lib/slice-one/frontend-regressions.test.ts
Normal file
53
apps/web/src/lib/slice-one/frontend-regressions.test.ts
Normal 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}");
|
||||
});
|
||||
});
|
||||
62
apps/web/src/lib/slice-one/presentation.test.ts
Normal file
62
apps/web/src/lib/slice-one/presentation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
84
apps/web/src/lib/slice-one/presentation.ts
Normal file
84
apps/web/src/lib/slice-one/presentation.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user