Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20)
This commit is contained in:
@@ -15,8 +15,6 @@
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@code/ui": "workspace:*",
|
||||
"@flue/react": "1.0.0-beta.9",
|
||||
"@flue/sdk": "1.0.0-beta.9",
|
||||
"@react-router/fs-routes": "^8.1.0",
|
||||
"@react-router/node": "^8.1.0",
|
||||
"@react-router/serve": "^8.1.0",
|
||||
@@ -24,8 +22,8 @@
|
||||
"isbot": "^5.1.44",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "2.5.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Desktop addons package static web assets; SSR output cannot be bundled
|
||||
ssr: false,
|
||||
appDirectory: "src",
|
||||
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
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";
|
||||
import type { ConversationPart } from "@/lib/chat/types";
|
||||
|
||||
type FilePart = Extract<FlueConversationPart, { type: "file" }>;
|
||||
type FilePart = Extract<ConversationPart, { type: "file" }>;
|
||||
|
||||
const isDirectlyRenderableUrl = (url: string): boolean =>
|
||||
url.startsWith("blob:") || url.startsWith("data:");
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
MobileChatBubble,
|
||||
MobileChatMessage,
|
||||
} from "@code/ui/components/mobile-chat";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
|
||||
import {
|
||||
getMessageText,
|
||||
getReasoningText,
|
||||
hasToolActivity,
|
||||
isReasoningStreaming,
|
||||
isMessageStreaming,
|
||||
} from "@/lib/chat/transforms";
|
||||
@@ -23,7 +21,6 @@ import type {
|
||||
|
||||
import { AssistantIdentity } from "./assistant-identity";
|
||||
import { MessageAttachments } from "./chat-attachments";
|
||||
import { ChatToolCall } from "./chat-tool-call";
|
||||
|
||||
const ReasoningTrace = ({
|
||||
isStreaming,
|
||||
@@ -96,27 +93,7 @@ const assistantState = (
|
||||
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) => {
|
||||
export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||
const isUser = message.role === "user";
|
||||
const isStreaming = isMessageStreaming(message);
|
||||
const text = getMessageText(message);
|
||||
@@ -124,16 +101,6 @@ export const ChatMessage = ({
|
||||
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;
|
||||
}
|
||||
@@ -167,7 +134,6 @@ export const ChatMessage = ({
|
||||
text={reasoning}
|
||||
/>
|
||||
)}
|
||||
<ToolActivity hidden={hideToolActivity} message={message} />
|
||||
{content}
|
||||
</MobileChatBubble>
|
||||
</div>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from "@code/ui/components/ai-elements/tool";
|
||||
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
|
||||
import { Search, SquareTerminal } from "lucide-react";
|
||||
|
||||
import type { ChatToolCallProps } from "@/lib/chat/types";
|
||||
|
||||
export const ChatToolCall = ({ part }: ChatToolCallProps) => {
|
||||
let detail =
|
||||
typeof part.input === "string"
|
||||
? part.input
|
||||
: JSON.stringify(part.input, null, 2);
|
||||
let status = "running";
|
||||
let tone: "error" | "neutral" | "success" = "neutral";
|
||||
|
||||
if (part.state === "output-available") {
|
||||
detail =
|
||||
typeof part.output === "string"
|
||||
? part.output
|
||||
: JSON.stringify(part.output, null, 2);
|
||||
status = "done";
|
||||
tone = "success";
|
||||
} else if (part.state === "output-error") {
|
||||
detail = part.errorText;
|
||||
status = "failed";
|
||||
tone = "error";
|
||||
}
|
||||
|
||||
const isSearch = part.toolName.toLowerCase().includes("search");
|
||||
const output = part.state === "output-available" ? part.output : undefined;
|
||||
const errorText = part.state === "output-error" ? part.errorText : undefined;
|
||||
|
||||
return (
|
||||
<Tool className="mb-0 w-full border-0" defaultOpen>
|
||||
<ToolContent className="space-y-0 p-0">
|
||||
<MobileChatToolCall
|
||||
detail={detail}
|
||||
icon={
|
||||
isSearch ? (
|
||||
<Search className="size-3" strokeWidth={2.2} />
|
||||
) : (
|
||||
<SquareTerminal className="size-3" strokeWidth={2.2} />
|
||||
)
|
||||
}
|
||||
status={status}
|
||||
tone={tone}
|
||||
toolName={part.toolName}
|
||||
/>
|
||||
<div className="sr-only">
|
||||
<ToolInput input={part.input} />
|
||||
<ToolOutput errorText={errorText} output={output} />
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
);
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
import { signOutWeb, useWebAuth } from "@code/auth/web";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
MobileAssistantChatScreen,
|
||||
MobileExpandedWorkScreen,
|
||||
MobileHomeScreen,
|
||||
MobileWorkChatScreen,
|
||||
MobileWorkListScreen,
|
||||
MobileWorkStackScreen,
|
||||
MobileWorkUnitDetailScreen,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
||||
import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack";
|
||||
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
||||
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
|
||||
import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message";
|
||||
import { getUserInitials } from "@/lib/users/get-user-initials";
|
||||
|
||||
export type MobileFlowScreen =
|
||||
| "assistant-chat"
|
||||
| "home"
|
||||
| "stack-home"
|
||||
| "work-chat"
|
||||
| "work-list"
|
||||
| "work-unit-detail";
|
||||
|
||||
interface MobileFlowPageProps {
|
||||
screen: MobileFlowScreen;
|
||||
}
|
||||
|
||||
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { workUnitId } = useParams();
|
||||
const auth = useWebAuth();
|
||||
const projectWorkspace = useMobileProjectWorkspace({
|
||||
selectedWorkUnitId: workUnitId,
|
||||
});
|
||||
const workspace = useMobileWorkspace({
|
||||
onCreateIssue: (title, body) =>
|
||||
projectWorkspace.raiseIssue({ body, title }),
|
||||
onSend: projectWorkspace.sendMessage,
|
||||
});
|
||||
const workStack = useMobileWorkStack(projectWorkspace.data.workUnits);
|
||||
const workPath = "/work";
|
||||
const chatPath = "/chat";
|
||||
const handleRestoreChecked = workStack.restoreChecked;
|
||||
const handleWorkUnitChecked = workStack.markChecked;
|
||||
const handleWorkUnitSentBack = workStack.sendToBack;
|
||||
const user = auth.status === "authenticated" ? auth.user : undefined;
|
||||
const handleSignOut = async () => {
|
||||
await signOutWeb();
|
||||
navigate("/login");
|
||||
};
|
||||
const statusMessage = getMobileStatusMessage({
|
||||
assistantError: projectWorkspace.error?.message,
|
||||
localStatus: workspace.statusMessage,
|
||||
});
|
||||
const handleRepositoryChange =
|
||||
projectWorkspace.projectWorkspace.setRepository;
|
||||
|
||||
const handleOpenUnit = (selectedWorkUnitId?: string) => {
|
||||
const targetId =
|
||||
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
|
||||
if (!targetId) {
|
||||
workspace.setProjectManagerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (screen === "work-list" && !workspace.expanded) {
|
||||
workspace.setExpanded(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/chat/${targetId}`);
|
||||
};
|
||||
|
||||
const screenProps = {
|
||||
composerValue: workspace.composerValue,
|
||||
createIssueBody: workspace.issueBody,
|
||||
createIssueTitle: workspace.issueTitle,
|
||||
data: projectWorkspace.data,
|
||||
modelId: MODEL_ID,
|
||||
modelLabel: MODEL_LABEL,
|
||||
onBack: () => navigate(chatPath),
|
||||
onComposerChange: workspace.handleComposerChange,
|
||||
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
|
||||
onComposerSubmit: workspace.handleComposerSubmit,
|
||||
onCreateBodyChange: workspace.setIssueBody,
|
||||
onCreateIssue: workspace.handleCreateIssueSubmit,
|
||||
onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId
|
||||
? () =>
|
||||
void projectWorkspace.raiseIssueFromSignal(
|
||||
projectWorkspace.data.latestSignal?.id ?? ""
|
||||
)
|
||||
: undefined,
|
||||
onCreateTitleChange: workspace.setIssueTitle,
|
||||
onManageProjects: () => workspace.setProjectManagerOpen(true),
|
||||
onOpenAssistant: () => navigate(chatPath),
|
||||
onOpenUnit: handleOpenUnit,
|
||||
onProjectManagerClose: () => workspace.setProjectManagerOpen(false),
|
||||
onProjectSelect: (projectId: string) =>
|
||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
||||
projectId as Id<"projects">
|
||||
),
|
||||
onRepositoryChange: projectWorkspace.projectWorkspace.setRepository,
|
||||
onRepositoryConnect: (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void projectWorkspace.projectWorkspace.connectRepository();
|
||||
},
|
||||
onReviewUnit: (reviewUrl: string) => {
|
||||
window.open(reviewUrl, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
onSettingsClose: () => workspace.setSettingsOpen(false),
|
||||
onSettingsOpen: () => workspace.setSettingsOpen(true),
|
||||
onStartUnit: (selectedIssueId: string) => {
|
||||
void projectWorkspace.startWorkUnit(
|
||||
selectedIssueId as Id<"projectIssues">
|
||||
);
|
||||
},
|
||||
onViewWork: () => navigate(workPath),
|
||||
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
|
||||
projectManagerOpen: workspace.projectManagerOpen,
|
||||
repositoryValue: projectWorkspace.projectWorkspace.repository,
|
||||
settingsOpen: workspace.settingsOpen,
|
||||
statusMessage,
|
||||
};
|
||||
|
||||
if (screen === "assistant-chat") {
|
||||
return <MobileAssistantChatScreen {...screenProps} />;
|
||||
}
|
||||
if (screen === "stack-home") {
|
||||
return (
|
||||
<MobileWorkStackScreen
|
||||
checkedCount={workStack.checkedCount}
|
||||
composerValue={workspace.composerValue}
|
||||
data={projectWorkspace.data}
|
||||
modelId={MODEL_ID}
|
||||
modelLabel={MODEL_LABEL}
|
||||
onAddProject={() => workspace.setProjectManagerOpen(true)}
|
||||
onComposerChange={workspace.handleComposerChange}
|
||||
onComposerSubmit={workspace.handleComposerSubmit}
|
||||
onOpenUnit={(selectedWorkUnitId) =>
|
||||
navigate(`/chat/${selectedWorkUnitId}`)
|
||||
}
|
||||
onProjectSelect={(projectId) =>
|
||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
||||
projectId as Id<"projects">
|
||||
)
|
||||
}
|
||||
onProjectManagerClose={() => workspace.setProjectManagerOpen(false)}
|
||||
onRepositoryChange={handleRepositoryChange}
|
||||
onRepositoryConnect={(event) => {
|
||||
event.preventDefault();
|
||||
void projectWorkspace.projectWorkspace.connectRepository();
|
||||
}}
|
||||
onRestoreChecked={handleRestoreChecked}
|
||||
onSettingsClose={() => workspace.setSettingsOpen(false)}
|
||||
onSettingsOpen={() => workspace.setSettingsOpen(true)}
|
||||
onSignOut={() => {
|
||||
void handleSignOut();
|
||||
}}
|
||||
onWorkUnitChecked={handleWorkUnitChecked}
|
||||
onWorkUnitSentBack={handleWorkUnitSentBack}
|
||||
pendingAction={projectWorkspace.projectWorkspace.pendingAction}
|
||||
projectManagerOpen={workspace.projectManagerOpen}
|
||||
repositoryValue={projectWorkspace.projectWorkspace.repository}
|
||||
settingsOpen={workspace.settingsOpen}
|
||||
statusMessage={statusMessage}
|
||||
userEmail={user?.email}
|
||||
userInitials={getUserInitials(user?.name)}
|
||||
userName={user?.name}
|
||||
workUnits={workStack.visibleWorkUnits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "work-chat") {
|
||||
return (
|
||||
<MobileWorkChatScreen
|
||||
composerValue={workspace.composerValue}
|
||||
data={projectWorkspace.data}
|
||||
onBack={() => navigate(chatPath)}
|
||||
onComposerChange={workspace.handleComposerChange}
|
||||
onComposerMessageSubmit={workspace.handleComposerMessageSubmit}
|
||||
onComposerSubmit={workspace.handleComposerSubmit}
|
||||
statusMessage={statusMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "home") {
|
||||
return <MobileHomeScreen {...screenProps} />;
|
||||
}
|
||||
if (screen === "work-unit-detail") {
|
||||
return <MobileWorkUnitDetailScreen {...screenProps} />;
|
||||
}
|
||||
if (workspace.expanded) {
|
||||
return <MobileExpandedWorkScreen {...screenProps} />;
|
||||
}
|
||||
return <MobileWorkListScreen {...screenProps} />;
|
||||
};
|
||||
@@ -23,7 +23,6 @@ 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,
|
||||
@@ -90,6 +89,78 @@ const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ConversationLoading = () => (
|
||||
<output
|
||||
aria-label="Loading conversation"
|
||||
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||
>
|
||||
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||
<span className="sr-only">Loading conversation…</span>
|
||||
</output>
|
||||
);
|
||||
|
||||
const ConversationEmptyState = () => (
|
||||
<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>
|
||||
);
|
||||
type SliceOneState = ReturnType<typeof useSliceOne>;
|
||||
|
||||
const ProjectsLoading = () => (
|
||||
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
export const SliceOnePage = () => {
|
||||
const slice = useSliceOne();
|
||||
const viewportStyle = useVisualViewportStyle();
|
||||
@@ -134,55 +205,11 @@ export const SliceOnePage = () => {
|
||||
};
|
||||
|
||||
if (slice.projects === undefined) {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||
<LoaderCircle className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
return <ProjectsLoading />;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
return <ConnectProject slice={slice} />;
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
@@ -190,10 +217,9 @@ export const SliceOnePage = () => {
|
||||
if (!message || busy) {
|
||||
return;
|
||||
}
|
||||
const images = await Promise.all(
|
||||
attachments.images.map(chatImageToPromptImage)
|
||||
);
|
||||
await slice.agent.sendMessage(message, { images });
|
||||
await slice.agent.sendMessage(message, {
|
||||
images: attachments.images.map((image) => image.file),
|
||||
});
|
||||
setDraft("");
|
||||
attachments.clear();
|
||||
};
|
||||
@@ -233,19 +259,11 @@ export const SliceOnePage = () => {
|
||||
</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 ? (
|
||||
<ConversationLoading />
|
||||
) : null}
|
||||
{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>
|
||||
<ConversationEmptyState />
|
||||
) : null}
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "work") {
|
||||
@@ -275,7 +293,7 @@ export const SliceOnePage = () => {
|
||||
id={`slice-message-${item.message.id}`}
|
||||
key={item.message.id}
|
||||
>
|
||||
<ChatMessage hideToolActivity message={item.message} />
|
||||
<ChatMessage message={item.message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { FlueProvider, useFlueAgent } from "@flue/react";
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { ArrowUp, Loader2, MessageSquare } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
const STANDALONE_CHAT_ID = "zopu-standalone";
|
||||
|
||||
const StandaloneFlueProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createFlueClient({
|
||||
baseUrl: env.VITE_ZOPU_SERVER_URL,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return <FlueProvider client={client}>{children}</FlueProvider>;
|
||||
};
|
||||
|
||||
const extractText = (parts: { type: string; text?: string }[]): string =>
|
||||
parts
|
||||
.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => p.text ?? "")
|
||||
.join("");
|
||||
|
||||
const ChatMessage = ({
|
||||
parts,
|
||||
role,
|
||||
}: {
|
||||
parts: { type: string; text?: string }[];
|
||||
role: string;
|
||||
}) => {
|
||||
const isUser = role === "user";
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full gap-3 px-4 py-3 ${isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-[#1a1a19] text-[#e8e8e6]">
|
||||
<MessageSquare className="size-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[78%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed ${
|
||||
isUser ? "bg-[#e8e8e6] text-[#0e0e0d]" : "bg-[#1a1a19] text-[#e8e8e6]"
|
||||
}`}
|
||||
>
|
||||
{extractText(parts) || <span className="text-[#888]">...</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatContent = () => {
|
||||
const agent = useFlueAgent({
|
||||
id: STANDALONE_CHAT_ID,
|
||||
name: "zopu",
|
||||
});
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = agent.messages.length > 0;
|
||||
const isBusy = agent.status === "submitted" || agent.status === "streaming";
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [agent.messages, isBusy]);
|
||||
|
||||
const submit = async () => {
|
||||
const message = input.trim();
|
||||
if (!message || isBusy) {
|
||||
return;
|
||||
}
|
||||
setInput("");
|
||||
await agent.sendMessage(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col bg-[#0e0e0d]">
|
||||
<header className="flex items-center gap-2 border-b border-[#1f1f1e] px-4 py-3">
|
||||
<div className="flex size-7 items-center justify-center rounded-full bg-[#e8e8e6]">
|
||||
<span className="text-sm font-bold text-[#0e0e0d]">Z</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-[15px] font-semibold text-[#e8e8e6]">Zopu</h1>
|
||||
<p className="text-xs text-[#888]">
|
||||
{isBusy ? "working..." : "ready"}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-2" ref={scrollRef}>
|
||||
{!hasMessages && !agent.historyReady && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-[#555]" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMessages && agent.historyReady && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-[#1a1a19]">
|
||||
<MessageSquare className="size-5 text-[#e8e8e6]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[15px] font-medium text-[#e8e8e6]">
|
||||
Chat with Zopu
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#888]">
|
||||
Ask about issues, trigger work, or check PR status.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{[
|
||||
"List recent pull requests",
|
||||
"Create an issue for adding a settings page",
|
||||
].map((suggestion) => (
|
||||
<button
|
||||
className="rounded-full border border-[#2a2a29] bg-[#161615] px-3 py-1.5 text-xs text-[#aaa] transition hover:border-[#3a3a39] hover:text-[#e8e8e6]"
|
||||
key={suggestion}
|
||||
onClick={() => void agent.sendMessage(suggestion)}
|
||||
type="button"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{agent.messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
parts={message.parts}
|
||||
role={message.role}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#1f1f1e] px-3 py-3">
|
||||
<div className="flex items-end gap-2 rounded-2xl border border-[#2a2a29] bg-[#161615] px-3 py-2">
|
||||
<textarea
|
||||
className="flex-1 resize-none bg-transparent text-[15px] text-[#e8e8e6] placeholder-[#666] outline-none"
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
placeholder="Message Zopu..."
|
||||
rows={1}
|
||||
style={{ maxHeight: 120 }}
|
||||
value={input}
|
||||
/>
|
||||
<button
|
||||
className={`flex size-8 shrink-0 items-center justify-center rounded-lg transition ${
|
||||
input.trim() && !isBusy
|
||||
? "bg-[#e8e8e6] text-[#0e0e0d]"
|
||||
: "bg-[#2a2a29] text-[#555]"
|
||||
}`}
|
||||
disabled={!input.trim() || isBusy}
|
||||
onClick={() => void submit()}
|
||||
type="button"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StandaloneChatPage = () => (
|
||||
<StandaloneFlueProvider>
|
||||
<ChatContent />
|
||||
</StandaloneFlueProvider>
|
||||
);
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useChatAgent } from "./use-chat-agent";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agent: {
|
||||
error: undefined,
|
||||
historyReady: true,
|
||||
messages: [],
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
status: "idle" as const,
|
||||
},
|
||||
organization: {} as { error?: Error; organizationId?: string },
|
||||
useFlueAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@flue/react", () => ({
|
||||
useFlueAgent: mocks.useFlueAgent,
|
||||
}));
|
||||
vi.mock("@/hooks/use-personal-organization", () => ({
|
||||
usePersonalOrganization: () => mocks.organization,
|
||||
}));
|
||||
|
||||
describe("useChatAgent", () => {
|
||||
beforeEach(() => {
|
||||
mocks.organization = {};
|
||||
mocks.agent.sendMessage.mockClear();
|
||||
mocks.useFlueAgent.mockReset();
|
||||
mocks.useFlueAgent.mockReturnValue(mocks.agent);
|
||||
});
|
||||
|
||||
test("does not use the agent before organization bootstrap completes", async () => {
|
||||
const chat = useChatAgent();
|
||||
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
expect(chat.status).toBe("connecting");
|
||||
await expect(chat.sendMessage("too early")).rejects.toThrow(
|
||||
"Personal organization is still being prepared"
|
||||
);
|
||||
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses the ensured organization id for the agent session", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
|
||||
const chat = useChatAgent();
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: "org-a",
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
|
||||
await chat.sendMessage("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,45 +1,106 @@
|
||||
import type { SendMessageOptions } from "@flue/react";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { projectConversation } from "@/lib/chat/conversation";
|
||||
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
|
||||
|
||||
const requestId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const uploadImage = async (
|
||||
file: File,
|
||||
generateUploadUrl: () => Promise<string>
|
||||
) => {
|
||||
const response = await fetch(await generateUploadUrl(), {
|
||||
body: file,
|
||||
headers: { "content-type": file.type },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image upload failed (${response.status})`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("storageId" in payload) ||
|
||||
typeof payload.storageId !== "string"
|
||||
) {
|
||||
throw new Error("Image upload returned an invalid response");
|
||||
}
|
||||
return {
|
||||
filename: file.name || undefined,
|
||||
mimeType: file.type,
|
||||
storageId: payload.storageId as Id<"_storage">,
|
||||
};
|
||||
};
|
||||
|
||||
export const useOrganizationChatAgent = (
|
||||
organization: PersonalOrganizationState
|
||||
): ChatAgentState => {
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization.organizationId,
|
||||
});
|
||||
const { organizationId } = organization;
|
||||
const rows = useQuery(
|
||||
api.conversationMessages.listForCurrentOrganization,
|
||||
organizationId ? { organizationId } : "skip"
|
||||
);
|
||||
const send = useMutation(api.conversationMessages.send);
|
||||
const generateUploadUrl = useMutation(
|
||||
api.conversationMessages.generateUploadUrl
|
||||
);
|
||||
const [sendError, setSendError] = useState<Error>();
|
||||
|
||||
const projected = projectConversation(rows ?? []);
|
||||
|
||||
let status: AgentStatus = projected.pending ? "submitted" : "idle";
|
||||
if (!organizationId || rows === undefined) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
} else if (projected.failedError || sendError) {
|
||||
status = "error";
|
||||
}
|
||||
|
||||
const sendMessage = async (
|
||||
message: string,
|
||||
options?: SendMessageOptions
|
||||
options?: { readonly images?: readonly File[] }
|
||||
): Promise<void> => {
|
||||
if (!organization.organizationId) {
|
||||
if (!organizationId) {
|
||||
throw (
|
||||
organization.error ??
|
||||
new Error("Personal organization is still being prepared")
|
||||
);
|
||||
}
|
||||
await agent.sendMessage(message, options);
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
(options?.images ?? []).map((file) =>
|
||||
uploadImage(file, generateUploadUrl)
|
||||
)
|
||||
);
|
||||
await send({
|
||||
clientRequestId: requestId(),
|
||||
images,
|
||||
organizationId,
|
||||
rawText: message,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
setSendError(normalized);
|
||||
throw normalized;
|
||||
}
|
||||
};
|
||||
|
||||
let { status } = agent;
|
||||
if (!organization.organizationId) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
}
|
||||
|
||||
return {
|
||||
error: organization.error ?? agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
error:
|
||||
organization.error ??
|
||||
sendError ??
|
||||
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||
historyReady: rows !== undefined,
|
||||
messages: projected.messages,
|
||||
sendMessage,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatAgent = (): ChatAgentState =>
|
||||
useOrganizationChatAgent(usePersonalOrganization());
|
||||
|
||||
@@ -2,7 +2,10 @@ 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";
|
||||
|
||||
const generateImageId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
export const useChatImages = () => {
|
||||
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||
@@ -40,7 +43,7 @@ export const useChatImages = () => {
|
||||
previewUrls.current.add(previewUrl);
|
||||
next.push({
|
||||
file,
|
||||
id: generateBrowserRequestId(),
|
||||
id: generateImageId(),
|
||||
previewUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,20 @@ 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";
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
export const useSliceOne = () => {
|
||||
const projects = useQuery(api.projects.list);
|
||||
const organization = usePersonalOrganization();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useChatAgent();
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
||||
import { STATUS_COPY } from "@/lib/chat/constants";
|
||||
import { getMessageText } from "@/lib/chat/transforms";
|
||||
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
|
||||
|
||||
const WORK_UNIT_SCOPE_PATTERN =
|
||||
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
|
||||
|
||||
const toAssistantMessages = (
|
||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
|
||||
selectedWorkUnitId?: string
|
||||
): MobileAssistantMessageView[] => {
|
||||
const result: MobileAssistantMessageView[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant" && message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const rawText = getMessageText(message);
|
||||
if (!rawText) {
|
||||
continue;
|
||||
}
|
||||
if (!selectedWorkUnitId) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
continue;
|
||||
}
|
||||
if (message.role === "user") {
|
||||
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
|
||||
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
|
||||
result.length = 0;
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: match.groups.message?.trim() ?? rawText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (result.length > 0) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getStatusTone = (
|
||||
status: ReturnType<typeof useOrganizationChatAgent>["status"]
|
||||
): MobileAssistantView["statusTone"] => {
|
||||
if (status === "error") {
|
||||
return "red";
|
||||
}
|
||||
return status === "connecting" ? "amber" : "green";
|
||||
};
|
||||
|
||||
interface UseMobileProjectWorkspaceOptions {
|
||||
readonly selectedWorkUnitId?: string;
|
||||
}
|
||||
|
||||
export const useMobileProjectWorkspace = ({
|
||||
selectedWorkUnitId,
|
||||
}: UseMobileProjectWorkspaceOptions) => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projectWorkspace = useProjectWorkspace();
|
||||
const chatAgent = useOrganizationChatAgent(organization);
|
||||
const signals = useQuery(
|
||||
api.signals.list,
|
||||
organization.organizationId
|
||||
? { organizationId: organization.organizationId }
|
||||
: "skip"
|
||||
);
|
||||
const selectedIssue = projectWorkspace.issues?.find(
|
||||
(issue) => String(issue._id) === selectedWorkUnitId
|
||||
);
|
||||
const sendMessage = (message: string) => {
|
||||
if (!selectedIssue) {
|
||||
return chatAgent.sendMessage(message);
|
||||
}
|
||||
return chatAgent.sendMessage(
|
||||
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
|
||||
);
|
||||
};
|
||||
const assistant: MobileAssistantView = {
|
||||
isBusy:
|
||||
chatAgent.status === "connecting" ||
|
||||
chatAgent.status === "streaming" ||
|
||||
chatAgent.status === "submitted",
|
||||
messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
|
||||
statusLabel: STATUS_COPY[chatAgent.status],
|
||||
statusTone: getStatusTone(chatAgent.status),
|
||||
};
|
||||
|
||||
return {
|
||||
data: buildMobileWorkspaceView({
|
||||
artifacts: projectWorkspace.artifacts,
|
||||
assistant,
|
||||
events: projectWorkspace.events,
|
||||
issues: projectWorkspace.issues,
|
||||
organizationLabel: organization.organizationName,
|
||||
projects: projectWorkspace.projects,
|
||||
selectedProject: projectWorkspace.selectedProject,
|
||||
selectedWorkUnitId,
|
||||
signals,
|
||||
}),
|
||||
error:
|
||||
organization.error ??
|
||||
chatAgent.error ??
|
||||
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
|
||||
organization,
|
||||
projectWorkspace,
|
||||
raiseIssue: projectWorkspace.raiseIssue,
|
||||
raiseIssueFromSignal: (signalId: string) =>
|
||||
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
|
||||
sendMessage,
|
||||
startWorkUnit: projectWorkspace.startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const useMobileWorkStack = (
|
||||
workUnits: readonly MobileWorkUnitView[]
|
||||
) => {
|
||||
const [order, setOrder] = useState<readonly string[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const workUnitsById = useMemo(
|
||||
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
|
||||
[workUnits]
|
||||
);
|
||||
const availableIds = useMemo(
|
||||
() => new Set(workUnitsById.keys()),
|
||||
[workUnitsById]
|
||||
);
|
||||
const visibleWorkUnits = useMemo(() => {
|
||||
const retainedOrder = order.filter(
|
||||
(workUnitId) =>
|
||||
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
const retainedIds = new Set(retainedOrder);
|
||||
const addedIds = workUnits
|
||||
.map((workUnit) => workUnit.id)
|
||||
.filter(
|
||||
(workUnitId) =>
|
||||
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
|
||||
const workUnit = workUnitsById.get(workUnitId);
|
||||
return workUnit ? [workUnit] : [];
|
||||
});
|
||||
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
|
||||
|
||||
const sendToBack = useCallback(
|
||||
(workUnitId: string) => {
|
||||
if (visibleWorkUnits[0]?.id !== workUnitId) {
|
||||
return;
|
||||
}
|
||||
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
|
||||
},
|
||||
[visibleWorkUnits]
|
||||
);
|
||||
|
||||
const markChecked = useCallback((workUnitId: string) => {
|
||||
setCheckedIds((current) => new Set(current).add(workUnitId));
|
||||
}, []);
|
||||
|
||||
const restoreChecked = useCallback(() => {
|
||||
setCheckedIds(new Set());
|
||||
setOrder([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
checkedCount: [...checkedIds].filter((workUnitId) =>
|
||||
availableIds.has(workUnitId)
|
||||
).length,
|
||||
markChecked,
|
||||
restoreChecked,
|
||||
sendToBack,
|
||||
visibleWorkUnits,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UseMobileWorkspaceOptions {
|
||||
readonly initialExpanded?: boolean;
|
||||
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
|
||||
readonly onSend: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : "Message could not be sent";
|
||||
|
||||
export const useMobileWorkspace = ({
|
||||
initialExpanded = false,
|
||||
onCreateIssue,
|
||||
onSend,
|
||||
}: UseMobileWorkspaceOptions) => {
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [expanded, setExpanded] = useState(initialExpanded);
|
||||
const [projectManagerOpen, setProjectManagerOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string>();
|
||||
|
||||
const handleComposerChange = (value: string) => {
|
||||
setComposerValue(value);
|
||||
setStatusMessage(undefined);
|
||||
};
|
||||
|
||||
const submitMessage = async (message: string) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage) {
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Sending to Zopu…");
|
||||
try {
|
||||
await onSend(trimmedMessage);
|
||||
setComposerValue("");
|
||||
setStatusMessage("Sent to Zopu");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const sendCurrentMessage = async () => {
|
||||
try {
|
||||
await submitMessage(composerValue);
|
||||
} catch {
|
||||
// submitMessage already exposes the failure through statusMessage.
|
||||
}
|
||||
};
|
||||
void sendCurrentMessage();
|
||||
};
|
||||
|
||||
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const title = issueTitle.trim();
|
||||
const body = issueBody.trim();
|
||||
if (!title || !body) {
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Creating project work…");
|
||||
const createIssue = async () => {
|
||||
try {
|
||||
await onCreateIssue(title, body);
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
setStatusMessage("Project work created");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
void createIssue();
|
||||
};
|
||||
|
||||
return {
|
||||
composerValue,
|
||||
expanded,
|
||||
handleComposerChange,
|
||||
handleComposerMessageSubmit: submitMessage,
|
||||
handleComposerSubmit,
|
||||
handleCreateIssueSubmit,
|
||||
issueBody,
|
||||
issueTitle,
|
||||
projectManagerOpen,
|
||||
setExpanded,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setProjectManagerOpen,
|
||||
setSettingsOpen,
|
||||
settingsOpen,
|
||||
statusMessage,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useConvexAuth, useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const auth = useWebAuth();
|
||||
const { isAuthenticated, isRefreshing } = useConvexAuth();
|
||||
const convexReady = isAuthenticated && !isRefreshing;
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
if (!convexReady || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
}, [convexReady, ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!userId || state?.userId !== userId) {
|
||||
if (!convexReady || !userId || state?.userId !== userId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useFlueClient } from "@flue/react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
buildProjectLoopView,
|
||||
summarizeProjectIssues,
|
||||
} from "@/lib/projects/project-evidence";
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useProjectWorkspace = () => {
|
||||
const flueClient = useFlueClient();
|
||||
const projects = useQuery(api.projects.list);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [selectedIssueId, setSelectedIssueId] =
|
||||
useState<Id<"projectIssues"> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const createIssue = useMutation(api.projectIssues.create);
|
||||
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
|
||||
const beginIssue = useMutation(api.projectIssues.begin);
|
||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
||||
const activeProjectId =
|
||||
selectedProjectId ??
|
||||
(projects?.[0]?.id as unknown as Id<"projects">) ??
|
||||
null;
|
||||
const artifacts = useQuery(
|
||||
api.projectArtifacts.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const issues = useQuery(
|
||||
api.projectIssues.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const events = useQuery(
|
||||
api.projectIssues.events,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const pullRequest = events
|
||||
?.map((event) => event.data)
|
||||
.find(
|
||||
(
|
||||
data
|
||||
): data is {
|
||||
pullRequest: {
|
||||
baseBranch: string;
|
||||
branch: string;
|
||||
number: number;
|
||||
status: "open" | "closed" | "merged";
|
||||
url: string;
|
||||
};
|
||||
} =>
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"pullRequest" in data &&
|
||||
typeof data.pullRequest === "object" &&
|
||||
data.pullRequest !== null
|
||||
)?.pullRequest;
|
||||
|
||||
const connectRepository = async () => {
|
||||
setPendingAction("connect");
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await importPublicGit({ repositoryUrl: repository });
|
||||
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssue = async (input?: {
|
||||
readonly body?: string;
|
||||
readonly title?: string;
|
||||
}) => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = input?.title ?? issueTitle;
|
||||
const nextBody = input?.body ?? issueBody;
|
||||
setPendingAction("issue");
|
||||
setError(null);
|
||||
try {
|
||||
const issueId = await createIssue({
|
||||
body: nextBody,
|
||||
projectId: activeProjectId,
|
||||
title: nextTitle,
|
||||
});
|
||||
setSelectedIssueId(issueId);
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
|
||||
setPendingAction(`signal:${signalId}`);
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await createIssueFromSignal({ signalId });
|
||||
setSelectedIssueId(outcome.issueId);
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startIssue = async (
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => {
|
||||
const actionKey = `issue:${issueId}`;
|
||||
setPendingAction(actionKey);
|
||||
setError(null);
|
||||
try {
|
||||
await beginIssue({ issueId });
|
||||
await flueClient.agents.send("project-manager", String(issueId), {
|
||||
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = errorMessage(caughtError);
|
||||
await markDispatchFailed({ error: message, issueId });
|
||||
setError(message);
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
|
||||
const issue = issues?.find((candidate) => candidate._id === issueId);
|
||||
if (!issue) {
|
||||
setError("That work unit is no longer available");
|
||||
return;
|
||||
}
|
||||
await startIssue(issue._id, issue.number, issue.title);
|
||||
};
|
||||
const selectedProject =
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null;
|
||||
const selectedIssue =
|
||||
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
|
||||
const projectLoop = buildProjectLoopView({
|
||||
artifacts,
|
||||
issue: selectedIssue,
|
||||
source: selectedProject?.sources[0],
|
||||
});
|
||||
const issueSummary = summarizeProjectIssues(issues);
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
connectRepository,
|
||||
error,
|
||||
events,
|
||||
issueBody,
|
||||
issueSummary,
|
||||
issueTitle,
|
||||
issues,
|
||||
pendingAction,
|
||||
projectLoop,
|
||||
projects,
|
||||
pullRequest,
|
||||
raiseIssue,
|
||||
raiseIssueFromSignal,
|
||||
repository,
|
||||
selectedIssue,
|
||||
selectedIssueId,
|
||||
selectedProject,
|
||||
selectedProjectId: activeProjectId,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setRepository,
|
||||
setSelectedIssueId,
|
||||
setSelectedProjectId,
|
||||
startIssue,
|
||||
startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
@@ -75,6 +75,19 @@ html.slice-one-viewport-lock body {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.route-progress-bar {
|
||||
animation: route-progress 900ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes route-progress {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes response-dot {
|
||||
0%,
|
||||
60%,
|
||||
|
||||
64
apps/web/src/lib/auth.server.ts
Normal file
64
apps/web/src/lib/auth.server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { env } from "@code/env/web";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
interface AuthLoaderData {
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
|
||||
|
||||
export const loadAuthToken = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
const cookie = request.headers.get("cookie");
|
||||
if (!cookie) {
|
||||
return { token: null };
|
||||
}
|
||||
|
||||
const headers = new Headers({ cookie });
|
||||
headers.set("host", tokenUrl.host);
|
||||
const response = await fetch(tokenUrl, { headers });
|
||||
if (response.status === 401) {
|
||||
return { token: null };
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Response("Authentication service unavailable", { status: 503 });
|
||||
}
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("token" in payload) ||
|
||||
typeof payload.token !== "string"
|
||||
) {
|
||||
throw new Response("Authentication service returned an invalid response", {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
return { token: payload.token };
|
||||
};
|
||||
|
||||
export const requireAuthToken = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
const auth = await loadAuthToken(request);
|
||||
if (!auth.token) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
|
||||
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
return auth;
|
||||
};
|
||||
|
||||
export const redirectAuthenticated = async (
|
||||
request: Request
|
||||
): Promise<AuthLoaderData> => {
|
||||
const auth = await loadAuthToken(request);
|
||||
if (auth.token) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const returnTo = requestUrl.searchParams.get("returnTo");
|
||||
throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
|
||||
}
|
||||
return auth;
|
||||
};
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_CHAT_IMAGE_BYTES,
|
||||
bytesToBase64,
|
||||
validateChatImage,
|
||||
} from "./attachments";
|
||||
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
|
||||
|
||||
describe("chat image attachments", () => {
|
||||
test("accepts supported image payloads within the upload limit", () => {
|
||||
@@ -24,8 +20,4 @@ describe("chat image attachments", () => {
|
||||
}).accepted
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("encodes image bytes for the Flue prompt contract", () => {
|
||||
expect(bytesToBase64(new Uint8Array([90, 111, 112, 117]))).toBe("Wm9wdQ==");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AgentPromptImage } from "@flue/react";
|
||||
|
||||
export const MAX_CHAT_IMAGES = 4;
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -28,20 +26,3 @@ export const validateChatImage = (
|
||||
}
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import type { AgentStatus } from "@flue/react";
|
||||
import type { AgentStatus } from "./types";
|
||||
|
||||
/**
|
||||
* The global Zopu agent. The instance `id` is the current organization id
|
||||
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
||||
* instance. Organization scoping is the hard tenancy boundary.
|
||||
*/
|
||||
export const CHAT_AGENT = {
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
} as const;
|
||||
|
||||
export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
|
||||
export const MODEL_LABEL = "GPT-OSS 20B Free";
|
||||
|
||||
export const SUGGESTIONS = [
|
||||
["Think it through", "Help me make a difficult decision"],
|
||||
["Make a plan", "Turn an idea into clear next steps"],
|
||||
["Explain simply", "Break down something complex"],
|
||||
] as const;
|
||||
|
||||
export const STATUS_COPY: Record<AgentStatus, string> = {
|
||||
connecting: "Connecting",
|
||||
error: "Connection issue",
|
||||
|
||||
44
apps/web/src/lib/chat/conversation.test.ts
Normal file
44
apps/web/src/lib/chat/conversation.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ConversationRow } from "./conversation";
|
||||
import { projectConversation } from "./conversation";
|
||||
|
||||
const row = (
|
||||
overrides: Partial<ConversationRow> &
|
||||
Pick<ConversationRow, "messageId" | "role" | "status">
|
||||
): ConversationRow => ({
|
||||
attachments: [],
|
||||
error: null,
|
||||
rawText: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("projectConversation", () => {
|
||||
test("keeps queued assistant rows as status instead of duplicate messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "user-1",
|
||||
rawText: "Build it",
|
||||
role: "user",
|
||||
status: "processing",
|
||||
}),
|
||||
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
|
||||
]);
|
||||
expect(state.pending).toBe(true);
|
||||
expect(state.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("projects completed Convex rows into renderable messages", () => {
|
||||
const state = projectConversation([
|
||||
row({
|
||||
messageId: "assistant-1",
|
||||
rawText: "Captured the request.",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
expect(state.messages[0]?.parts).toEqual([
|
||||
{ state: "done", text: "Captured the request.", type: "text" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
50
apps/web/src/lib/chat/conversation.ts
Normal file
50
apps/web/src/lib/chat/conversation.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ConversationMessage } from "./types";
|
||||
|
||||
export interface ConversationRow {
|
||||
readonly attachments: readonly {
|
||||
readonly filename: string | null;
|
||||
readonly id: string;
|
||||
readonly mediaType: string;
|
||||
readonly url: string | null;
|
||||
}[];
|
||||
readonly error: string | null;
|
||||
readonly messageId: string;
|
||||
readonly rawText: string;
|
||||
readonly role: "assistant" | "user";
|
||||
readonly status: "completed" | "failed" | "processing" | "queued";
|
||||
}
|
||||
|
||||
export const projectConversation = (rows: readonly ConversationRow[]) => ({
|
||||
failedError: rows.findLast(
|
||||
(row) => row.role === "assistant" && row.status === "failed"
|
||||
)?.error,
|
||||
messages: rows
|
||||
.filter((row) => row.role === "user" || row.status === "completed")
|
||||
.map<ConversationMessage>((row) => ({
|
||||
id: row.messageId,
|
||||
parts: [
|
||||
...row.attachments.map((attachment) => ({
|
||||
filename: attachment.filename ?? undefined,
|
||||
id: attachment.id,
|
||||
mediaType: attachment.mediaType,
|
||||
type: "file" as const,
|
||||
url: attachment.url ?? undefined,
|
||||
})),
|
||||
...(row.rawText
|
||||
? [
|
||||
{
|
||||
state: "done" as const,
|
||||
text: row.rawText,
|
||||
type: "text" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
role: row.role,
|
||||
})),
|
||||
pending: rows.some(
|
||||
(row) =>
|
||||
row.role === "assistant" &&
|
||||
(row.status === "queued" || row.status === "processing")
|
||||
),
|
||||
});
|
||||
@@ -1,53 +1,18 @@
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
extractThinkingMarkup,
|
||||
getReasoningText,
|
||||
hasToolActivity,
|
||||
isReasoningStreaming,
|
||||
} from "./transforms";
|
||||
import type { ConversationMessage } from "./types";
|
||||
|
||||
const message = (
|
||||
parts: FlueConversationMessage["parts"]
|
||||
): FlueConversationMessage => ({
|
||||
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
|
||||
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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
|
||||
import type { AgentStatus, ConversationMessage } from "./types";
|
||||
|
||||
export const stripThinkingMarkup = (text: string): string => {
|
||||
const withoutCompletedBlocks = text.replaceAll(
|
||||
@@ -33,16 +33,16 @@ export const extractThinkingMarkup = (text: string): string => {
|
||||
return completed.join("\n\n");
|
||||
};
|
||||
|
||||
export const getRawMessageText = (message: FlueConversationMessage): string =>
|
||||
export const getRawMessageText = (message: ConversationMessage): string =>
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
|
||||
export const getMessageText = (message: FlueConversationMessage): string =>
|
||||
export const getMessageText = (message: ConversationMessage): string =>
|
||||
stripThinkingMarkup(getRawMessageText(message));
|
||||
|
||||
export const getReasoningText = (message: FlueConversationMessage): string => {
|
||||
export const getReasoningText = (message: ConversationMessage): string => {
|
||||
const nativeReasoning = message.parts
|
||||
.filter((part) => part.type === "reasoning")
|
||||
.map((part) => part.text)
|
||||
@@ -51,9 +51,7 @@ export const getReasoningText = (message: FlueConversationMessage): string => {
|
||||
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
|
||||
};
|
||||
|
||||
export const isReasoningStreaming = (
|
||||
message: FlueConversationMessage
|
||||
): boolean => {
|
||||
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
|
||||
const nativeReasoningStreaming = message.parts.some(
|
||||
(part) => part.type === "reasoning" && part.state === "streaming"
|
||||
);
|
||||
@@ -66,19 +64,12 @@ export const isReasoningStreaming = (
|
||||
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) => {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return part.state === "input-available";
|
||||
}
|
||||
return (
|
||||
export const isMessageStreaming = (message: ConversationMessage): boolean =>
|
||||
message.parts.some(
|
||||
(part) =>
|
||||
(part.type === "text" || part.type === "reasoning") &&
|
||||
part.state === "streaming"
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
export const getStatusDotClass = (status: AgentStatus): string => {
|
||||
if (status === "error") {
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
import type {
|
||||
AgentPromptImage,
|
||||
AgentStatus,
|
||||
FlueConversationMessage,
|
||||
FlueConversationPart,
|
||||
} from "@flue/react";
|
||||
export type AgentStatus =
|
||||
| "connecting"
|
||||
| "error"
|
||||
| "idle"
|
||||
| "streaming"
|
||||
| "submitted";
|
||||
|
||||
export type ConversationPart =
|
||||
| {
|
||||
readonly state: "done" | "streaming";
|
||||
readonly text: string;
|
||||
readonly type: "text";
|
||||
}
|
||||
| {
|
||||
readonly state: "done" | "streaming";
|
||||
readonly text: string;
|
||||
readonly type: "reasoning";
|
||||
}
|
||||
| {
|
||||
readonly filename?: string;
|
||||
readonly id?: string;
|
||||
readonly mediaType: string;
|
||||
readonly type: "file";
|
||||
readonly url?: string;
|
||||
};
|
||||
|
||||
export interface ConversationMessage {
|
||||
readonly id: string;
|
||||
readonly parts: ConversationPart[];
|
||||
readonly role: "assistant" | "user";
|
||||
}
|
||||
|
||||
export interface ChatAgentState {
|
||||
error?: Error;
|
||||
historyReady: boolean;
|
||||
messages: FlueConversationMessage[];
|
||||
messages: ConversationMessage[];
|
||||
sendMessage: (
|
||||
message: string,
|
||||
options?: { readonly images?: AgentPromptImage[] }
|
||||
options?: { readonly images?: readonly File[] }
|
||||
) => Promise<void>;
|
||||
status: AgentStatus;
|
||||
}
|
||||
@@ -24,7 +49,7 @@ export interface ChatComposerProps {
|
||||
|
||||
export interface ChatConversationProps {
|
||||
historyReady: boolean;
|
||||
messages: FlueConversationMessage[];
|
||||
messages: ConversationMessage[];
|
||||
onSuggestion: (suggestion: string) => void;
|
||||
status: AgentStatus;
|
||||
}
|
||||
@@ -34,12 +59,7 @@ export interface ChatHeaderProps {
|
||||
}
|
||||
|
||||
export interface ChatMessageProps {
|
||||
hideToolActivity?: boolean;
|
||||
message: FlueConversationMessage;
|
||||
}
|
||||
|
||||
export interface ChatToolCallProps {
|
||||
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
|
||||
message: ConversationMessage;
|
||||
}
|
||||
|
||||
export type AssistantResponseState = "thinking" | "writing";
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createFlueFetch, generateBrowserRequestId } from "./flue-transport";
|
||||
|
||||
const BASE_URL = new URL("https://flue.example/api/");
|
||||
const GLOBAL_RECEIVER_FETCH = function globalReceiverFetch(this: unknown) {
|
||||
if (this !== globalThis) {
|
||||
throw new TypeError("fetch receiver must be globalThis");
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
} as typeof fetch;
|
||||
|
||||
describe("createFlueFetch", () => {
|
||||
test("overlapping SDK agent sends keep distinct request IDs", async () => {
|
||||
const firstResponse = Promise.withResolvers<Response>();
|
||||
const secondResponse = Promise.withResolvers<Response>();
|
||||
const bothStarted = Promise.withResolvers<boolean>();
|
||||
const capturedHeaders: Headers[] = [];
|
||||
let requestCount = 0;
|
||||
let requestId = 0;
|
||||
|
||||
const fetchImpl: typeof fetch = (input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
requestCount += 1;
|
||||
if (requestCount === 2) {
|
||||
bothStarted.resolve(true);
|
||||
}
|
||||
if (requestCount === 1) {
|
||||
return firstResponse.promise;
|
||||
}
|
||||
if (requestCount === 2) {
|
||||
return secondResponse.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${String(input)}`);
|
||||
};
|
||||
const client = createFlueClient({
|
||||
baseUrl: BASE_URL.toString(),
|
||||
fetch: createFlueFetch({
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl,
|
||||
generateRequestId: () => `request-${(requestId += 1)}`,
|
||||
}),
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
});
|
||||
|
||||
const firstSend = client.agents.send("zopu", "org-a", {
|
||||
message: "first",
|
||||
});
|
||||
const secondSend = client.agents.send("zopu", "org-a", {
|
||||
message: "second",
|
||||
});
|
||||
await bothStarted.promise;
|
||||
|
||||
expect(capturedHeaders).toHaveLength(2);
|
||||
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
|
||||
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
|
||||
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
|
||||
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
|
||||
|
||||
firstResponse.resolve(
|
||||
Response.json(
|
||||
{
|
||||
offset: "1",
|
||||
streamUrl: "http://internal/streams/first",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
);
|
||||
secondResponse.resolve(
|
||||
Response.json(
|
||||
{
|
||||
offset: "2",
|
||||
streamUrl: "http://internal/streams/second",
|
||||
submissionId: "submission-2",
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
|
||||
{
|
||||
offset: "1",
|
||||
streamUrl: "https://flue.example/streams/first",
|
||||
submissionId: "submission-1",
|
||||
},
|
||||
{
|
||||
offset: "2",
|
||||
streamUrl: "https://flue.example/streams/second",
|
||||
submissionId: "submission-2",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("invokes fetch with the browser global receiver", async () => {
|
||||
await expect(
|
||||
createFlueFetch({ baseUrl: BASE_URL, fetchImpl: GLOBAL_RECEIVER_FETCH })(
|
||||
"https://flue.example/api/agents/zopu/org-a?view=history"
|
||||
)
|
||||
).resolves.toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
test("uses the default browser request ID generator without losing its receiver", async () => {
|
||||
const capturedHeaders: Headers[] = [];
|
||||
const fetchImpl: typeof fetch = (_input, init) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
|
||||
await createFlueFetch({ baseUrl: BASE_URL, fetchImpl })(
|
||||
"https://flue.example/api/agents/zopu/org-a",
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toMatch(
|
||||
/^[0-9a-f-]{36}$/u
|
||||
);
|
||||
});
|
||||
|
||||
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) => {
|
||||
capturedHeaders.push(new Headers(init?.headers));
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
};
|
||||
const flueFetch = createFlueFetch({
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl,
|
||||
generateRequestId: () => "must-not-be-used",
|
||||
});
|
||||
|
||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
method: "GET",
|
||||
});
|
||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
|
||||
headers: { authorization: "Bearer current-jwt" },
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
expect(capturedHeaders).toHaveLength(2);
|
||||
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
|
||||
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
interface FlueFetchOptions {
|
||||
readonly baseUrl: URL;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
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,
|
||||
requestUrl: URL
|
||||
): Promise<Response> => {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
const responseClone = response.clone();
|
||||
const responseText = await responseClone.text();
|
||||
const detail = responseText.trim() || "request failed";
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: `${method} ${requestUrl.pathname}${requestUrl.search}: ${detail}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: response.headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the Flue transport used by the browser client.
|
||||
*
|
||||
* The installed Flue SDK does not expose per-call headers on `agents.send`.
|
||||
* Its custom `fetch` seam does receive the resolved URL and method, so request
|
||||
* IDs are attached here to each agent admission POST. History and Durable
|
||||
* Streams observation requests are GETs and intentionally remain untagged.
|
||||
*/
|
||||
export const createFlueFetch = ({
|
||||
baseUrl,
|
||||
fetchImpl = fetch,
|
||||
generateRequestId = generateBrowserRequestId,
|
||||
}: FlueFetchOptions): typeof fetch => {
|
||||
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
||||
|
||||
return async (input, init) => {
|
||||
const inputUrl =
|
||||
typeof input === "string" || input instanceof URL ? input : input.url;
|
||||
const requestUrl = new URL(inputUrl, baseUrl);
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
|
||||
? requestUrl.pathname.slice(basePath.length)
|
||||
: requestUrl.pathname;
|
||||
const pathSegments = relativePath.split("/").filter(Boolean);
|
||||
const isAgentAdmission =
|
||||
method === "POST" &&
|
||||
pathSegments.length === 3 &&
|
||||
pathSegments[0] === "agents";
|
||||
|
||||
let requestInit = init;
|
||||
if (isAgentAdmission) {
|
||||
const headers = new Headers(
|
||||
input instanceof Request ? input.headers : undefined
|
||||
);
|
||||
for (const [key, value] of new Headers(init?.headers).entries()) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
headers.set("x-zopu-request-id", generateRequestId());
|
||||
requestInit = { ...init, headers };
|
||||
}
|
||||
|
||||
const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
|
||||
const response = await addRequestContext(rawResponse, method, requestUrl);
|
||||
if (!response.headers.get("content-type")?.includes("application/json")) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const body = (await response.clone().json()) as unknown;
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("streamUrl" in body) ||
|
||||
typeof body.streamUrl !== "string"
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const streamUrl = new URL(body.streamUrl);
|
||||
streamUrl.protocol = baseUrl.protocol;
|
||||
streamUrl.host = baseUrl.host;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("location", streamUrl.toString());
|
||||
return Response.json(
|
||||
{ ...body, streamUrl: streamUrl.toString() },
|
||||
{
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
getProjectWorkNextAction,
|
||||
getProjectWorkProgress,
|
||||
getProjectWorkStatus,
|
||||
} from "@code/primitives/project-work";
|
||||
import type {
|
||||
MobileAssistantView,
|
||||
MobileProjectView,
|
||||
MobileSignalView,
|
||||
MobileWorkspaceView,
|
||||
MobileWorkUnitTone,
|
||||
MobileWorkUnitView,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
|
||||
type SignalList = FunctionReturnType<typeof api.signals.list>;
|
||||
type ProjectEventList = FunctionReturnType<typeof api.projectIssues.events>;
|
||||
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
|
||||
|
||||
interface BuildMobileWorkspaceViewInput {
|
||||
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
|
||||
readonly assistant: MobileAssistantView;
|
||||
readonly events: ProjectEventList | undefined;
|
||||
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
||||
readonly organizationLabel?: string;
|
||||
readonly projects: readonly ProjectView[] | undefined;
|
||||
readonly selectedProject: ProjectView | null;
|
||||
readonly selectedWorkUnitId?: string;
|
||||
readonly signals: SignalList | undefined;
|
||||
}
|
||||
|
||||
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
const statusTone = (
|
||||
status: ReturnType<typeof getProjectWorkStatus>
|
||||
): MobileWorkUnitTone => {
|
||||
if (status.phase === "completed") {
|
||||
return "green";
|
||||
}
|
||||
if (status.phase === "failed" || status.phase === "waiting") {
|
||||
return "orange";
|
||||
}
|
||||
if (status.phase === "captured") {
|
||||
return "purple";
|
||||
}
|
||||
return "blue";
|
||||
};
|
||||
|
||||
const isPullRequestData = (
|
||||
value: unknown
|
||||
): value is {
|
||||
pullRequest: {
|
||||
number: number;
|
||||
status: "open" | "closed" | "merged";
|
||||
url: string;
|
||||
};
|
||||
} =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"pullRequest" in value &&
|
||||
typeof value.pullRequest === "object" &&
|
||||
value.pullRequest !== null &&
|
||||
"number" in value.pullRequest &&
|
||||
typeof value.pullRequest.number === "number" &&
|
||||
"url" in value.pullRequest &&
|
||||
typeof value.pullRequest.url === "string";
|
||||
|
||||
const toWorkUnit = (
|
||||
issue: Doc<"projectIssues">,
|
||||
artifactCount: number,
|
||||
events: ProjectEventList | undefined
|
||||
): MobileWorkUnitView => {
|
||||
const status = getProjectWorkStatus(issue.status);
|
||||
const pullRequest = events
|
||||
?.filter((event) => event.issueId === issue._id)
|
||||
.map((event) => event.data)
|
||||
.find(isPullRequestData)?.pullRequest;
|
||||
return {
|
||||
artifactCount,
|
||||
canRetry: status.phase === "failed" || status.phase === "waiting",
|
||||
canStart: status.phase === "captured",
|
||||
code: `#${issue.number}`,
|
||||
id: issue._id,
|
||||
nextAction: getProjectWorkNextAction(issue.status),
|
||||
progress: getProjectWorkProgress(issue.status),
|
||||
pullRequestNumber: pullRequest?.number,
|
||||
reviewUrl: pullRequest?.url,
|
||||
statusLabel: status.label,
|
||||
summary: issue.body,
|
||||
title: issue.title,
|
||||
tone: statusTone(status),
|
||||
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
|
||||
};
|
||||
};
|
||||
|
||||
const toLatestSignalView = (
|
||||
signal: SignalList[number]["signal"] | undefined
|
||||
): MobileSignalView | undefined => {
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
||||
id: signal._id,
|
||||
projectId: signal.projectId ?? undefined,
|
||||
summary: signal.problemStatement.summary,
|
||||
title: signal.problemStatement.title,
|
||||
};
|
||||
};
|
||||
|
||||
const relevantSignalsForProject = (
|
||||
signals: SignalList | undefined,
|
||||
projectId: ProjectView["id"] | undefined
|
||||
) =>
|
||||
signals?.filter(
|
||||
({ signal }) =>
|
||||
!signal.projectId || String(signal.projectId) === String(projectId)
|
||||
) ?? [];
|
||||
|
||||
const makeProjectViews = (
|
||||
projects: readonly ProjectView[] | undefined
|
||||
): readonly MobileProjectView[] =>
|
||||
projects?.map((project) => {
|
||||
const [source] = project.sources;
|
||||
return {
|
||||
connected: Boolean(source),
|
||||
host: source?.host,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
repositoryPath: source?.repositoryPath,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
export const buildMobileWorkspaceView = ({
|
||||
artifacts,
|
||||
assistant,
|
||||
events,
|
||||
issues,
|
||||
organizationLabel = "Personal workspace",
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedWorkUnitId,
|
||||
signals,
|
||||
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
|
||||
const artifactCount = artifacts?.length ?? 0;
|
||||
const workUnits =
|
||||
issues?.map((issue) => toWorkUnit(issue, artifactCount, events)) ?? [];
|
||||
const selectedWorkUnit =
|
||||
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
|
||||
workUnits.find((workUnit) => workUnit.tone === "blue") ??
|
||||
workUnits[0];
|
||||
const selectedProjectId = selectedProject?.id;
|
||||
const relevantSignals = relevantSignalsForProject(signals, selectedProjectId);
|
||||
const latestSignal = relevantSignals[0]?.signal;
|
||||
const activeCount = workUnits.filter(
|
||||
(workUnit) => workUnit.progress < 100
|
||||
).length;
|
||||
const needsAttentionCount = issues?.filter(
|
||||
(issue) => issue.status === "failed" || issue.status === "needs-input"
|
||||
).length;
|
||||
const shippedCount = issues?.filter(
|
||||
(issue) => issue.status === "completed"
|
||||
).length;
|
||||
const hasSelectedProject = Boolean(selectedProject);
|
||||
const projectViews = makeProjectViews(projects);
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
activityCount: workUnits.length + relevantSignals.length,
|
||||
artifactCount,
|
||||
assistant,
|
||||
isLoading:
|
||||
projects === undefined ||
|
||||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
|
||||
latestSignal: toLatestSignalView(latestSignal),
|
||||
needsAttentionCount: needsAttentionCount ?? 0,
|
||||
organizationLabel,
|
||||
projectName: selectedProject?.name,
|
||||
projects: projectViews,
|
||||
selectedProjectId: selectedProject?.id,
|
||||
selectedWorkUnit,
|
||||
shippedCount: shippedCount ?? 0,
|
||||
totalCount: workUnits.length,
|
||||
workUnits,
|
||||
};
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getMobileStatusMessage } from "./mobile-status-message";
|
||||
|
||||
describe("getMobileStatusMessage", () => {
|
||||
it("keeps local composer feedback", () => {
|
||||
expect(
|
||||
getMobileStatusMessage({
|
||||
assistantError: "Flue API error 401",
|
||||
localStatus: "Sent to Zopu",
|
||||
})
|
||||
).toBe("Sent to Zopu");
|
||||
});
|
||||
|
||||
it("does not expose Flue authorization internals", () => {
|
||||
expect(
|
||||
getMobileStatusMessage({
|
||||
assistantError:
|
||||
'Flue API error 401: GET /agents/zopu/example: {"error":"Unauthorized"}',
|
||||
})
|
||||
).toBe("Zopu is reconnecting…");
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
interface MobileStatusMessageInput {
|
||||
readonly assistantError?: string;
|
||||
readonly localStatus?: string;
|
||||
}
|
||||
|
||||
const isAgentAuthorizationError = (message: string) =>
|
||||
message.includes("Flue API error 401") ||
|
||||
message.includes('"Unauthorized"') ||
|
||||
message.includes("GET /agents/");
|
||||
|
||||
export const getMobileStatusMessage = ({
|
||||
assistantError,
|
||||
localStatus,
|
||||
}: MobileStatusMessageInput): string | undefined => {
|
||||
if (localStatus) {
|
||||
return localStatus;
|
||||
}
|
||||
if (!assistantError) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAgentAuthorizationError(assistantError)) {
|
||||
return "Zopu is reconnecting…";
|
||||
}
|
||||
return "Zopu needs attention";
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildProjectLoopView,
|
||||
summarizeProjectIssues,
|
||||
} from "./project-evidence";
|
||||
|
||||
const makeIssue = (
|
||||
status: Doc<"projectIssues">["status"],
|
||||
issueId: Id<"projectIssues"> = "issue-1" as Id<"projectIssues">
|
||||
): Doc<"projectIssues"> =>
|
||||
({
|
||||
_creationTime: 1,
|
||||
_id: issueId,
|
||||
body: "Make the project loop visible in the web app.",
|
||||
createdAt: 1000,
|
||||
number: 8,
|
||||
projectId: "project-1",
|
||||
status,
|
||||
title: "Build project loop UI",
|
||||
updatedAt: 2000,
|
||||
}) as Doc<"projectIssues">;
|
||||
|
||||
const makeArtifact = (content: string): Doc<"projectArtifacts"> =>
|
||||
({
|
||||
_creationTime: 1,
|
||||
_id: "artifact-1",
|
||||
content,
|
||||
createdAt: 1000,
|
||||
path: "artifacts.md",
|
||||
projectId: "project-1",
|
||||
revision: 2,
|
||||
updatedAt: 2000,
|
||||
}) as Doc<"projectArtifacts">;
|
||||
|
||||
describe("project evidence", () => {
|
||||
test("turns published PR evidence into a review action", () => {
|
||||
const view = buildProjectLoopView({
|
||||
artifacts: [
|
||||
makeArtifact(
|
||||
"Verification: 14 unit tests passed.\nPull request: PR #42"
|
||||
),
|
||||
],
|
||||
issue: makeIssue("completed"),
|
||||
source: {
|
||||
host: "git.example.com",
|
||||
repositoryPath: "puter/zopu",
|
||||
url: "https://git.example.com/puter/zopu",
|
||||
},
|
||||
});
|
||||
|
||||
expect(view?.progress).toBe(100);
|
||||
expect(view?.verification).toBe("passed");
|
||||
expect(view?.pullRequest.reviewUrl).toBe(
|
||||
"https://git.example.com/puter/zopu/pulls/42"
|
||||
);
|
||||
expect(view?.verificationNote).toContain(
|
||||
"Verification: 14 unit tests passed."
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the review slot pending when no PR is published", () => {
|
||||
const view = buildProjectLoopView({
|
||||
artifacts: [
|
||||
makeArtifact("The run is recording implementation evidence."),
|
||||
],
|
||||
issue: makeIssue("working"),
|
||||
source: undefined,
|
||||
});
|
||||
|
||||
expect(view?.verification).toBe("running");
|
||||
expect(view?.pullRequest.state).toBe("pending");
|
||||
expect(view?.pullRequest.reviewUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test("summarizes the project queue without inventing work", () => {
|
||||
const summary = summarizeProjectIssues([
|
||||
makeIssue("working"),
|
||||
makeIssue("needs-input", "issue-2" as Id<"projectIssues">),
|
||||
makeIssue("completed", "issue-3" as Id<"projectIssues">),
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({
|
||||
active: 2,
|
||||
completed: 1,
|
||||
needsInput: 1,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
||||
import type { ProjectIssueStatus } from "@code/primitives/project-issue";
|
||||
import {
|
||||
getProjectWorkNextAction,
|
||||
getProjectWorkProgress,
|
||||
getProjectWorkStatus,
|
||||
} from "@code/primitives/project-work";
|
||||
import type { ProjectVerificationStatus } from "@code/primitives/project-work";
|
||||
|
||||
type ProjectIssue = Doc<"projectIssues">;
|
||||
type ProjectArtifact = Doc<"projectArtifacts">;
|
||||
|
||||
export interface ProjectSourceSummary {
|
||||
readonly host: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
export interface ProjectIssueSummary {
|
||||
readonly active: number;
|
||||
readonly completed: number;
|
||||
readonly needsInput: number;
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
export interface ProjectVerificationCheck {
|
||||
readonly detail: string;
|
||||
readonly label: string;
|
||||
readonly state: "attention" | "complete" | "current" | "upcoming";
|
||||
}
|
||||
|
||||
export interface ProjectActivityItem {
|
||||
readonly detail: string;
|
||||
readonly label: string;
|
||||
readonly time: string;
|
||||
}
|
||||
|
||||
export interface ProjectPullRequest {
|
||||
readonly number: number | undefined;
|
||||
readonly reviewUrl: string | undefined;
|
||||
readonly state: "available" | "pending";
|
||||
}
|
||||
|
||||
export interface ProjectLoopView {
|
||||
readonly activity: readonly ProjectActivityItem[];
|
||||
readonly currentState: string;
|
||||
readonly nextAction: string;
|
||||
readonly progress: number;
|
||||
readonly pullRequest: ProjectPullRequest;
|
||||
readonly status: ReturnType<typeof getProjectWorkStatus>;
|
||||
readonly verification: ProjectVerificationStatus;
|
||||
readonly verificationChecks: readonly ProjectVerificationCheck[];
|
||||
readonly verificationNote: string;
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
|
||||
completed:
|
||||
"Implementation and verification are complete. Review the resulting change.",
|
||||
failed:
|
||||
"The run stopped with its workspace preserved. Review the failure and retry when ready.",
|
||||
"needs-input":
|
||||
"The run is paused until a project decision or missing piece is resolved.",
|
||||
open: "The issue is ready to start. Zopu will keep the project context attached to the run.",
|
||||
queued: "The issue is queued for the project manager to pick up.",
|
||||
working:
|
||||
"The project manager is working through the issue and recording durable evidence.",
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number): string =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(timestamp);
|
||||
|
||||
const relativeTime = (timestamp: number): string => {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 1) {
|
||||
return "just now";
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const allArtifactText = (artifacts: readonly ProjectArtifact[]): string =>
|
||||
artifacts.map((artifact) => artifact.content).join("\n");
|
||||
|
||||
const extractPullRequest = (
|
||||
artifacts: readonly ProjectArtifact[],
|
||||
source: ProjectSourceSummary | undefined
|
||||
): ProjectPullRequest => {
|
||||
const text = allArtifactText(artifacts);
|
||||
const urlMatch = text.match(/https?:\/\/[^\s)]+\/pulls\/\d+/iu)?.[0];
|
||||
const numberMatch = text.match(/\bPR\s*#(?<number>\d+)\b/iu);
|
||||
const numberValue = numberMatch?.groups?.number;
|
||||
const number = numberValue ? Number(numberValue) : undefined;
|
||||
let reviewUrl = urlMatch;
|
||||
if (!reviewUrl && number && source) {
|
||||
reviewUrl = `${source.url.replace(/\/$/u, "")}/pulls/${number}`;
|
||||
}
|
||||
|
||||
return {
|
||||
number,
|
||||
reviewUrl,
|
||||
state: reviewUrl ? "available" : "pending",
|
||||
};
|
||||
};
|
||||
|
||||
const extractVerificationNote = (
|
||||
artifacts: readonly ProjectArtifact[],
|
||||
status: ProjectIssueStatus
|
||||
): string => {
|
||||
const evidenceLine = allArtifactText(artifacts)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /verification|tests?|browser|passed|failed/iu.test(line));
|
||||
|
||||
if (evidenceLine) {
|
||||
return evidenceLine.replace(/^[-*#\s]+/u, "").slice(0, 140);
|
||||
}
|
||||
if (status === "completed") {
|
||||
return "The agent reported completion after its verification step.";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "Verification did not complete successfully.";
|
||||
}
|
||||
if (status === "working") {
|
||||
return "Verification will appear here as the run records evidence.";
|
||||
}
|
||||
return "No verification evidence has been recorded yet.";
|
||||
};
|
||||
|
||||
const makeRunCheck = (status: ProjectIssueStatus): ProjectVerificationCheck => {
|
||||
if (status === "queued") {
|
||||
return {
|
||||
detail: "Agent admission is queued",
|
||||
label: "Agent run",
|
||||
state: "current",
|
||||
};
|
||||
}
|
||||
if (status === "open") {
|
||||
return {
|
||||
detail: "Start the issue to create a run",
|
||||
label: "Agent run",
|
||||
state: "upcoming",
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: "Project manager run is attached",
|
||||
label: "Agent run",
|
||||
state: "complete",
|
||||
};
|
||||
};
|
||||
|
||||
const makeVerificationCheck = (
|
||||
verification: ProjectVerificationStatus
|
||||
): ProjectVerificationCheck => {
|
||||
if (verification === "passed") {
|
||||
return {
|
||||
detail: "Verification passed",
|
||||
label: "Verification",
|
||||
state: "complete",
|
||||
};
|
||||
}
|
||||
if (verification === "failed") {
|
||||
return {
|
||||
detail: "Review the preserved failure",
|
||||
label: "Verification",
|
||||
state: "attention",
|
||||
};
|
||||
}
|
||||
if (verification === "blocked") {
|
||||
return {
|
||||
detail: "Waiting on a project decision",
|
||||
label: "Verification",
|
||||
state: "attention",
|
||||
};
|
||||
}
|
||||
if (verification === "running") {
|
||||
return {
|
||||
detail: "Evidence is being recorded",
|
||||
label: "Verification",
|
||||
state: "current",
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail: "No verification result yet",
|
||||
label: "Verification",
|
||||
state: "upcoming",
|
||||
};
|
||||
};
|
||||
|
||||
const makeChecks = (
|
||||
status: ProjectIssueStatus,
|
||||
verification: ProjectVerificationStatus,
|
||||
pullRequest: ProjectPullRequest
|
||||
): readonly ProjectVerificationCheck[] => {
|
||||
const issueCheck: ProjectVerificationCheck =
|
||||
status === "open"
|
||||
? {
|
||||
detail: "Waiting to be started",
|
||||
label: "Issue accepted",
|
||||
state: "upcoming",
|
||||
}
|
||||
: {
|
||||
detail: "Issue context is attached",
|
||||
label: "Issue accepted",
|
||||
state: "complete",
|
||||
};
|
||||
const pullRequestCheck: ProjectVerificationCheck =
|
||||
pullRequest.state === "available"
|
||||
? {
|
||||
detail: "Ready for human review",
|
||||
label: "Gitea pull request",
|
||||
state: "complete",
|
||||
}
|
||||
: {
|
||||
detail: "Appears after a verified run publishes one",
|
||||
label: "Gitea pull request",
|
||||
state: "upcoming",
|
||||
};
|
||||
return [
|
||||
issueCheck,
|
||||
makeRunCheck(status),
|
||||
makeVerificationCheck(verification),
|
||||
pullRequestCheck,
|
||||
];
|
||||
};
|
||||
|
||||
const buildActivity = (
|
||||
issue: ProjectIssue,
|
||||
artifacts: readonly ProjectArtifact[],
|
||||
statusLabel: string
|
||||
): readonly ProjectActivityItem[] => {
|
||||
const artifact = [...artifacts]
|
||||
.toSorted((left, right) => right.updatedAt - left.updatedAt)
|
||||
.find((candidate) => candidate.path !== "work.md");
|
||||
const items: ProjectActivityItem[] = [
|
||||
{
|
||||
detail: `Issue #${issue.number} entered the project loop`,
|
||||
label: "Issue created",
|
||||
time: formatTime(issue.createdAt),
|
||||
},
|
||||
];
|
||||
|
||||
if (issue.updatedAt !== issue.createdAt) {
|
||||
items.unshift({
|
||||
detail: `Status is now ${statusLabel.toLowerCase()}`,
|
||||
label: "Work state updated",
|
||||
time: relativeTime(issue.updatedAt),
|
||||
});
|
||||
}
|
||||
|
||||
if (artifact) {
|
||||
items.unshift({
|
||||
detail: `Durable project evidence changed in ${artifact.path}`,
|
||||
label: "Evidence recorded",
|
||||
time: relativeTime(artifact.updatedAt),
|
||||
});
|
||||
}
|
||||
|
||||
return items.slice(0, 3);
|
||||
};
|
||||
|
||||
export const summarizeProjectIssues = (
|
||||
issues: readonly ProjectIssue[] | undefined
|
||||
): ProjectIssueSummary => {
|
||||
if (!issues) {
|
||||
return { active: 0, completed: 0, needsInput: 0, total: 0 };
|
||||
}
|
||||
return {
|
||||
active: issues.filter(
|
||||
(issue) => !["completed", "failed"].includes(issue.status)
|
||||
).length,
|
||||
completed: issues.filter((issue) => issue.status === "completed").length,
|
||||
needsInput: issues.filter((issue) => issue.status === "needs-input").length,
|
||||
total: issues.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildProjectLoopView = ({
|
||||
artifacts,
|
||||
issue,
|
||||
source,
|
||||
}: {
|
||||
readonly artifacts: readonly ProjectArtifact[] | undefined;
|
||||
readonly issue: ProjectIssue | undefined;
|
||||
readonly source: ProjectSourceSummary | undefined;
|
||||
}): ProjectLoopView | null => {
|
||||
if (!issue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = getProjectWorkStatus(issue.status);
|
||||
const { verification } = status;
|
||||
const resolvedArtifacts = artifacts ?? [];
|
||||
const pullRequest = extractPullRequest(resolvedArtifacts, source);
|
||||
|
||||
return {
|
||||
activity: buildActivity(issue, resolvedArtifacts, status.label),
|
||||
currentState: STATUS_TEXT[issue.status],
|
||||
nextAction: getProjectWorkNextAction(issue.status),
|
||||
progress: getProjectWorkProgress(issue.status),
|
||||
pullRequest,
|
||||
status,
|
||||
verification,
|
||||
verificationChecks: makeChecks(issue.status, verification, pullRequest),
|
||||
verificationNote: extractVerificationNote(resolvedArtifacts, issue.status),
|
||||
};
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
|
||||
|
||||
const textMessage = (
|
||||
id: string,
|
||||
role: "assistant" | "user",
|
||||
text: string
|
||||
): FlueConversationMessage => ({
|
||||
): ConversationMessage => ({
|
||||
id,
|
||||
parts: [{ state: "done", text, type: "text" }],
|
||||
role,
|
||||
@@ -27,19 +28,6 @@ describe("Slice 1 presentation", () => {
|
||||
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]
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
|
||||
import {
|
||||
getMessageText,
|
||||
getRawMessageText,
|
||||
getReasoningText,
|
||||
hasToolActivity,
|
||||
} from "@/lib/chat/transforms";
|
||||
import type { ConversationMessage } from "@/lib/chat/types";
|
||||
|
||||
export type SliceTimelineItem =
|
||||
| {
|
||||
readonly kind: "message";
|
||||
readonly message: FlueConversationMessage;
|
||||
readonly message: ConversationMessage;
|
||||
}
|
||||
| { readonly kind: "work"; readonly notice: WorkNotice };
|
||||
|
||||
export const isSliceOneVisibleMessage = (
|
||||
message: FlueConversationMessage
|
||||
message: ConversationMessage
|
||||
): boolean =>
|
||||
message.role === "user" ||
|
||||
!hasToolActivity(message) ||
|
||||
getMessageText(message).length > 0 ||
|
||||
getReasoningText(message).length > 0;
|
||||
|
||||
const targetIndexForNotice = (
|
||||
messages: readonly FlueConversationMessage[],
|
||||
messages: readonly ConversationMessage[],
|
||||
notice: WorkNotice
|
||||
): number => {
|
||||
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
|
||||
@@ -40,14 +38,12 @@ const targetIndexForNotice = (
|
||||
const sourceIndex = Math.max(...sourceIndexes);
|
||||
const responseOffset = messages
|
||||
.slice(sourceIndex + 1)
|
||||
.findIndex(
|
||||
(message) => message.role === "assistant" && !hasToolActivity(message)
|
||||
);
|
||||
.findIndex((message) => message.role === "assistant");
|
||||
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
|
||||
};
|
||||
|
||||
export const buildSliceOneTimeline = (
|
||||
allMessages: readonly FlueConversationMessage[],
|
||||
allMessages: readonly ConversationMessage[],
|
||||
notices: readonly WorkNotice[]
|
||||
): readonly SliceTimelineItem[] => {
|
||||
const messages = allMessages.filter(isSliceOneVisibleMessage);
|
||||
@@ -75,7 +71,7 @@ export const buildSliceOneTimeline = (
|
||||
};
|
||||
|
||||
export const findSourceMessageTarget = (
|
||||
messages: readonly FlueConversationMessage[],
|
||||
messages: readonly ConversationMessage[],
|
||||
sourceText: string
|
||||
): string | undefined =>
|
||||
messages.find(
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
|
||||
import { env } from "@code/env/web";
|
||||
import { WebAuthProvider } from "@code/auth/web";
|
||||
import { Toaster } from "@code/ui/components/sonner";
|
||||
import { FlueProvider } from "@flue/react";
|
||||
|
||||
import "./index.css";
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { useMemo } from "react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
@@ -13,11 +10,14 @@ import {
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useNavigation,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { createFlueFetch } from "./lib/flue-transport";
|
||||
import { loadAuthToken } from "./lib/auth.server";
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
||||
@@ -32,34 +32,6 @@ export const links: Route.LinksFunction = () => [
|
||||
},
|
||||
];
|
||||
|
||||
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
|
||||
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
|
||||
|
||||
const AuthenticatedFlueProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const resolveAccessToken = useConvexAccessToken();
|
||||
const client = useMemo(
|
||||
() =>
|
||||
createFlueClient({
|
||||
baseUrl: flueBaseUrl.toString(),
|
||||
fetch: flueFetch,
|
||||
headers: async () => {
|
||||
const accessToken = await resolveAccessToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
headers.authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
[resolveAccessToken]
|
||||
);
|
||||
|
||||
return <FlueProvider client={client}>{children}</FlueProvider>;
|
||||
};
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -79,20 +51,42 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
</html>
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<WebAuthProvider>
|
||||
<AuthenticatedFlueProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<Outlet />
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</AuthenticatedFlueProvider>
|
||||
const RouteProgress = () => {
|
||||
const navigation = useNavigation();
|
||||
if (!navigation.location) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<output
|
||||
aria-label="Loading page"
|
||||
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
|
||||
>
|
||||
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
|
||||
</output>
|
||||
);
|
||||
};
|
||||
|
||||
export const HydrateFallback = () => (
|
||||
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu…
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
const App = ({ loaderData }: Route.ComponentProps) => (
|
||||
<WebAuthProvider initialToken={loaderData.token}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<RouteProgress />
|
||||
<Outlet />
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</WebAuthProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -2,21 +2,12 @@ import { index, layout, route } from "@react-router/dev/routes";
|
||||
import type { RouteConfig } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
// Standalone chat page — no auth, mobile-first, talks to the zopu server.
|
||||
route("chat", "./routes/standalone/chat/page.tsx"),
|
||||
layout("./routes/auth/layout.tsx", [
|
||||
route("login", "./routes/auth/login/page.tsx"),
|
||||
route("signup", "./routes/auth/signup/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [
|
||||
layout("./routes/app/mobile/layout.tsx", [
|
||||
index("./routes/app/mobile/page.tsx"),
|
||||
route("chat", "./routes/app/mobile/chat/page.tsx"),
|
||||
route("chat/:workUnitId", "./routes/app/mobile/chat/work/page.tsx"),
|
||||
route("work", "./routes/app/mobile/work-list/page.tsx"),
|
||||
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
|
||||
]),
|
||||
index("./routes/app/mobile/page.tsx"),
|
||||
route("dashboard", "./routes/app/dashboard/page.tsx"),
|
||||
route("todos", "./routes/app/todos/page.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import { requireAuthToken } from "@/lib/auth.server";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||
requireAuthToken(request);
|
||||
|
||||
export default function AppLayout() {
|
||||
const auth = useWebAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (auth.status === "loading") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "inconsistent") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Your session could not be verified
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "unauthenticated") {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Zopu" },
|
||||
{ content: "Chat with Zopu", name: "description" },
|
||||
];
|
||||
|
||||
export default function MobileChatPage() {
|
||||
return <SliceOnePage />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Work chat | Zopu" },
|
||||
{
|
||||
content: "Chat with Zopu in the context of a selected work unit",
|
||||
name: "description",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MobileWorkChatPage() {
|
||||
return <MobileFlowPage screen="work-chat" />;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function MobileProductLayout() {
|
||||
return (
|
||||
<div className="min-h-svh bg-[#11110f]">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Active work | Zopu" },
|
||||
{
|
||||
content: "Browse active work and expand a work unit in place",
|
||||
name: "description",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MobileWorkListPage() {
|
||||
return <MobileFlowPage screen="work-list" />;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Work unit | Zopu" },
|
||||
{
|
||||
content: "Review the state, next milestone, and activity for a work unit",
|
||||
name: "description",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MobileWorkUnitPage() {
|
||||
return <MobileFlowPage screen="work-unit-detail" />;
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@code/ui/components/card";
|
||||
import { Checkbox } from "@code/ui/components/checkbox";
|
||||
import { Input } from "@code/ui/components/input";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
|
||||
export default function Todos() {
|
||||
const [newTodoText, setNewTodoText] = useState("");
|
||||
|
||||
const todos = useQuery(api.todos.getAll);
|
||||
const createTodo = useMutation(api.todos.create);
|
||||
const toggleTodo = useMutation(api.todos.toggle);
|
||||
const deleteTodo = useMutation(api.todos.deleteTodo);
|
||||
|
||||
const handleAddTodo = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const text = newTodoText.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
await createTodo({ text });
|
||||
setNewTodoText("");
|
||||
};
|
||||
|
||||
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
|
||||
void toggleTodo({ completed: !currentCompleted, id });
|
||||
};
|
||||
|
||||
const handleDeleteTodo = (id: Id<"todos">) => {
|
||||
void deleteTodo({ id });
|
||||
};
|
||||
|
||||
let todoContent: ReactNode;
|
||||
if (todos === undefined) {
|
||||
todoContent = (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
} else if (todos.length === 0) {
|
||||
todoContent = (
|
||||
<p className="py-4 text-center">No todos yet. Add one above!</p>
|
||||
);
|
||||
} else {
|
||||
todoContent = (
|
||||
<ul className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<li
|
||||
className="flex items-center justify-between rounded-md border p-2"
|
||||
key={todo._id}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={todo.completed}
|
||||
id={`todo-${todo._id}`}
|
||||
onCheckedChange={() =>
|
||||
handleToggleTodo(todo._id, todo.completed)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className={
|
||||
todo.completed
|
||||
? "text-muted-foreground line-through"
|
||||
: undefined
|
||||
}
|
||||
htmlFor={`todo-${todo._id}`}
|
||||
>
|
||||
{todo.text}
|
||||
</label>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Delete todo"
|
||||
onClick={() => handleDeleteTodo(todo._id)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md py-10">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Todo List</CardTitle>
|
||||
<CardDescription>Manage your tasks efficiently</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="mb-6 flex items-center space-x-2"
|
||||
onSubmit={handleAddTodo}
|
||||
>
|
||||
<Input
|
||||
onChange={(event) => setNewTodoText(event.target.value)}
|
||||
placeholder="Add a new task..."
|
||||
value={newTodoText}
|
||||
/>
|
||||
<Button disabled={!newTodoText.trim()} type="submit">
|
||||
Add
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{todoContent}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
import { redirectAuthenticated } from "@/lib/auth.server";
|
||||
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||
redirectAuthenticated(request);
|
||||
|
||||
export default function AuthLayout() {
|
||||
const auth = useWebAuth();
|
||||
|
||||
if (auth.status === "loading") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "authenticated") {
|
||||
return <Navigate replace to="/" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoginForm } from "@code/auth/web";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
|
||||
|
||||
export default function LoginRoute() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
|
||||
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
|
||||
return (
|
||||
<LoginForm
|
||||
onSuccess={() =>
|
||||
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { StandaloneChatPage } from "@/components/standalone-chat/standalone-chat-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{
|
||||
content: "width=device-width, initial-scale=1, viewport-fit=cover",
|
||||
name: "viewport",
|
||||
},
|
||||
{ content: "Chat with Zopu", name: "description" },
|
||||
{ title: "Zopu Chat" },
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return <StandaloneChatPage />;
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import { defineConfig } from "vite-plus";
|
||||
|
||||
export default defineConfig({
|
||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
dedupe: ["convex", "react", "react-dom"],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user