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:
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useChatAgent } from "./use-chat-agent";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agent: {
|
||||
error: undefined,
|
||||
historyReady: true,
|
||||
messages: [],
|
||||
sendMessage: vi.fn(() => Promise.resolve()),
|
||||
status: "idle" as const,
|
||||
},
|
||||
organization: {} as { error?: Error; organizationId?: string },
|
||||
useFlueAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@flue/react", () => ({
|
||||
useFlueAgent: mocks.useFlueAgent,
|
||||
}));
|
||||
vi.mock("@/hooks/use-personal-organization", () => ({
|
||||
usePersonalOrganization: () => mocks.organization,
|
||||
}));
|
||||
|
||||
describe("useChatAgent", () => {
|
||||
beforeEach(() => {
|
||||
mocks.organization = {};
|
||||
mocks.agent.sendMessage.mockClear();
|
||||
mocks.useFlueAgent.mockReset();
|
||||
mocks.useFlueAgent.mockReturnValue(mocks.agent);
|
||||
});
|
||||
|
||||
test("does not use the agent before organization bootstrap completes", async () => {
|
||||
const chat = useChatAgent();
|
||||
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: undefined,
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
expect(chat.status).toBe("connecting");
|
||||
await expect(chat.sendMessage("too early")).rejects.toThrow(
|
||||
"Personal organization is still being prepared"
|
||||
);
|
||||
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses the ensured organization id for the agent session", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
|
||||
const chat = useChatAgent();
|
||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
||||
id: "org-a",
|
||||
live: "sse",
|
||||
name: "zopu",
|
||||
});
|
||||
|
||||
await chat.sendMessage("ready");
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready", undefined);
|
||||
});
|
||||
|
||||
test("forwards image attachments through to the Flue agent", async () => {
|
||||
mocks.organization = { organizationId: "org-a" };
|
||||
const images = [
|
||||
{
|
||||
data: "abc",
|
||||
filename: "a.png",
|
||||
mimeType: "image/png",
|
||||
type: "image" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const chat = useChatAgent();
|
||||
await chat.sendMessage("describe this", { images });
|
||||
|
||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("describe this", {
|
||||
images,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,106 @@
|
||||
import type { SendMessageOptions } from "@flue/react";
|
||||
import { useFlueAgent } from "@flue/react";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { projectConversation } from "@/lib/chat/conversation";
|
||||
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
|
||||
|
||||
const requestId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const uploadImage = async (
|
||||
file: File,
|
||||
generateUploadUrl: () => Promise<string>
|
||||
) => {
|
||||
const response = await fetch(await generateUploadUrl(), {
|
||||
body: file,
|
||||
headers: { "content-type": file.type },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image upload failed (${response.status})`);
|
||||
}
|
||||
const payload: unknown = await response.json();
|
||||
if (
|
||||
typeof payload !== "object" ||
|
||||
payload === null ||
|
||||
!("storageId" in payload) ||
|
||||
typeof payload.storageId !== "string"
|
||||
) {
|
||||
throw new Error("Image upload returned an invalid response");
|
||||
}
|
||||
return {
|
||||
filename: file.name || undefined,
|
||||
mimeType: file.type,
|
||||
storageId: payload.storageId as Id<"_storage">,
|
||||
};
|
||||
};
|
||||
|
||||
export const useOrganizationChatAgent = (
|
||||
organization: PersonalOrganizationState
|
||||
): ChatAgentState => {
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization.organizationId,
|
||||
});
|
||||
const { organizationId } = organization;
|
||||
const rows = useQuery(
|
||||
api.conversationMessages.listForCurrentOrganization,
|
||||
organizationId ? { organizationId } : "skip"
|
||||
);
|
||||
const send = useMutation(api.conversationMessages.send);
|
||||
const generateUploadUrl = useMutation(
|
||||
api.conversationMessages.generateUploadUrl
|
||||
);
|
||||
const [sendError, setSendError] = useState<Error>();
|
||||
|
||||
const projected = projectConversation(rows ?? []);
|
||||
|
||||
let status: AgentStatus = projected.pending ? "submitted" : "idle";
|
||||
if (!organizationId || rows === undefined) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
} else if (projected.failedError || sendError) {
|
||||
status = "error";
|
||||
}
|
||||
|
||||
const sendMessage = async (
|
||||
message: string,
|
||||
options?: SendMessageOptions
|
||||
options?: { readonly images?: readonly File[] }
|
||||
): Promise<void> => {
|
||||
if (!organization.organizationId) {
|
||||
if (!organizationId) {
|
||||
throw (
|
||||
organization.error ??
|
||||
new Error("Personal organization is still being prepared")
|
||||
);
|
||||
}
|
||||
await agent.sendMessage(message, options);
|
||||
setSendError(undefined);
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
(options?.images ?? []).map((file) =>
|
||||
uploadImage(file, generateUploadUrl)
|
||||
)
|
||||
);
|
||||
await send({
|
||||
clientRequestId: requestId(),
|
||||
images,
|
||||
organizationId,
|
||||
rawText: message,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
setSendError(normalized);
|
||||
throw normalized;
|
||||
}
|
||||
};
|
||||
|
||||
let { status } = agent;
|
||||
if (!organization.organizationId) {
|
||||
status = organization.error ? "error" : "connecting";
|
||||
}
|
||||
|
||||
return {
|
||||
error: organization.error ?? agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
error:
|
||||
organization.error ??
|
||||
sendError ??
|
||||
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||
historyReady: rows !== undefined,
|
||||
messages: projected.messages,
|
||||
sendMessage,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatAgent = (): ChatAgentState =>
|
||||
useOrganizationChatAgent(usePersonalOrganization());
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
|
||||
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||
import { generateBrowserRequestId } from "@/lib/flue-transport";
|
||||
|
||||
const generateImageId = (): string =>
|
||||
globalThis.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
export const useChatImages = () => {
|
||||
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||
@@ -40,7 +43,7 @@ export const useChatImages = () => {
|
||||
previewUrls.current.add(previewUrl);
|
||||
next.push({
|
||||
file,
|
||||
id: generateBrowserRequestId(),
|
||||
id: generateImageId(),
|
||||
previewUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,15 +3,20 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
|
||||
const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
export const useSliceOne = () => {
|
||||
const projects = useQuery(api.projects.list);
|
||||
const organization = usePersonalOrganization();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const agent = useChatAgent();
|
||||
const agent = useOrganizationChatAgent(organization);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import type {
|
||||
MobileAssistantMessageView,
|
||||
MobileAssistantView,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
||||
import { STATUS_COPY } from "@/lib/chat/constants";
|
||||
import { getMessageText } from "@/lib/chat/transforms";
|
||||
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
|
||||
|
||||
const WORK_UNIT_SCOPE_PATTERN =
|
||||
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
|
||||
|
||||
const toAssistantMessages = (
|
||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
|
||||
selectedWorkUnitId?: string
|
||||
): MobileAssistantMessageView[] => {
|
||||
const result: MobileAssistantMessageView[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant" && message.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const rawText = getMessageText(message);
|
||||
if (!rawText) {
|
||||
continue;
|
||||
}
|
||||
if (!selectedWorkUnitId) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
continue;
|
||||
}
|
||||
if (message.role === "user") {
|
||||
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
|
||||
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
|
||||
result.length = 0;
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: match.groups.message?.trim() ?? rawText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (result.length > 0) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getStatusTone = (
|
||||
status: ReturnType<typeof useOrganizationChatAgent>["status"]
|
||||
): MobileAssistantView["statusTone"] => {
|
||||
if (status === "error") {
|
||||
return "red";
|
||||
}
|
||||
return status === "connecting" ? "amber" : "green";
|
||||
};
|
||||
|
||||
interface UseMobileProjectWorkspaceOptions {
|
||||
readonly selectedWorkUnitId?: string;
|
||||
}
|
||||
|
||||
export const useMobileProjectWorkspace = ({
|
||||
selectedWorkUnitId,
|
||||
}: UseMobileProjectWorkspaceOptions) => {
|
||||
const organization = usePersonalOrganization();
|
||||
const projectWorkspace = useProjectWorkspace();
|
||||
const chatAgent = useOrganizationChatAgent(organization);
|
||||
const signals = useQuery(
|
||||
api.signals.list,
|
||||
organization.organizationId
|
||||
? { organizationId: organization.organizationId }
|
||||
: "skip"
|
||||
);
|
||||
const selectedIssue = projectWorkspace.issues?.find(
|
||||
(issue) => String(issue._id) === selectedWorkUnitId
|
||||
);
|
||||
const sendMessage = (message: string) => {
|
||||
if (!selectedIssue) {
|
||||
return chatAgent.sendMessage(message);
|
||||
}
|
||||
return chatAgent.sendMessage(
|
||||
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
|
||||
);
|
||||
};
|
||||
const assistant: MobileAssistantView = {
|
||||
isBusy:
|
||||
chatAgent.status === "connecting" ||
|
||||
chatAgent.status === "streaming" ||
|
||||
chatAgent.status === "submitted",
|
||||
messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
|
||||
statusLabel: STATUS_COPY[chatAgent.status],
|
||||
statusTone: getStatusTone(chatAgent.status),
|
||||
};
|
||||
|
||||
return {
|
||||
data: buildMobileWorkspaceView({
|
||||
artifacts: projectWorkspace.artifacts,
|
||||
assistant,
|
||||
events: projectWorkspace.events,
|
||||
issues: projectWorkspace.issues,
|
||||
organizationLabel: organization.organizationName,
|
||||
projects: projectWorkspace.projects,
|
||||
selectedProject: projectWorkspace.selectedProject,
|
||||
selectedWorkUnitId,
|
||||
signals,
|
||||
}),
|
||||
error:
|
||||
organization.error ??
|
||||
chatAgent.error ??
|
||||
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
|
||||
organization,
|
||||
projectWorkspace,
|
||||
raiseIssue: projectWorkspace.raiseIssue,
|
||||
raiseIssueFromSignal: (signalId: string) =>
|
||||
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
|
||||
sendMessage,
|
||||
startWorkUnit: projectWorkspace.startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const useMobileWorkStack = (
|
||||
workUnits: readonly MobileWorkUnitView[]
|
||||
) => {
|
||||
const [order, setOrder] = useState<readonly string[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const workUnitsById = useMemo(
|
||||
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
|
||||
[workUnits]
|
||||
);
|
||||
const availableIds = useMemo(
|
||||
() => new Set(workUnitsById.keys()),
|
||||
[workUnitsById]
|
||||
);
|
||||
const visibleWorkUnits = useMemo(() => {
|
||||
const retainedOrder = order.filter(
|
||||
(workUnitId) =>
|
||||
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
const retainedIds = new Set(retainedOrder);
|
||||
const addedIds = workUnits
|
||||
.map((workUnit) => workUnit.id)
|
||||
.filter(
|
||||
(workUnitId) =>
|
||||
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
|
||||
const workUnit = workUnitsById.get(workUnitId);
|
||||
return workUnit ? [workUnit] : [];
|
||||
});
|
||||
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
|
||||
|
||||
const sendToBack = useCallback(
|
||||
(workUnitId: string) => {
|
||||
if (visibleWorkUnits[0]?.id !== workUnitId) {
|
||||
return;
|
||||
}
|
||||
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
|
||||
},
|
||||
[visibleWorkUnits]
|
||||
);
|
||||
|
||||
const markChecked = useCallback((workUnitId: string) => {
|
||||
setCheckedIds((current) => new Set(current).add(workUnitId));
|
||||
}, []);
|
||||
|
||||
const restoreChecked = useCallback(() => {
|
||||
setCheckedIds(new Set());
|
||||
setOrder([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
checkedCount: [...checkedIds].filter((workUnitId) =>
|
||||
availableIds.has(workUnitId)
|
||||
).length,
|
||||
markChecked,
|
||||
restoreChecked,
|
||||
sendToBack,
|
||||
visibleWorkUnits,
|
||||
} as const;
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UseMobileWorkspaceOptions {
|
||||
readonly initialExpanded?: boolean;
|
||||
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
|
||||
readonly onSend: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : "Message could not be sent";
|
||||
|
||||
export const useMobileWorkspace = ({
|
||||
initialExpanded = false,
|
||||
onCreateIssue,
|
||||
onSend,
|
||||
}: UseMobileWorkspaceOptions) => {
|
||||
const [composerValue, setComposerValue] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [expanded, setExpanded] = useState(initialExpanded);
|
||||
const [projectManagerOpen, setProjectManagerOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string>();
|
||||
|
||||
const handleComposerChange = (value: string) => {
|
||||
setComposerValue(value);
|
||||
setStatusMessage(undefined);
|
||||
};
|
||||
|
||||
const submitMessage = async (message: string) => {
|
||||
const trimmedMessage = message.trim();
|
||||
if (!trimmedMessage) {
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Sending to Zopu…");
|
||||
try {
|
||||
await onSend(trimmedMessage);
|
||||
setComposerValue("");
|
||||
setStatusMessage("Sent to Zopu");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const sendCurrentMessage = async () => {
|
||||
try {
|
||||
await submitMessage(composerValue);
|
||||
} catch {
|
||||
// submitMessage already exposes the failure through statusMessage.
|
||||
}
|
||||
};
|
||||
void sendCurrentMessage();
|
||||
};
|
||||
|
||||
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const title = issueTitle.trim();
|
||||
const body = issueBody.trim();
|
||||
if (!title || !body) {
|
||||
return;
|
||||
}
|
||||
setStatusMessage("Creating project work…");
|
||||
const createIssue = async () => {
|
||||
try {
|
||||
await onCreateIssue(title, body);
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
setStatusMessage("Project work created");
|
||||
} catch (error) {
|
||||
setStatusMessage(errorMessage(error));
|
||||
}
|
||||
};
|
||||
void createIssue();
|
||||
};
|
||||
|
||||
return {
|
||||
composerValue,
|
||||
expanded,
|
||||
handleComposerChange,
|
||||
handleComposerMessageSubmit: submitMessage,
|
||||
handleComposerSubmit,
|
||||
handleCreateIssueSubmit,
|
||||
issueBody,
|
||||
issueTitle,
|
||||
projectManagerOpen,
|
||||
setExpanded,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setProjectManagerOpen,
|
||||
setSettingsOpen,
|
||||
settingsOpen,
|
||||
statusMessage,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useConvexAuth, useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const auth = useWebAuth();
|
||||
const { isAuthenticated, isRefreshing } = useConvexAuth();
|
||||
const convexReady = isAuthenticated && !isRefreshing;
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
if (!convexReady || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
}, [convexReady, ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!userId || state?.userId !== userId) {
|
||||
if (!convexReady || !userId || state?.userId !== userId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useFlueClient } from "@flue/react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
buildProjectLoopView,
|
||||
summarizeProjectIssues,
|
||||
} from "@/lib/projects/project-evidence";
|
||||
|
||||
const errorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export const useProjectWorkspace = () => {
|
||||
const flueClient = useFlueClient();
|
||||
const projects = useQuery(api.projects.list);
|
||||
const [selectedProjectId, setSelectedProjectId] =
|
||||
useState<Id<"projects"> | null>(null);
|
||||
const [repository, setRepository] = useState("");
|
||||
const [issueTitle, setIssueTitle] = useState("");
|
||||
const [issueBody, setIssueBody] = useState("");
|
||||
const [selectedIssueId, setSelectedIssueId] =
|
||||
useState<Id<"projectIssues"> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||
const createIssue = useMutation(api.projectIssues.create);
|
||||
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
|
||||
const beginIssue = useMutation(api.projectIssues.begin);
|
||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
||||
const activeProjectId =
|
||||
selectedProjectId ??
|
||||
(projects?.[0]?.id as unknown as Id<"projects">) ??
|
||||
null;
|
||||
const artifacts = useQuery(
|
||||
api.projectArtifacts.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const issues = useQuery(
|
||||
api.projectIssues.list,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const events = useQuery(
|
||||
api.projectIssues.events,
|
||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||
);
|
||||
const pullRequest = events
|
||||
?.map((event) => event.data)
|
||||
.find(
|
||||
(
|
||||
data
|
||||
): data is {
|
||||
pullRequest: {
|
||||
baseBranch: string;
|
||||
branch: string;
|
||||
number: number;
|
||||
status: "open" | "closed" | "merged";
|
||||
url: string;
|
||||
};
|
||||
} =>
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"pullRequest" in data &&
|
||||
typeof data.pullRequest === "object" &&
|
||||
data.pullRequest !== null
|
||||
)?.pullRequest;
|
||||
|
||||
const connectRepository = async () => {
|
||||
setPendingAction("connect");
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await importPublicGit({ repositoryUrl: repository });
|
||||
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
|
||||
setRepository("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssue = async (input?: {
|
||||
readonly body?: string;
|
||||
readonly title?: string;
|
||||
}) => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = input?.title ?? issueTitle;
|
||||
const nextBody = input?.body ?? issueBody;
|
||||
setPendingAction("issue");
|
||||
setError(null);
|
||||
try {
|
||||
const issueId = await createIssue({
|
||||
body: nextBody,
|
||||
projectId: activeProjectId,
|
||||
title: nextTitle,
|
||||
});
|
||||
setSelectedIssueId(issueId);
|
||||
setIssueTitle("");
|
||||
setIssueBody("");
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
|
||||
setPendingAction(`signal:${signalId}`);
|
||||
setError(null);
|
||||
try {
|
||||
const outcome = await createIssueFromSignal({ signalId });
|
||||
setSelectedIssueId(outcome.issueId);
|
||||
} catch (caughtError) {
|
||||
setError(errorMessage(caughtError));
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startIssue = async (
|
||||
issueId: Id<"projectIssues">,
|
||||
issueNumber: number,
|
||||
title: string
|
||||
) => {
|
||||
const actionKey = `issue:${issueId}`;
|
||||
setPendingAction(actionKey);
|
||||
setError(null);
|
||||
try {
|
||||
await beginIssue({ issueId });
|
||||
await flueClient.agents.send("project-manager", String(issueId), {
|
||||
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
|
||||
});
|
||||
} catch (caughtError) {
|
||||
const message = errorMessage(caughtError);
|
||||
await markDispatchFailed({ error: message, issueId });
|
||||
setError(message);
|
||||
} finally {
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
|
||||
const issue = issues?.find((candidate) => candidate._id === issueId);
|
||||
if (!issue) {
|
||||
setError("That work unit is no longer available");
|
||||
return;
|
||||
}
|
||||
await startIssue(issue._id, issue.number, issue.title);
|
||||
};
|
||||
const selectedProject =
|
||||
projects?.find(
|
||||
(project) => project.id === (activeProjectId as unknown as string)
|
||||
) ?? null;
|
||||
const selectedIssue =
|
||||
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
|
||||
const projectLoop = buildProjectLoopView({
|
||||
artifacts,
|
||||
issue: selectedIssue,
|
||||
source: selectedProject?.sources[0],
|
||||
});
|
||||
const issueSummary = summarizeProjectIssues(issues);
|
||||
|
||||
return {
|
||||
artifacts,
|
||||
connectRepository,
|
||||
error,
|
||||
events,
|
||||
issueBody,
|
||||
issueSummary,
|
||||
issueTitle,
|
||||
issues,
|
||||
pendingAction,
|
||||
projectLoop,
|
||||
projects,
|
||||
pullRequest,
|
||||
raiseIssue,
|
||||
raiseIssueFromSignal,
|
||||
repository,
|
||||
selectedIssue,
|
||||
selectedIssueId,
|
||||
selectedProject,
|
||||
selectedProjectId: activeProjectId,
|
||||
setIssueBody,
|
||||
setIssueTitle,
|
||||
setRepository,
|
||||
setSelectedIssueId,
|
||||
setSelectedProjectId,
|
||||
startIssue,
|
||||
startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
Reference in New Issue
Block a user