Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20)

This commit was merged in pull request #20.
This commit is contained in:
2026-07-27 16:03:36 +00:00
parent cc47007fa9
commit cb7484912c
141 changed files with 1547 additions and 17812 deletions
+64
View 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 -9
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==");
});
});
-19
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",
});
+1 -12
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",
@@ -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
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")
),
});
+2 -37
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(
+9 -18
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") {
+35 -15
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";
-152
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);
});
});
-120
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,
}
);
};
};
@@ -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]
+7 -11
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(