From 48200a11dfd44153c5a348c112f642803c3b74a0 Mon Sep 17 00:00:00 2001 From: sai karthik Date: Sat, 25 Jul 2026 17:49:11 +0530 Subject: [PATCH] Integrate mobile chat workspace and OpenRouter agent flow --- .../mobile-workspace/mobile-flow-page.tsx | 20 +- .../src/hooks/use-mobile-project-workspace.ts | 5 +- apps/web/src/hooks/use-mobile-workspace.ts | 38 +- apps/web/src/lib/chat/constants.ts | 3 +- apps/web/src/lib/flue-transport.test.ts | 31 + apps/web/src/lib/flue-transport.ts | 35 +- .../build-mobile-workspace-view.ts | 15 +- apps/web/src/routes/app/mobile/page.tsx | 16 +- bun.lock | 1 - packages/agents/src/agents/zopu.ts | 1 - packages/agents/src/app.ts | 11 +- packages/agents/src/auth.ts | 1 + packages/agents/src/tools/signals.ts | 57 +- packages/backend/convex/_generated/api.d.ts | 4 + .../backend/convex/_generated/server.d.ts | 2 + packages/backend/convex/projects.ts | 34 +- packages/backend/convex/publicGit.test.ts | 58 ++ packages/backend/convex/publicGit.ts | 97 ++- packages/backend/convex/signalRouting.test.ts | 17 +- packages/backend/convex/signalRouting.ts | 57 +- packages/env/src/agent.ts | 1 + .../components/ai-elements/prompt-input.tsx | 161 ++++ .../components/mobile-workspace-screens.tsx | 686 ++++++++++++++---- .../src/components/mobile-workspace/models.ts | 2 + 24 files changed, 1142 insertions(+), 211 deletions(-) create mode 100644 packages/backend/convex/publicGit.test.ts create mode 100644 packages/ui/src/components/ai-elements/prompt-input.tsx diff --git a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx index 769603a..a654ab4 100644 --- a/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx +++ b/apps/web/src/components/mobile-workspace/mobile-flow-page.tsx @@ -6,10 +6,12 @@ import { MobileWorkListScreen, 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 { useMobileWorkspace } from "@/hooks/use-mobile-workspace"; +import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants"; export type MobileFlowScreen = | "assistant-chat" @@ -39,7 +41,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { const targetId = selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id; if (!targetId) { - navigate("/dashboard"); + workspace.setProjectManagerOpen(true); return; } if (screen === "work-list" && !workspace.expanded) { @@ -54,8 +56,11 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { createIssueBody: workspace.issueBody, createIssueTitle: workspace.issueTitle, data: projectWorkspace.data, + modelId: MODEL_ID, + modelLabel: MODEL_LABEL, onBack: () => navigate(workPath), onComposerChange: workspace.handleComposerChange, + onComposerMessageSubmit: workspace.handleComposerMessageSubmit, onComposerSubmit: workspace.handleComposerSubmit, onCreateBodyChange: workspace.setIssueBody, onCreateIssue: workspace.handleCreateIssueSubmit, @@ -66,16 +71,24 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { ) : undefined, onCreateTitleChange: workspace.setIssueTitle, - onManageProjects: () => navigate("/dashboard"), + 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) => { + 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"> @@ -83,6 +96,9 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => { }, onViewWork: () => navigate(workPath), pendingAction: projectWorkspace.projectWorkspace.pendingAction, + projectManagerOpen: workspace.projectManagerOpen, + repositoryValue: projectWorkspace.projectWorkspace.repository, + settingsOpen: workspace.settingsOpen, statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message, }; diff --git a/apps/web/src/hooks/use-mobile-project-workspace.ts b/apps/web/src/hooks/use-mobile-project-workspace.ts index 9950134..81114b9 100644 --- a/apps/web/src/hooks/use-mobile-project-workspace.ts +++ b/apps/web/src/hooks/use-mobile-project-workspace.ts @@ -79,7 +79,10 @@ export const useMobileProjectWorkspace = ({ selectedWorkUnitId, signals, }), - error: organization.error ?? chatAgent.error, + error: + organization.error ?? + chatAgent.error ?? + (projectWorkspace.error ? new Error(projectWorkspace.error) : undefined), organization, projectWorkspace, raiseIssue: projectWorkspace.raiseIssue, diff --git a/apps/web/src/hooks/use-mobile-workspace.ts b/apps/web/src/hooks/use-mobile-workspace.ts index 75c7b8a..6132a0f 100644 --- a/apps/web/src/hooks/use-mobile-workspace.ts +++ b/apps/web/src/hooks/use-mobile-workspace.ts @@ -19,6 +19,8 @@ export const useMobileWorkspace = ({ 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(); const handleComposerChange = (value: string) => { @@ -26,23 +28,32 @@ export const useMobileWorkspace = ({ setStatusMessage(undefined); }; - const handleComposerSubmit = (event: FormEvent) => { - event.preventDefault(); - const message = composerValue.trim(); - if (!message) { + const submitMessage = async (message: string) => { + const trimmedMessage = message.trim(); + if (!trimmedMessage) { return; } setStatusMessage("Sending to Zopu…"); - const sendMessage = async () => { + try { + await onSend(trimmedMessage); + setComposerValue(""); + setStatusMessage("Sent to Zopu"); + } catch (error) { + setStatusMessage(errorMessage(error)); + throw error; + } + }; + + const handleComposerSubmit = (event: FormEvent) => { + event.preventDefault(); + const sendCurrentMessage = async () => { try { - await onSend(message); - setComposerValue(""); - setStatusMessage("Sent to Zopu"); - } catch (error) { - setStatusMessage(errorMessage(error)); + await submitMessage(composerValue); + } catch { + // submitMessage already exposes the failure through statusMessage. } }; - void sendMessage(); + void sendCurrentMessage(); }; const handleCreateIssueSubmit = (event: FormEvent) => { @@ -70,13 +81,18 @@ export const useMobileWorkspace = ({ composerValue, expanded, handleComposerChange, + handleComposerMessageSubmit: submitMessage, handleComposerSubmit, handleCreateIssueSubmit, issueBody, issueTitle, + projectManagerOpen, setExpanded, setIssueBody, setIssueTitle, + setProjectManagerOpen, + setSettingsOpen, + settingsOpen, statusMessage, }; }; diff --git a/apps/web/src/lib/chat/constants.ts b/apps/web/src/lib/chat/constants.ts index 38e1bb9..a360ee4 100644 --- a/apps/web/src/lib/chat/constants.ts +++ b/apps/web/src/lib/chat/constants.ts @@ -10,7 +10,8 @@ export const CHAT_AGENT = { name: "zopu", } as const; -export const MODEL_LABEL = "GLM-5.2"; +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"], diff --git a/apps/web/src/lib/flue-transport.test.ts b/apps/web/src/lib/flue-transport.test.ts index 93b758f..bb378f7 100644 --- a/apps/web/src/lib/flue-transport.test.ts +++ b/apps/web/src/lib/flue-transport.test.ts @@ -4,6 +4,12 @@ import { describe, expect, test } from "vitest"; import { createFlueFetch } 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 () => { @@ -87,6 +93,31 @@ describe("createFlueFetch", () => { ]); }); + 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("history and stream observation requests remain untagged", async () => { const capturedHeaders: Headers[] = []; const fetchImpl: typeof fetch = (_input, init) => { diff --git a/apps/web/src/lib/flue-transport.ts b/apps/web/src/lib/flue-transport.ts index ac838a1..c5431c3 100644 --- a/apps/web/src/lib/flue-transport.ts +++ b/apps/web/src/lib/flue-transport.ts @@ -4,6 +4,31 @@ interface FlueFetchOptions { readonly generateRequestId?: () => string; } +const addRequestContext = async ( + response: Response, + method: string, + requestUrl: URL +): Promise => { + 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. * @@ -15,7 +40,7 @@ interface FlueFetchOptions { export const createFlueFetch = ({ baseUrl, fetchImpl = fetch, - generateRequestId = crypto.randomUUID, + generateRequestId = () => crypto.randomUUID(), }: FlueFetchOptions): typeof fetch => { const basePath = baseUrl.pathname.replace(/\/+$/u, ""); @@ -47,11 +72,9 @@ export const createFlueFetch = ({ requestInit = { ...init, headers }; } - const response = await fetchImpl(input, requestInit); - if ( - !response.ok || - !response.headers.get("content-type")?.includes("application/json") - ) { + 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; } diff --git a/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts b/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts index d5f8d52..81d526a 100644 --- a/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts +++ b/apps/web/src/lib/mobile-workspace/build-mobile-workspace-view.ts @@ -125,11 +125,16 @@ const relevantSignalsForProject = ( const makeProjectViews = ( projects: readonly ProjectView[] | undefined ): readonly MobileProjectView[] => - projects?.map((project) => ({ - connected: project.sources.length > 0, - id: project.id, - name: project.name, - })) ?? []; + 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, diff --git a/apps/web/src/routes/app/mobile/page.tsx b/apps/web/src/routes/app/mobile/page.tsx index 4e058d8..468f589 100644 --- a/apps/web/src/routes/app/mobile/page.tsx +++ b/apps/web/src/routes/app/mobile/page.tsx @@ -1,15 +1,5 @@ -import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page"; +import { Navigate } from "react-router"; -import type { Route } from "./+types/page"; - -export const meta = (_args: Route.MetaArgs) => [ - { title: "Daily focus | Zopu" }, - { - content: "Supervise active work and open the next action", - name: "description", - }, -]; - -export default function MobileHomePage() { - return ; +export default function MobileLandingRedirect() { + return ; } diff --git a/bun.lock b/bun.lock index cff17a7..f8063d6 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "code", diff --git a/packages/agents/src/agents/zopu.ts b/packages/agents/src/agents/zopu.ts index 004bdf7..a670408 100644 --- a/packages/agents/src/agents/zopu.ts +++ b/packages/agents/src/agents/zopu.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { parseAgentEnv } from "@code/env/agent"; import { defineAgent } from "@flue/runtime"; - // import { agentos } from "../sandboxes/agentos"; import { local } from "@flue/runtime/node"; diff --git a/packages/agents/src/app.ts b/packages/agents/src/app.ts index 8ecc694..4d3aabc 100644 --- a/packages/agents/src/app.ts +++ b/packages/agents/src/app.ts @@ -9,18 +9,25 @@ const agentEnv = parseAgentEnv(process.env); registerProvider(agentEnv.AGENT_MODEL_PROVIDER, { api: agentEnv.AGENT_MODEL_API, - apiKey: agentEnv.AGENT_MODEL_API_KEY, + apiKey: + agentEnv.AGENT_MODEL_PROVIDER === "openrouter" + ? (agentEnv.OPENROUTER_API_KEY ?? agentEnv.AGENT_MODEL_API_KEY) + : agentEnv.AGENT_MODEL_API_KEY, baseUrl: agentEnv.AGENT_MODEL_BASE_URL, models: { [agentEnv.AGENT_MODEL_NAME]: { contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW, maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS, }, + "openai/gpt-oss-20b:free": { + contextWindow: 131_072, + maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS, + }, }, }); const app = new Hono(); app.post("/project-requests", projectRequestRoute); -app.route("/", flue()); +app.route("/", flue() as unknown as Hono); export default app; diff --git a/packages/agents/src/auth.ts b/packages/agents/src/auth.ts index 28df062..0c6f1f6 100644 --- a/packages/agents/src/auth.ts +++ b/packages/agents/src/auth.ts @@ -317,4 +317,5 @@ export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => { clientRequestId, organizationId: organization._id, }); + return c.res; }; diff --git a/packages/agents/src/tools/signals.ts b/packages/agents/src/tools/signals.ts index 0b8a5bd..37d2640 100644 --- a/packages/agents/src/tools/signals.ts +++ b/packages/agents/src/tools/signals.ts @@ -1,7 +1,9 @@ import { parseAgentEnv } from "@code/env/agent"; -import { defineTool } from "@flue/runtime"; +import { ProjectIssueDispatchInput } from "@code/primitives/project-issue"; +import { defineTool, dispatch } from "@flue/runtime"; import { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; +import { Schema } from "effect"; import * as v from "valibot"; // --------------------------------------------------------------------------- @@ -103,9 +105,24 @@ const beginIssueRef = makeFunctionReference< readonly issueId: string; readonly token: string; }, - "queued" | "working" + { + readonly dispatchRequired: boolean; + readonly projectId: string; + readonly status: "queued" | "working"; + } >("signalRouting:beginIssue"); +const markDispatchFailedRef = makeFunctionReference< + "mutation", + { + readonly error: string; + readonly issueId: string; + readonly organizationId: string; + readonly token: string; + }, + null +>("signalRouting:markDispatchFailed"); + const getProjectContextRef = makeFunctionReference< "query", { @@ -306,11 +323,45 @@ export const createSignalRoutingTools = ( }), name: "begin_issue", async run({ input }) { - return await client.mutation(beginIssueRef, { + const outcome = await client.mutation(beginIssueRef, { issueId: input.issueId, organizationId, token, }); + const dispatchInput = Schema.decodeUnknownSync( + ProjectIssueDispatchInput + )({ + issueId: input.issueId, + kind: "project.issue.started", + projectId: outcome.projectId, + }); + if (!outcome.dispatchRequired) { + return { + acceptedAt: "", + dispatchId: "", + status: outcome.status, + }; + } + try { + const receipt = await dispatch({ + agent: "project-manager", + id: input.issueId, + input: dispatchInput, + }); + return { + acceptedAt: receipt.acceptedAt, + dispatchId: receipt.dispatchId, + status: outcome.status, + }; + } catch (error) { + await client.mutation(markDispatchFailedRef, { + error: error instanceof Error ? error.message : String(error), + issueId: input.issueId, + organizationId, + token, + }); + throw error; + } }, }), ]; diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 7386dfe..4d67c22 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -26,8 +26,10 @@ import type * as projectIssues from "../projectIssues.js"; import type * as projectStore from "../projectStore.js"; import type * as projects from "../projects.js"; import type * as publicGit from "../publicGit.js"; +import type * as signalRouting from "../signalRouting.js"; import type * as signals from "../signals.js"; import type * as todos from "../todos.js"; +import type * as workflows from "../workflows.js"; import type { ApiFromModules, @@ -54,8 +56,10 @@ declare const fullApi: ApiFromModules<{ projectStore: typeof projectStore; projects: typeof projects; publicGit: typeof publicGit; + signalRouting: typeof signalRouting; signals: typeof signals; todos: typeof todos; + workflows: typeof workflows; }>; /** diff --git a/packages/backend/convex/_generated/server.d.ts b/packages/backend/convex/_generated/server.d.ts index c5e955d..507b8d4 100644 --- a/packages/backend/convex/_generated/server.d.ts +++ b/packages/backend/convex/_generated/server.d.ts @@ -26,6 +26,8 @@ import type { DataModel } from "./dataModel.js"; */ type Env = { readonly FLUE_DB_TOKEN: string; + readonly GITEA_TOKEN: string | undefined; + readonly GITEA_URL: string | undefined; readonly NATIVE_APP_URL: string | undefined; readonly SITE_URL: string; }; diff --git a/packages/backend/convex/projects.ts b/packages/backend/convex/projects.ts index b3881a5..94ce1be 100644 --- a/packages/backend/convex/projects.ts +++ b/packages/backend/convex/projects.ts @@ -1,4 +1,5 @@ import { + decodePublicGitImportResult, preparePublicGitSource, type ProjectImportOutcome, type ProjectView, @@ -8,15 +9,17 @@ import { import { v } from "convex/values"; import { Effect } from "effect"; +import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; import { action, internalMutation, query } from "./_generated/server"; import { createInitialArtifacts } from "./artifactModel"; -import { requireCurrentOrganization } from "./authz"; +import { requireAuthUserId, requireCurrentOrganization } from "./authz"; import { makeProjectMutationStore, makeProjectQueryStore, persistPublicGitImportTransaction, } from "./projectStore"; +import { inspectPublicGit } from "./publicGit"; // --------------------------------------------------------------------------- // Internal: persist a public Git import in one transaction @@ -182,18 +185,31 @@ export const get = query({ }); // --------------------------------------------------------------------------- -// Public action: import a public Git repository -// (daemon wiring added in the Daemon phase) +// Public action: import a supported public Git repository // --------------------------------------------------------------------------- export const importPublicGit = action({ args: { repositoryUrl: v.string() }, - handler: async (_ctx, args): Promise => { - // Normalize through the domain layer to validate the URL before the - // daemon adapter is wired. - await Effect.runPromise(preparePublicGitSource(args.repositoryUrl)); - // The daemon PublicGit adapter is wired in the Daemon phase. - throw new Error("PublicGit import is not yet wired to the daemon adapter"); + handler: async (ctx, args): Promise => { + const userId = await requireAuthUserId(ctx); + const source = await Effect.runPromise( + preparePublicGitSource(args.repositoryUrl) + ); + const remote = await inspectPublicGit(source); + const validatedRemote = await Effect.runPromise( + decodePublicGitImportResult(remote) + ); + return await ctx.runMutation(internal.projects.persistPublicGitImport, { + remote: { + defaultBranch: validatedRemote.defaultBranch, + documents: validatedRemote.documents.map((document) => ({ + ...document, + })), + warnings: validatedRemote.warnings.map((warning) => ({ ...warning })), + }, + source, + userId, + }); }, }); diff --git a/packages/backend/convex/publicGit.test.ts b/packages/backend/convex/publicGit.test.ts new file mode 100644 index 0000000..6b11ca7 --- /dev/null +++ b/packages/backend/convex/publicGit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "vitest"; + +import { inspectPublicGit } from "./publicGit"; + +const source = { + host: "git.openputer.com", + normalizedUrl: "https://git.openputer.com/sai-karthik/hello-world", + projectName: "hello-world", + repositoryPath: "Sai-karthik/hello-world", + url: "https://git.openputer.com/Sai-karthik/hello-world.git", +}; + +describe("public Git repository inspection", () => { + test("imports metadata for an empty public Gitea repository", async () => { + const requests: string[] = []; + const result = await inspectPublicGit(source, { + fetch(input) { + const url = String(input); + requests.push(url); + if (url.endsWith("/api/v1/repos/Sai-karthik/hello-world")) { + return Promise.resolve( + Response.json({ + default_branch: "main", + empty: true, + }) + ); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }, + }); + + expect(result).toMatchObject({ + defaultBranch: "main", + documents: [], + }); + expect(result.warnings).toHaveLength(6); + expect(requests).toContain( + "https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world" + ); + expect(requests).toContain( + "https://git.openputer.com/api/v1/repos/Sai-karthik/hello-world/raw/README.md?ref=main" + ); + }); + + test("rejects hosts outside the supported provider allowlist", async () => { + await expect( + inspectPublicGit({ + ...source, + host: "example.com", + normalizedUrl: "https://example.com/example/repository", + repositoryPath: "example/repository", + url: "https://example.com/example/repository.git", + }) + ).rejects.toThrow( + "Repository import supports public GitHub and git.openputer.com URLs" + ); + }); +}); diff --git a/packages/backend/convex/publicGit.ts b/packages/backend/convex/publicGit.ts index eaebe66..a978a3f 100644 --- a/packages/backend/convex/publicGit.ts +++ b/packages/backend/convex/publicGit.ts @@ -8,10 +8,31 @@ import { } from "@code/primitives/project"; const GITHUB_HOST = "github.com"; -const REQUEST_HEADERS = { +const GITEA_HOST = "git.openputer.com"; +const GITHUB_REQUEST_HEADERS = { Accept: "application/vnd.github+json", "User-Agent": "zopu-project-importer", } as const; +const GITEA_REQUEST_HEADERS = { + Accept: "application/json", + "User-Agent": "zopu-project-importer", +} as const; + +type Fetch = ( + input: string | URL | Request, + init?: RequestInit +) => Promise; + +interface PublicGitProvider { + readonly headers: Readonly>; + readonly metadataUrl: (source: PreparedPublicGitSource) => string; + readonly rawFileUrl: ( + source: PreparedPublicGitSource, + branch: string, + path: string + ) => string; + readonly repositoryNotFoundMessage: string; +} const candidatePaths = { agents: ["AGENTS.md"], @@ -28,18 +49,51 @@ const encodePath = (path: string) => .map((segment) => encodeURIComponent(segment)) .join("/"); +const providerForSource = ( + source: PreparedPublicGitSource +): PublicGitProvider => { + if (source.host === GITHUB_HOST) { + return { + headers: GITHUB_REQUEST_HEADERS, + metadataUrl: (candidate) => + `https://api.github.com/repos/${encodePath(candidate.repositoryPath)}`, + rawFileUrl: (candidate, branch, path) => + `https://raw.githubusercontent.com/${encodePath(candidate.repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`, + repositoryNotFoundMessage: "Public GitHub repository not found", + }; + } + if (source.host === GITEA_HOST) { + return { + headers: GITEA_REQUEST_HEADERS, + metadataUrl: (candidate) => + `https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}`, + rawFileUrl: (candidate, branch, path) => + `https://${GITEA_HOST}/api/v1/repos/${encodePath(candidate.repositoryPath)}/raw/${encodePath(path)}?ref=${encodeURIComponent(branch)}`, + repositoryNotFoundMessage: "Public Gitea repository not found", + }; + } + throw new Error( + "Repository import supports public GitHub and git.openputer.com URLs" + ); +}; + const fetchTextCandidate = async ( - repositoryPath: string, + fetcher: Fetch, + provider: PublicGitProvider, + source: PreparedPublicGitSource, branch: string, path: string ): Promise => { - const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`; - const response = await fetch(url, { headers: REQUEST_HEADERS }); + const response = await fetcher(provider.rawFileUrl(source, branch, path), { + headers: provider.headers, + }); if (response.status === 404) { return null; } if (!response.ok) { - throw new Error(`GitHub returned ${response.status} for ${path}`); + throw new Error( + `${source.host} returned ${response.status} while reading ${path}` + ); } const content = await response.text(); if (content.trim().length === 0) { @@ -52,13 +106,17 @@ const fetchTextCandidate = async ( }; const fetchContextDocument = async ( + fetcher: Fetch, + provider: PublicGitProvider, source: PreparedPublicGitSource, branch: string, kind: (typeof CONTEXT_KINDS)[number] ): Promise => { for (const candidate of candidatePaths[kind]) { const content = await fetchTextCandidate( - source.repositoryPath, + fetcher, + provider, + source, branch, candidate ); @@ -74,20 +132,18 @@ const fetchContextDocument = async ( }; const readDefaultBranch = async ( + fetcher: Fetch, + provider: PublicGitProvider, source: PreparedPublicGitSource ): Promise => { - if (source.host !== GITHUB_HOST) { - throw new Error("Repository import currently supports public GitHub URLs"); - } - const response = await fetch( - `https://api.github.com/repos/${encodePath(source.repositoryPath)}`, - { headers: REQUEST_HEADERS } - ); + const response = await fetcher(provider.metadataUrl(source), { + headers: provider.headers, + }); if (!response.ok) { throw new Error( response.status === 404 - ? "Public GitHub repository not found" - : `GitHub returned ${response.status} while reading the repository` + ? provider.repositoryNotFoundMessage + : `${source.host} returned ${response.status} while reading the repository` ); } const metadata = (await response.json()) as { default_branch?: unknown }; @@ -95,18 +151,21 @@ const readDefaultBranch = async ( typeof metadata.default_branch !== "string" || metadata.default_branch.length === 0 ) { - throw new Error("GitHub did not return a default branch"); + throw new Error(`${source.host} did not return a default branch`); } return metadata.default_branch; }; export const inspectPublicGit = async ( - source: PreparedPublicGitSource + source: PreparedPublicGitSource, + options: { readonly fetch?: Fetch } = {} ): Promise => { - const defaultBranch = await readDefaultBranch(source); + const fetcher = options.fetch ?? globalThis.fetch; + const provider = providerForSource(source); + const defaultBranch = await readDefaultBranch(fetcher, provider, source); const candidates = await Promise.all( CONTEXT_KINDS.map((kind) => - fetchContextDocument(source, defaultBranch, kind) + fetchContextDocument(fetcher, provider, source, defaultBranch, kind) ) ); const documents = candidates.filter( diff --git a/packages/backend/convex/signalRouting.test.ts b/packages/backend/convex/signalRouting.test.ts index 2e294c3..e178eb1 100644 --- a/packages/backend/convex/signalRouting.test.ts +++ b/packages/backend/convex/signalRouting.test.ts @@ -491,7 +491,22 @@ describe("signalRouting begin issue", () => { token: TOKEN, }); - expect(status).toBe("queued"); + expect(status).toEqual({ + dispatchRequired: true, + projectId, + status: "queued", + }); + + const repeated = await t.mutation(api.signalRouting.beginIssue, { + issueId: result.issueId as string, + organizationId: orgId, + token: TOKEN, + }); + expect(repeated).toEqual({ + dispatchRequired: false, + projectId, + status: "queued", + }); }); test("begin rejects invalid token", async () => { diff --git a/packages/backend/convex/signalRouting.ts b/packages/backend/convex/signalRouting.ts index 3dc4111..34abb16 100644 --- a/packages/backend/convex/signalRouting.ts +++ b/packages/backend/convex/signalRouting.ts @@ -613,7 +613,14 @@ export const beginIssue = mutation({ issueId: v.id("projectIssues"), token: v.string(), }, - handler: async (ctx, args): Promise<"queued" | "working"> => { + handler: async ( + ctx, + args + ): Promise<{ + dispatchRequired: boolean; + projectId: Id<"projects">; + status: "queued" | "working"; + }> => { requireAgent(args.token); const issue = await ctx.db.get(args.issueId); if (!issue) { @@ -629,7 +636,11 @@ export const beginIssue = mutation({ ); }); if (status === issue.status) { - return status; + return { + dispatchRequired: false, + projectId: issue.projectId, + status, + }; } const timestamp = Date.now(); @@ -644,12 +655,50 @@ export const beginIssue = mutation({ kind: "issue.queued", projectId: issue.projectId, }); - return status; + return { + dispatchRequired: true, + projectId: issue.projectId, + status, + }; }, }); // --------------------------------------------------------------------------- -// 8. Get project summary + context documents for routing decisions. +// 8. Mark a failed Flue dispatch so retrying can safely queue it again. +// --------------------------------------------------------------------------- + +export const markDispatchFailed = mutation({ + args: { + error: v.string(), + issueId: v.id("projectIssues"), + organizationId: v.id("organizations"), + token: v.string(), + }, + handler: async (ctx, args) => { + requireAgent(args.token); + const issue = await ctx.db.get(args.issueId); + if (!issue) { + throw new ConvexError("Issue not found"); + } + await authorizeProjectInOrg(ctx, args.organizationId, issue.projectId); + const timestamp = Date.now(); + await ctx.db.patch(issue._id, { + status: "failed", + updatedAt: timestamp, + }); + await ctx.db.insert("projectEvents", { + createdAt: timestamp, + data: { error: args.error.slice(0, 1000), phase: "dispatch" }, + issueId: issue._id, + kind: "agent.failed", + projectId: issue.projectId, + }); + return null; + }, +}); + +// --------------------------------------------------------------------------- +// 9. Get project summary + context documents for routing decisions. // --------------------------------------------------------------------------- export const getProjectContext = query({ diff --git a/packages/env/src/agent.ts b/packages/env/src/agent.ts index 8710d78..7c8180b 100644 --- a/packages/env/src/agent.ts +++ b/packages/env/src/agent.ts @@ -13,6 +13,7 @@ const agentEnvSchema = z.object({ FLUE_DB_TOKEN: z.string().min(1), GITEA_TOKEN: z.string().min(1).optional(), GITEA_URL: z.url().default("https://git.openputer.com"), + OPENROUTER_API_KEY: z.string().min(1).optional(), }); export type AgentEnv = z.infer; diff --git a/packages/ui/src/components/ai-elements/prompt-input.tsx b/packages/ui/src/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..1c7165b --- /dev/null +++ b/packages/ui/src/components/ai-elements/prompt-input.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { + InputGroup, + InputGroupButton, + InputGroupTextarea, +} from "@code/ui/components/input-group"; +import { cn } from "@code/ui/lib/utils"; +import type { ChatStatus } from "ai"; +import { ArrowUpIcon, LoaderCircleIcon, SquareIcon } from "lucide-react"; +import type { + ComponentProps, + FormEvent, + FormEventHandler, + HTMLAttributes, + KeyboardEventHandler, + ReactNode, +} from "react"; + +export interface PromptInputMessage { + files: []; + text: string; +} + +export type PromptInputProps = Omit, "onSubmit"> & { + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => Promise | void; +}; + +export const PromptInput = ({ + children, + className, + onSubmit, + ...props +}: PromptInputProps) => { + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + const text = new FormData(event.currentTarget) + .get("message") + ?.toString() + .trim(); + + if (!text) { + return; + } + + const submitMessage = async () => { + try { + await onSubmit({ files: [], text }, event); + } catch { + // The owning chat hook exposes submission failures in the composer status. + } + }; + void submitMessage(); + }; + + return ( +
+ {children} +
+ ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + className, + onKeyDown, + ...props +}: PromptInputTextareaProps) => { + const handleKeyDown: KeyboardEventHandler = (event) => { + onKeyDown?.(event); + + if ( + event.defaultPrevented || + event.key !== "Enter" || + event.shiftKey || + event.nativeEvent.isComposing + ) { + return; + } + + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + }; + + return ( + + ); +}; + +export type PromptInputButtonProps = ComponentProps; + +export const PromptInputButton = ({ + className, + size = "icon-sm", + ...props +}: PromptInputButtonProps) => ( + +); + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; +}; + +export const PromptInputSubmit = ({ + children, + className, + disabled, + size = "icon-sm", + status = "ready", + ...props +}: PromptInputSubmitProps) => { + const isBusy = status === "submitted" || status === "streaming"; + let content: ReactNode = children ?? ; + if (status === "streaming") { + content = ; + } else if (status === "submitted") { + content = ; + } + + return ( + + {content} + + ); +}; diff --git a/packages/ui/src/components/mobile-workspace-screens.tsx b/packages/ui/src/components/mobile-workspace-screens.tsx index 23425ef..a9028e2 100644 --- a/packages/ui/src/components/mobile-workspace-screens.tsx +++ b/packages/ui/src/components/mobile-workspace-screens.tsx @@ -8,7 +8,6 @@ import { FileText, ListChecks, LoaderCircle, - Mic, MoreHorizontal, Paperclip, Plus, @@ -17,6 +16,23 @@ import { } from "lucide-react"; import type { FormEvent, ReactNode } from "react"; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "./ai-elements/conversation"; +import { + Message, + MessageContent, + MessageResponse, +} from "./ai-elements/message"; +import { + PromptInput, + PromptInputBody, + PromptInputButton, + PromptInputSubmit, + PromptInputTextarea, +} from "./ai-elements/prompt-input"; import type { MobileProjectView, MobileWorkspaceView, @@ -36,21 +52,32 @@ export interface MobileWorkspaceRendererProps { createIssueBody: string; createIssueTitle: string; data: MobileWorkspaceView; + modelId?: string; + modelLabel?: string; onBack?: () => void; onComposerChange: (value: string) => void; + onComposerMessageSubmit?: (message: string) => Promise; onComposerSubmit: (event: FormEvent) => void; onCreateBodyChange: (value: string) => void; onCreateIssue: (event: FormEvent) => void; onCreateIssueFromSignal?: () => void; onCreateTitleChange: (value: string) => void; onManageProjects?: () => void; + onProjectManagerClose?: () => void; onOpenAssistant?: () => void; onOpenUnit?: (workUnitId?: string) => void; onProjectSelect?: (projectId: string) => void; + onRepositoryChange?: (value: string) => void; + onRepositoryConnect?: (event: FormEvent) => void; + onSettingsClose?: () => void; + onSettingsOpen?: () => void; onReviewUnit?: (reviewUrl: string) => void; onStartUnit?: (workUnitId: string) => void; onViewWork?: () => void; pendingAction?: string | null; + projectManagerOpen?: boolean; + repositoryValue?: string; + settingsOpen?: boolean; statusMessage?: string; variant: MobileWorkspaceVariant; } @@ -294,7 +321,9 @@ const ProjectSwitcher = ({ ) : ( data.projects.map((project: MobileProjectView) => ( )) )} @@ -310,6 +339,184 @@ const ProjectSwitcher = ({
); +const ProjectManagementPanel = ({ + createIssueBody, + createIssueTitle, + data, + onClose, + onCreateBodyChange, + onCreateIssue, + onCreateTitleChange, + onProjectSelect, + onRepositoryChange, + onRepositoryConnect, + pendingAction, + repositoryValue = "", + statusMessage, +}: { + readonly createIssueBody: string; + readonly createIssueTitle: string; + readonly data: MobileWorkspaceView; + readonly onClose?: () => void; + readonly onCreateBodyChange: (value: string) => void; + readonly onCreateIssue: (event: FormEvent) => void; + readonly onCreateTitleChange: (value: string) => void; + readonly onProjectSelect?: (projectId: string) => void; + readonly onRepositoryChange?: (value: string) => void; + readonly onRepositoryConnect?: (event: FormEvent) => void; + readonly pendingAction?: string | null; + readonly repositoryValue?: string; + readonly statusMessage?: string; +}) => { + const connecting = pendingAction === "connect"; + return ( +
+
+
+

Project workspace

+

+ Connect repositories and create durable work. +

+
+ +
+
+
+

+ Connected project +

+ +
+ + onRepositoryChange?.(event.target.value)} + placeholder="https://git.openputer.com/owner/repo.git" + required + type="url" + value={repositoryValue} + /> + +
+
+ {data.selectedProjectId ? ( +
+ +
+ ) : null} + {statusMessage ? ( +

+ {statusMessage} +

+ ) : null} +
+
+ ); +}; + +const ChatSettingsPanel = ({ + modelId = "openrouter/openai/gpt-oss-20b:free", + modelLabel = "GPT-OSS 20B Free", + onClose, +}: { + readonly modelId?: string; + readonly modelLabel?: string; + readonly onClose?: () => void; +}) => ( +
+
+
+

Settings

+

+ Configure how Zopu responds. +

+
+ +
+
+
+ + +

+ A tool-capable free OpenRouter model is active for the local Zopu + agent. +

+
+
+
+); + const WorkUnitActions = ({ onReview, onStart, @@ -1224,16 +1431,126 @@ const assistantStatusDotClass: Record< red: "bg-[#e04b3f]", }; +const ChatWorkUnitCard = ({ + onOpen, + onReview, + onStart, + pendingAction, + workUnit, +}: { + readonly onOpen?: (workUnitId?: string) => void; + readonly onReview?: (reviewUrl: string) => void; + readonly onStart?: (workUnitId: string) => void; + readonly pendingAction?: string | null; + readonly workUnit?: MobileWorkUnitView; +}) => { + if (!workUnit) { + return null; + } + return ( +
+ + +
+ ); +}; + const CharacterChat = ({ + createIssueBody, + createIssueTitle, data, + modelId, + modelLabel, onChange, + onMessageSubmit, onCreateIssueFromSignal, - onSubmit, + onCreateBodyChange, + onCreateIssue, + onCreateTitleChange, + onManageProjects, + onOpen, + onProjectManagerClose, + onProjectSelect, + onRepositoryChange, + onRepositoryConnect, + onReview, + onSettingsClose, + onSettingsOpen, + onStart, + pendingAction, + projectManagerOpen, + repositoryValue, + settingsOpen, statusMessage, value, }: ComposerProps & { + createIssueBody: string; + createIssueTitle: string; data: MobileWorkspaceView; + modelId?: string; + modelLabel?: string; + onCreateBodyChange: (value: string) => void; + onCreateIssue: (event: FormEvent) => void; onCreateIssueFromSignal?: () => void; + onCreateTitleChange: (value: string) => void; + onManageProjects?: () => void; + onOpen?: (workUnitId?: string) => void; + onProjectManagerClose?: () => void; + onProjectSelect?: (projectId: string) => void; + onRepositoryChange?: (value: string) => void; + onRepositoryConnect?: (event: FormEvent) => void; + onMessageSubmit?: (message: string) => Promise; + onReview?: (reviewUrl: string) => void; + onSettingsClose?: () => void; + onSettingsOpen?: () => void; + onStart?: (workUnitId: string) => void; + pendingAction?: string | null; + projectManagerOpen?: boolean; + repositoryValue?: string; + settingsOpen?: boolean; }) => (
@@ -1253,146 +1570,218 @@ const CharacterChat = ({ {data.assistant.statusLabel}

- + -
- {data.assistant.messages.length === 0 ? ( - <> -
- TODAY -
-
- - - - Zopu - • a fresh start -
-

- Good morning. What are we making clearer today? -

-
- - - -
- {data.latestSignal ? ( -
-
- - ↗ - -
-

- {data.latestSignal.title} -

-

- Latest project signal -

-
- -
-

- {data.latestSignal.summary} -

+
+ +
+ + + {data.assistant.messages.length === 0 ? ( + <> +
+ TODAY +
+
+ + + + Zopu + • a fresh start +
+

+ Good morning. What are we making clearer today? +

+
-
- ) : null} - - ) : ( -
- {data.assistant.messages.map((message) => - message.role === "user" ? ( -
onChange("Plan the next steps for my project")} + type="button" + > + + Plan + + +
+ {data.latestSignal ? ( +
+
+ + ↗ + +
+

+ {data.latestSignal.title} +

+

+ Latest project signal +

+
+ +
+

+ {data.latestSignal.summary} +

+ +
+ ) : null} + + ) : ( +
+ {data.assistant.messages.map((message) => ( + - {message.text} -
- ) : ( -
-
- - - - Zopu - - THINKING PARTNER - -
-

- {message.text} -

-
- ) - )} - {data.assistant.isBusy ? ( -

Zopu is thinking…

- ) : null} -
- )} -
-
- - onChange(event.target.value)} - placeholder="Ask anything..." - value={value} + + {message.role === "assistant" ? ( + <> +
+ + + + Zopu + + THINKING PARTNER + +
+ + {message.text} + + + ) : ( + + {message.text} + + )} +
+ + ))} + {data.assistant.isBusy ? ( +

Zopu is thinking…

+ ) : null} + + )} + + + - - - + + { + if (onMessageSubmit) { + await onMessageSubmit(message.text); + return; + } + onChange(message.text); + }} + > + + + + + onChange(event.currentTarget.value)} + placeholder="Ask anything..." + value={value} + /> + + + + +

{statusMessage ?? "Press Enter to send · Shift + Enter for a new line"}

+ {projectManagerOpen ? ( + + ) : null} + {settingsOpen ? ( + + ) : null} ); @@ -1402,21 +1791,32 @@ export const MobileWorkspaceScreenRenderer = ({ createIssueBody, createIssueTitle, data, + modelId, + modelLabel, onBack, onComposerChange, + onComposerMessageSubmit, onComposerSubmit, onCreateBodyChange, onCreateIssue, onCreateIssueFromSignal, onCreateTitleChange, onManageProjects, + onProjectManagerClose, onOpenAssistant, onOpenUnit, onProjectSelect, + onRepositoryChange, + onRepositoryConnect, onReviewUnit, + onSettingsClose, + onSettingsOpen, onStartUnit, onViewWork, pendingAction, + projectManagerOpen, + repositoryValue, + settingsOpen, statusMessage, variant, }: MobileWorkspaceRendererProps) => { @@ -1432,8 +1832,30 @@ export const MobileWorkspaceScreenRenderer = ({ return ( ); } diff --git a/packages/ui/src/components/mobile-workspace/models.ts b/packages/ui/src/components/mobile-workspace/models.ts index f365883..4ded81c 100644 --- a/packages/ui/src/components/mobile-workspace/models.ts +++ b/packages/ui/src/components/mobile-workspace/models.ts @@ -2,8 +2,10 @@ export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple"; export interface MobileProjectView { readonly connected: boolean; + readonly host?: string; readonly id: string; readonly name: string; + readonly repositoryPath?: string; } export interface MobileWorkUnitView {