Convex-only Slice 1: clients talk to Convex, Flue is a private worker

Normalize the topology so web/desktop/mobile clients communicate only with
Convex. Convex admits durable commands, dispatches private Flue turns through
FLUE_URL, and stores the product-facing result before clients observe it.

- Normalized relational schema: organizations, projects, conversations/turns/
  messages/attachments, signals/sources/constraints, works/events/attachments.
- Conversation turns queued in Convex; the agent runAction dispatches to Flue
  and persists assistant response, signals, and proposed work back into Convex.
- Browser chat moved off the Flue transport: use-chat-agent now reads reactive
  Convex rows and sends through conversationMessages.send.
- Fixed signed-in blank screen: gate all product queries on useConvexAuth
  (isAuthenticated && !isRefreshing) plus personal-org bootstrap.
- Pinned react/react-dom to 19.2.8 via catalog to fix SSR dual-React crash.
- Removed ~16.6k net lines: daemon, AgentOS/Orb, project issues/runs/artifacts,
  todos, browser Flue transport, mobile execution views, smoke scripts.

Verified end-to-end on cheaptricks: connect repo, send actionable message,
Flue turn completes, Signal + proposed Work created with exact provenance,
persists across refresh.
This commit is contained in:
-Puter
2026-07-27 21:32:17 +05:30
parent 4c741fbe06
commit a5414d83bd
127 changed files with 1283 additions and 17784 deletions

View File

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

View File

@@ -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:");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("");

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,205 +0,0 @@
import { useConvexAccessToken } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { useState } from "react";
import {
buildProjectLoopView,
summarizeProjectIssues,
} from "@/lib/projects/project-evidence";
import { requestProjectIssue } from "@/lib/projects/project-requests";
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => {
const resolveAccessToken = useConvexAccessToken();
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 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 accessToken = await resolveAccessToken();
if (!accessToken) {
throw new Error("Authentication required");
}
const outcome = await requestProjectIssue({
accessToken,
request: {
body: nextBody,
kind: "explicit",
projectId: activeProjectId,
title: nextTitle,
},
});
setSelectedIssueId(outcome.issueId as unknown as Id<"projectIssues">);
setIssueTitle("");
setIssueBody("");
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
setPendingAction(`signal:${signalId}`);
setError(null);
try {
const accessToken = await resolveAccessToken();
if (!accessToken) {
throw new Error("Authentication required");
}
const outcome = await requestProjectIssue({
accessToken,
request: { kind: "signal", signalId },
});
setSelectedIssueId(outcome.issueId as unknown as Id<"projectIssues">);
} catch (caughtError) {
setError(errorMessage(caughtError));
} finally {
setPendingAction(null);
}
};
const startIssue = async (issueId: Id<"projectIssues">) => {
const actionKey = `issue:${issueId}`;
setPendingAction(actionKey);
setError(null);
try {
const accessToken = await resolveAccessToken();
if (!accessToken) {
throw new Error("Authentication required");
}
await requestProjectIssue({
accessToken,
request: { issueId, kind: "existing" },
});
} catch (caughtError) {
setError(errorMessage(caughtError));
} 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);
};
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;
};

View File

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

View File

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

View File

@@ -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",

View 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" },
]);
});
});

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

View File

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

View File

@@ -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") {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { env } from "@code/env/web";
import { ProjectIssueRequestResult } from "@code/primitives/project-issue";
import type { ProjectIssueRequestResult as ProjectIssueRequestResultValue } from "@code/primitives/project-issue";
import { Schema } from "effect";
const projectRequestsUrl = new URL(
"project-requests",
`${env.VITE_FLUE_URL.replace(/\/+$/u, "")}/`
);
interface RequestProjectIssueOptions {
readonly accessToken: string;
readonly request:
| {
readonly issueId: Id<"projectIssues">;
readonly kind: "existing";
}
| {
readonly body: string;
readonly kind: "explicit";
readonly projectId: Id<"projects">;
readonly title: string;
}
| {
readonly kind: "signal";
readonly signalId: Id<"signals">;
};
}
const responseError = async (response: Response): Promise<Error> => {
const payload: unknown = await response.json().catch(() => null);
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof payload.error === "string"
) {
return new Error(payload.error);
}
return new Error(`Project request failed (${response.status})`);
};
export const requestProjectIssue = async ({
accessToken,
request,
}: RequestProjectIssueOptions): Promise<ProjectIssueRequestResultValue> => {
const response = await fetch(projectRequestsUrl, {
body: JSON.stringify(request),
headers: {
authorization: `Bearer ${accessToken}`,
"content-type": "application/json",
},
method: "POST",
});
if (!response.ok) {
throw await responseError(response);
}
return Schema.decodeUnknownSync(ProjectIssueRequestResult)(
await response.json()
);
};

View File

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

View File

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

View File

@@ -1,12 +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 { createFlueClient } from "@flue/sdk";
import "./index.css";
import { LoaderCircle } from "lucide-react";
import { useMemo } from "react";
import {
isRouteErrorResponse,
Links,
@@ -20,7 +16,6 @@ import {
import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider";
import { loadAuthToken } from "./lib/auth.server";
import { createFlueFetch } from "./lib/flue-transport";
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
@@ -37,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>
@@ -109,19 +76,17 @@ export const HydrateFallback = () => (
const App = ({ loaderData }: Route.ComponentProps) => (
<WebAuthProvider initialToken={loaderData.token}>
<AuthenticatedFlueProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<RouteProgress />
<Outlet />
<Toaster richColors />
</ThemeProvider>
</AuthenticatedFlueProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
forcedTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<RouteProgress />
<Outlet />
<Toaster richColors />
</ThemeProvider>
</WebAuthProvider>
);

View File

@@ -7,14 +7,7 @@ export default [
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;

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
import { Outlet } from "react-router";
export default function MobileProductLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

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

View File

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

View File

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

View File

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