Integrate mobile chat workspace and OpenRouter agent flow
This commit is contained in:
@@ -6,10 +6,12 @@ import {
|
|||||||
MobileWorkListScreen,
|
MobileWorkListScreen,
|
||||||
MobileWorkUnitDetailScreen,
|
MobileWorkUnitDetailScreen,
|
||||||
} from "@code/ui/components/mobile-product";
|
} from "@code/ui/components/mobile-product";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
||||||
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
||||||
|
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
|
||||||
|
|
||||||
export type MobileFlowScreen =
|
export type MobileFlowScreen =
|
||||||
| "assistant-chat"
|
| "assistant-chat"
|
||||||
@@ -39,7 +41,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
|||||||
const targetId =
|
const targetId =
|
||||||
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
|
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
navigate("/dashboard");
|
workspace.setProjectManagerOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (screen === "work-list" && !workspace.expanded) {
|
if (screen === "work-list" && !workspace.expanded) {
|
||||||
@@ -54,8 +56,11 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
|||||||
createIssueBody: workspace.issueBody,
|
createIssueBody: workspace.issueBody,
|
||||||
createIssueTitle: workspace.issueTitle,
|
createIssueTitle: workspace.issueTitle,
|
||||||
data: projectWorkspace.data,
|
data: projectWorkspace.data,
|
||||||
|
modelId: MODEL_ID,
|
||||||
|
modelLabel: MODEL_LABEL,
|
||||||
onBack: () => navigate(workPath),
|
onBack: () => navigate(workPath),
|
||||||
onComposerChange: workspace.handleComposerChange,
|
onComposerChange: workspace.handleComposerChange,
|
||||||
|
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
|
||||||
onComposerSubmit: workspace.handleComposerSubmit,
|
onComposerSubmit: workspace.handleComposerSubmit,
|
||||||
onCreateBodyChange: workspace.setIssueBody,
|
onCreateBodyChange: workspace.setIssueBody,
|
||||||
onCreateIssue: workspace.handleCreateIssueSubmit,
|
onCreateIssue: workspace.handleCreateIssueSubmit,
|
||||||
@@ -66,16 +71,24 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
|||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
onCreateTitleChange: workspace.setIssueTitle,
|
onCreateTitleChange: workspace.setIssueTitle,
|
||||||
onManageProjects: () => navigate("/dashboard"),
|
onManageProjects: () => workspace.setProjectManagerOpen(true),
|
||||||
onOpenAssistant: () => navigate(chatPath),
|
onOpenAssistant: () => navigate(chatPath),
|
||||||
onOpenUnit: handleOpenUnit,
|
onOpenUnit: handleOpenUnit,
|
||||||
|
onProjectManagerClose: () => workspace.setProjectManagerOpen(false),
|
||||||
onProjectSelect: (projectId: string) =>
|
onProjectSelect: (projectId: string) =>
|
||||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
||||||
projectId as Id<"projects">
|
projectId as Id<"projects">
|
||||||
),
|
),
|
||||||
|
onRepositoryChange: projectWorkspace.projectWorkspace.setRepository,
|
||||||
|
onRepositoryConnect: (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void projectWorkspace.projectWorkspace.connectRepository();
|
||||||
|
},
|
||||||
onReviewUnit: (reviewUrl: string) => {
|
onReviewUnit: (reviewUrl: string) => {
|
||||||
window.open(reviewUrl, "_blank", "noopener,noreferrer");
|
window.open(reviewUrl, "_blank", "noopener,noreferrer");
|
||||||
},
|
},
|
||||||
|
onSettingsClose: () => workspace.setSettingsOpen(false),
|
||||||
|
onSettingsOpen: () => workspace.setSettingsOpen(true),
|
||||||
onStartUnit: (selectedIssueId: string) => {
|
onStartUnit: (selectedIssueId: string) => {
|
||||||
void projectWorkspace.startWorkUnit(
|
void projectWorkspace.startWorkUnit(
|
||||||
selectedIssueId as Id<"projectIssues">
|
selectedIssueId as Id<"projectIssues">
|
||||||
@@ -83,6 +96,9 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
|||||||
},
|
},
|
||||||
onViewWork: () => navigate(workPath),
|
onViewWork: () => navigate(workPath),
|
||||||
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
|
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
|
||||||
|
projectManagerOpen: workspace.projectManagerOpen,
|
||||||
|
repositoryValue: projectWorkspace.projectWorkspace.repository,
|
||||||
|
settingsOpen: workspace.settingsOpen,
|
||||||
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
|
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ export const useMobileProjectWorkspace = ({
|
|||||||
selectedWorkUnitId,
|
selectedWorkUnitId,
|
||||||
signals,
|
signals,
|
||||||
}),
|
}),
|
||||||
error: organization.error ?? chatAgent.error,
|
error:
|
||||||
|
organization.error ??
|
||||||
|
chatAgent.error ??
|
||||||
|
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
|
||||||
organization,
|
organization,
|
||||||
projectWorkspace,
|
projectWorkspace,
|
||||||
raiseIssue: projectWorkspace.raiseIssue,
|
raiseIssue: projectWorkspace.raiseIssue,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const useMobileWorkspace = ({
|
|||||||
const [issueBody, setIssueBody] = useState("");
|
const [issueBody, setIssueBody] = useState("");
|
||||||
const [issueTitle, setIssueTitle] = useState("");
|
const [issueTitle, setIssueTitle] = useState("");
|
||||||
const [expanded, setExpanded] = useState(initialExpanded);
|
const [expanded, setExpanded] = useState(initialExpanded);
|
||||||
|
const [projectManagerOpen, setProjectManagerOpen] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string>();
|
const [statusMessage, setStatusMessage] = useState<string>();
|
||||||
|
|
||||||
const handleComposerChange = (value: string) => {
|
const handleComposerChange = (value: string) => {
|
||||||
@@ -26,23 +28,32 @@ export const useMobileWorkspace = ({
|
|||||||
setStatusMessage(undefined);
|
setStatusMessage(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const submitMessage = async (message: string) => {
|
||||||
event.preventDefault();
|
const trimmedMessage = message.trim();
|
||||||
const message = composerValue.trim();
|
if (!trimmedMessage) {
|
||||||
if (!message) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatusMessage("Sending to Zopu…");
|
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<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const sendCurrentMessage = async () => {
|
||||||
try {
|
try {
|
||||||
await onSend(message);
|
await submitMessage(composerValue);
|
||||||
setComposerValue("");
|
} catch {
|
||||||
setStatusMessage("Sent to Zopu");
|
// submitMessage already exposes the failure through statusMessage.
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(errorMessage(error));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void sendMessage();
|
void sendCurrentMessage();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
@@ -70,13 +81,18 @@ export const useMobileWorkspace = ({
|
|||||||
composerValue,
|
composerValue,
|
||||||
expanded,
|
expanded,
|
||||||
handleComposerChange,
|
handleComposerChange,
|
||||||
|
handleComposerMessageSubmit: submitMessage,
|
||||||
handleComposerSubmit,
|
handleComposerSubmit,
|
||||||
handleCreateIssueSubmit,
|
handleCreateIssueSubmit,
|
||||||
issueBody,
|
issueBody,
|
||||||
issueTitle,
|
issueTitle,
|
||||||
|
projectManagerOpen,
|
||||||
setExpanded,
|
setExpanded,
|
||||||
setIssueBody,
|
setIssueBody,
|
||||||
setIssueTitle,
|
setIssueTitle,
|
||||||
|
setProjectManagerOpen,
|
||||||
|
setSettingsOpen,
|
||||||
|
settingsOpen,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export const CHAT_AGENT = {
|
|||||||
name: "zopu",
|
name: "zopu",
|
||||||
} as const;
|
} 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 = [
|
export const SUGGESTIONS = [
|
||||||
["Think it through", "Help me make a difficult decision"],
|
["Think it through", "Help me make a difficult decision"],
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { describe, expect, test } from "vitest";
|
|||||||
import { createFlueFetch } from "./flue-transport";
|
import { createFlueFetch } from "./flue-transport";
|
||||||
|
|
||||||
const BASE_URL = new URL("https://flue.example/api/");
|
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", () => {
|
describe("createFlueFetch", () => {
|
||||||
test("overlapping SDK agent sends keep distinct request IDs", async () => {
|
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 () => {
|
test("history and stream observation requests remain untagged", async () => {
|
||||||
const capturedHeaders: Headers[] = [];
|
const capturedHeaders: Headers[] = [];
|
||||||
const fetchImpl: typeof fetch = (_input, init) => {
|
const fetchImpl: typeof fetch = (_input, init) => {
|
||||||
|
|||||||
@@ -4,6 +4,31 @@ interface FlueFetchOptions {
|
|||||||
readonly generateRequestId?: () => string;
|
readonly generateRequestId?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* Build the Flue transport used by the browser client.
|
||||||
*
|
*
|
||||||
@@ -15,7 +40,7 @@ interface FlueFetchOptions {
|
|||||||
export const createFlueFetch = ({
|
export const createFlueFetch = ({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
generateRequestId = crypto.randomUUID,
|
generateRequestId = () => crypto.randomUUID(),
|
||||||
}: FlueFetchOptions): typeof fetch => {
|
}: FlueFetchOptions): typeof fetch => {
|
||||||
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
||||||
|
|
||||||
@@ -47,11 +72,9 @@ export const createFlueFetch = ({
|
|||||||
requestInit = { ...init, headers };
|
requestInit = { ...init, headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetchImpl(input, requestInit);
|
const rawResponse = await fetchImpl.call(globalThis, input, requestInit);
|
||||||
if (
|
const response = await addRequestContext(rawResponse, method, requestUrl);
|
||||||
!response.ok ||
|
if (!response.headers.get("content-type")?.includes("application/json")) {
|
||||||
!response.headers.get("content-type")?.includes("application/json")
|
|
||||||
) {
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,11 +125,16 @@ const relevantSignalsForProject = (
|
|||||||
const makeProjectViews = (
|
const makeProjectViews = (
|
||||||
projects: readonly ProjectView[] | undefined
|
projects: readonly ProjectView[] | undefined
|
||||||
): readonly MobileProjectView[] =>
|
): readonly MobileProjectView[] =>
|
||||||
projects?.map((project) => ({
|
projects?.map((project) => {
|
||||||
connected: project.sources.length > 0,
|
const [source] = project.sources;
|
||||||
id: project.id,
|
return {
|
||||||
name: project.name,
|
connected: Boolean(source),
|
||||||
})) ?? [];
|
host: source?.host,
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
repositoryPath: source?.repositoryPath,
|
||||||
|
};
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
export const buildMobileWorkspaceView = ({
|
export const buildMobileWorkspaceView = ({
|
||||||
artifacts,
|
artifacts,
|
||||||
|
|||||||
@@ -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 default function MobileLandingRedirect() {
|
||||||
|
return <Navigate replace to="/chat" />;
|
||||||
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 <MobileFlowPage screen="home" />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "code",
|
"name": "code",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { parseAgentEnv } from "@code/env/agent";
|
import { parseAgentEnv } from "@code/env/agent";
|
||||||
import { defineAgent } from "@flue/runtime";
|
import { defineAgent } from "@flue/runtime";
|
||||||
|
|
||||||
// import { agentos } from "../sandboxes/agentos";
|
// import { agentos } from "../sandboxes/agentos";
|
||||||
import { local } from "@flue/runtime/node";
|
import { local } from "@flue/runtime/node";
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,25 @@ const agentEnv = parseAgentEnv(process.env);
|
|||||||
|
|
||||||
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||||
api: agentEnv.AGENT_MODEL_API,
|
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,
|
baseUrl: agentEnv.AGENT_MODEL_BASE_URL,
|
||||||
models: {
|
models: {
|
||||||
[agentEnv.AGENT_MODEL_NAME]: {
|
[agentEnv.AGENT_MODEL_NAME]: {
|
||||||
contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW,
|
contextWindow: agentEnv.AGENT_MODEL_CONTEXT_WINDOW,
|
||||||
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
|
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
|
||||||
},
|
},
|
||||||
|
"openai/gpt-oss-20b:free": {
|
||||||
|
contextWindow: 131_072,
|
||||||
|
maxTokens: agentEnv.AGENT_MODEL_MAX_TOKENS,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.post("/project-requests", projectRequestRoute);
|
app.post("/project-requests", projectRequestRoute);
|
||||||
app.route("/", flue());
|
app.route("/", flue() as unknown as Hono);
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -317,4 +317,5 @@ export const authenticatedAgentRoute: AgentRouteHandler = async (c, next) => {
|
|||||||
clientRequestId,
|
clientRequestId,
|
||||||
organizationId: organization._id,
|
organizationId: organization._id,
|
||||||
});
|
});
|
||||||
|
return c.res;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { parseAgentEnv } from "@code/env/agent";
|
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 { ConvexHttpClient } from "convex/browser";
|
||||||
import { makeFunctionReference } from "convex/server";
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { Schema } from "effect";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -103,9 +105,24 @@ const beginIssueRef = makeFunctionReference<
|
|||||||
readonly issueId: string;
|
readonly issueId: string;
|
||||||
readonly token: string;
|
readonly token: string;
|
||||||
},
|
},
|
||||||
"queued" | "working"
|
{
|
||||||
|
readonly dispatchRequired: boolean;
|
||||||
|
readonly projectId: string;
|
||||||
|
readonly status: "queued" | "working";
|
||||||
|
}
|
||||||
>("signalRouting:beginIssue");
|
>("signalRouting:beginIssue");
|
||||||
|
|
||||||
|
const markDispatchFailedRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{
|
||||||
|
readonly error: string;
|
||||||
|
readonly issueId: string;
|
||||||
|
readonly organizationId: string;
|
||||||
|
readonly token: string;
|
||||||
|
},
|
||||||
|
null
|
||||||
|
>("signalRouting:markDispatchFailed");
|
||||||
|
|
||||||
const getProjectContextRef = makeFunctionReference<
|
const getProjectContextRef = makeFunctionReference<
|
||||||
"query",
|
"query",
|
||||||
{
|
{
|
||||||
@@ -306,11 +323,45 @@ export const createSignalRoutingTools = (
|
|||||||
}),
|
}),
|
||||||
name: "begin_issue",
|
name: "begin_issue",
|
||||||
async run({ input }) {
|
async run({ input }) {
|
||||||
return await client.mutation(beginIssueRef, {
|
const outcome = await client.mutation(beginIssueRef, {
|
||||||
issueId: input.issueId,
|
issueId: input.issueId,
|
||||||
organizationId,
|
organizationId,
|
||||||
token,
|
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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -26,8 +26,10 @@ import type * as projectIssues from "../projectIssues.js";
|
|||||||
import type * as projectStore from "../projectStore.js";
|
import type * as projectStore from "../projectStore.js";
|
||||||
import type * as projects from "../projects.js";
|
import type * as projects from "../projects.js";
|
||||||
import type * as publicGit from "../publicGit.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 signals from "../signals.js";
|
||||||
import type * as todos from "../todos.js";
|
import type * as todos from "../todos.js";
|
||||||
|
import type * as workflows from "../workflows.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiFromModules,
|
ApiFromModules,
|
||||||
@@ -54,8 +56,10 @@ declare const fullApi: ApiFromModules<{
|
|||||||
projectStore: typeof projectStore;
|
projectStore: typeof projectStore;
|
||||||
projects: typeof projects;
|
projects: typeof projects;
|
||||||
publicGit: typeof publicGit;
|
publicGit: typeof publicGit;
|
||||||
|
signalRouting: typeof signalRouting;
|
||||||
signals: typeof signals;
|
signals: typeof signals;
|
||||||
todos: typeof todos;
|
todos: typeof todos;
|
||||||
|
workflows: typeof workflows;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import type { DataModel } from "./dataModel.js";
|
|||||||
*/
|
*/
|
||||||
type Env = {
|
type Env = {
|
||||||
readonly FLUE_DB_TOKEN: string;
|
readonly FLUE_DB_TOKEN: string;
|
||||||
|
readonly GITEA_TOKEN: string | undefined;
|
||||||
|
readonly GITEA_URL: string | undefined;
|
||||||
readonly NATIVE_APP_URL: string | undefined;
|
readonly NATIVE_APP_URL: string | undefined;
|
||||||
readonly SITE_URL: string;
|
readonly SITE_URL: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
decodePublicGitImportResult,
|
||||||
preparePublicGitSource,
|
preparePublicGitSource,
|
||||||
type ProjectImportOutcome,
|
type ProjectImportOutcome,
|
||||||
type ProjectView,
|
type ProjectView,
|
||||||
@@ -8,15 +9,17 @@ import {
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
|
|
||||||
|
import { internal } from "./_generated/api";
|
||||||
import type { Id } from "./_generated/dataModel";
|
import type { Id } from "./_generated/dataModel";
|
||||||
import { action, internalMutation, query } from "./_generated/server";
|
import { action, internalMutation, query } from "./_generated/server";
|
||||||
import { createInitialArtifacts } from "./artifactModel";
|
import { createInitialArtifacts } from "./artifactModel";
|
||||||
import { requireCurrentOrganization } from "./authz";
|
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
|
||||||
import {
|
import {
|
||||||
makeProjectMutationStore,
|
makeProjectMutationStore,
|
||||||
makeProjectQueryStore,
|
makeProjectQueryStore,
|
||||||
persistPublicGitImportTransaction,
|
persistPublicGitImportTransaction,
|
||||||
} from "./projectStore";
|
} from "./projectStore";
|
||||||
|
import { inspectPublicGit } from "./publicGit";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal: persist a public Git import in one transaction
|
// Internal: persist a public Git import in one transaction
|
||||||
@@ -182,18 +185,31 @@ export const get = query({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public action: import a public Git repository
|
// Public action: import a supported public Git repository
|
||||||
// (daemon wiring added in the Daemon phase)
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const importPublicGit = action({
|
export const importPublicGit = action({
|
||||||
args: { repositoryUrl: v.string() },
|
args: { repositoryUrl: v.string() },
|
||||||
handler: async (_ctx, args): Promise<ProjectImportOutcome> => {
|
handler: async (ctx, args): Promise<ProjectImportOutcome> => {
|
||||||
// Normalize through the domain layer to validate the URL before the
|
const userId = await requireAuthUserId(ctx);
|
||||||
// daemon adapter is wired.
|
const source = await Effect.runPromise(
|
||||||
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
|
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");
|
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,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
58
packages/backend/convex/publicGit.test.ts
Normal file
58
packages/backend/convex/publicGit.test.ts
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,31 @@ import {
|
|||||||
} from "@code/primitives/project";
|
} from "@code/primitives/project";
|
||||||
|
|
||||||
const GITHUB_HOST = "github.com";
|
const GITHUB_HOST = "github.com";
|
||||||
const REQUEST_HEADERS = {
|
const GITEA_HOST = "git.openputer.com";
|
||||||
|
const GITHUB_REQUEST_HEADERS = {
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
"User-Agent": "zopu-project-importer",
|
"User-Agent": "zopu-project-importer",
|
||||||
} as const;
|
} 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<Response>;
|
||||||
|
|
||||||
|
interface PublicGitProvider {
|
||||||
|
readonly headers: Readonly<Record<string, string>>;
|
||||||
|
readonly metadataUrl: (source: PreparedPublicGitSource) => string;
|
||||||
|
readonly rawFileUrl: (
|
||||||
|
source: PreparedPublicGitSource,
|
||||||
|
branch: string,
|
||||||
|
path: string
|
||||||
|
) => string;
|
||||||
|
readonly repositoryNotFoundMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
const candidatePaths = {
|
const candidatePaths = {
|
||||||
agents: ["AGENTS.md"],
|
agents: ["AGENTS.md"],
|
||||||
@@ -28,18 +49,51 @@ const encodePath = (path: string) =>
|
|||||||
.map((segment) => encodeURIComponent(segment))
|
.map((segment) => encodeURIComponent(segment))
|
||||||
.join("/");
|
.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 (
|
const fetchTextCandidate = async (
|
||||||
repositoryPath: string,
|
fetcher: Fetch,
|
||||||
|
provider: PublicGitProvider,
|
||||||
|
source: PreparedPublicGitSource,
|
||||||
branch: string,
|
branch: string,
|
||||||
path: string
|
path: string
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
const url = `https://raw.githubusercontent.com/${encodePath(repositoryPath)}/${encodePath(branch)}/${encodePath(path)}`;
|
const response = await fetcher(provider.rawFileUrl(source, branch, path), {
|
||||||
const response = await fetch(url, { headers: REQUEST_HEADERS });
|
headers: provider.headers,
|
||||||
|
});
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
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();
|
const content = await response.text();
|
||||||
if (content.trim().length === 0) {
|
if (content.trim().length === 0) {
|
||||||
@@ -52,13 +106,17 @@ const fetchTextCandidate = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchContextDocument = async (
|
const fetchContextDocument = async (
|
||||||
|
fetcher: Fetch,
|
||||||
|
provider: PublicGitProvider,
|
||||||
source: PreparedPublicGitSource,
|
source: PreparedPublicGitSource,
|
||||||
branch: string,
|
branch: string,
|
||||||
kind: (typeof CONTEXT_KINDS)[number]
|
kind: (typeof CONTEXT_KINDS)[number]
|
||||||
): Promise<RepositoryContextDocument | null> => {
|
): Promise<RepositoryContextDocument | null> => {
|
||||||
for (const candidate of candidatePaths[kind]) {
|
for (const candidate of candidatePaths[kind]) {
|
||||||
const content = await fetchTextCandidate(
|
const content = await fetchTextCandidate(
|
||||||
source.repositoryPath,
|
fetcher,
|
||||||
|
provider,
|
||||||
|
source,
|
||||||
branch,
|
branch,
|
||||||
candidate
|
candidate
|
||||||
);
|
);
|
||||||
@@ -74,20 +132,18 @@ const fetchContextDocument = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const readDefaultBranch = async (
|
const readDefaultBranch = async (
|
||||||
|
fetcher: Fetch,
|
||||||
|
provider: PublicGitProvider,
|
||||||
source: PreparedPublicGitSource
|
source: PreparedPublicGitSource
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
if (source.host !== GITHUB_HOST) {
|
const response = await fetcher(provider.metadataUrl(source), {
|
||||||
throw new Error("Repository import currently supports public GitHub URLs");
|
headers: provider.headers,
|
||||||
}
|
});
|
||||||
const response = await fetch(
|
|
||||||
`https://api.github.com/repos/${encodePath(source.repositoryPath)}`,
|
|
||||||
{ headers: REQUEST_HEADERS }
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
response.status === 404
|
response.status === 404
|
||||||
? "Public GitHub repository not found"
|
? provider.repositoryNotFoundMessage
|
||||||
: `GitHub returned ${response.status} while reading the repository`
|
: `${source.host} returned ${response.status} while reading the repository`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const metadata = (await response.json()) as { default_branch?: unknown };
|
const metadata = (await response.json()) as { default_branch?: unknown };
|
||||||
@@ -95,18 +151,21 @@ const readDefaultBranch = async (
|
|||||||
typeof metadata.default_branch !== "string" ||
|
typeof metadata.default_branch !== "string" ||
|
||||||
metadata.default_branch.length === 0
|
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;
|
return metadata.default_branch;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const inspectPublicGit = async (
|
export const inspectPublicGit = async (
|
||||||
source: PreparedPublicGitSource
|
source: PreparedPublicGitSource,
|
||||||
|
options: { readonly fetch?: Fetch } = {}
|
||||||
): Promise<PublicGitImportResult> => {
|
): Promise<PublicGitImportResult> => {
|
||||||
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(
|
const candidates = await Promise.all(
|
||||||
CONTEXT_KINDS.map((kind) =>
|
CONTEXT_KINDS.map((kind) =>
|
||||||
fetchContextDocument(source, defaultBranch, kind)
|
fetchContextDocument(fetcher, provider, source, defaultBranch, kind)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const documents = candidates.filter(
|
const documents = candidates.filter(
|
||||||
|
|||||||
@@ -491,7 +491,22 @@ describe("signalRouting begin issue", () => {
|
|||||||
token: TOKEN,
|
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 () => {
|
test("begin rejects invalid token", async () => {
|
||||||
|
|||||||
@@ -613,7 +613,14 @@ export const beginIssue = mutation({
|
|||||||
issueId: v.id("projectIssues"),
|
issueId: v.id("projectIssues"),
|
||||||
token: v.string(),
|
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);
|
requireAgent(args.token);
|
||||||
const issue = await ctx.db.get(args.issueId);
|
const issue = await ctx.db.get(args.issueId);
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
@@ -629,7 +636,11 @@ export const beginIssue = mutation({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (status === issue.status) {
|
if (status === issue.status) {
|
||||||
return status;
|
return {
|
||||||
|
dispatchRequired: false,
|
||||||
|
projectId: issue.projectId,
|
||||||
|
status,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
@@ -644,12 +655,50 @@ export const beginIssue = mutation({
|
|||||||
kind: "issue.queued",
|
kind: "issue.queued",
|
||||||
projectId: issue.projectId,
|
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({
|
export const getProjectContext = query({
|
||||||
|
|||||||
1
packages/env/src/agent.ts
vendored
1
packages/env/src/agent.ts
vendored
@@ -13,6 +13,7 @@ const agentEnvSchema = z.object({
|
|||||||
FLUE_DB_TOKEN: z.string().min(1),
|
FLUE_DB_TOKEN: z.string().min(1),
|
||||||
GITEA_TOKEN: z.string().min(1).optional(),
|
GITEA_TOKEN: z.string().min(1).optional(),
|
||||||
GITEA_URL: z.url().default("https://git.openputer.com"),
|
GITEA_URL: z.url().default("https://git.openputer.com"),
|
||||||
|
OPENROUTER_API_KEY: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AgentEnv = z.infer<typeof agentEnvSchema>;
|
export type AgentEnv = z.infer<typeof agentEnvSchema>;
|
||||||
|
|||||||
161
packages/ui/src/components/ai-elements/prompt-input.tsx
Normal file
161
packages/ui/src/components/ai-elements/prompt-input.tsx
Normal file
@@ -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<ComponentProps<"form">, "onSubmit"> & {
|
||||||
|
onSubmit: (
|
||||||
|
message: PromptInputMessage,
|
||||||
|
event: FormEvent<HTMLFormElement>
|
||||||
|
) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PromptInput = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: PromptInputProps) => {
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (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 (
|
||||||
|
<form
|
||||||
|
className={cn("w-full", className)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<InputGroup>{children}</InputGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const PromptInputBody = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PromptInputBodyProps) => (
|
||||||
|
<div className={cn("contents", className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PromptInputTextareaProps = ComponentProps<
|
||||||
|
typeof InputGroupTextarea
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const PromptInputTextarea = ({
|
||||||
|
className,
|
||||||
|
onKeyDown,
|
||||||
|
...props
|
||||||
|
}: PromptInputTextareaProps) => {
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||||
|
onKeyDown?.(event);
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.defaultPrevented ||
|
||||||
|
event.key !== "Enter" ||
|
||||||
|
event.shiftKey ||
|
||||||
|
event.nativeEvent.isComposing
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.form?.requestSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroupTextarea
|
||||||
|
className={cn("field-sizing-content", className)}
|
||||||
|
name="message"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
|
||||||
|
|
||||||
|
export const PromptInputButton = ({
|
||||||
|
className,
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}: PromptInputButtonProps) => (
|
||||||
|
<InputGroupButton
|
||||||
|
className={cn(className)}
|
||||||
|
size={size}
|
||||||
|
type="button"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
|
||||||
|
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 ?? <ArrowUpIcon className="size-4" />;
|
||||||
|
if (status === "streaming") {
|
||||||
|
content = <SquareIcon className="size-4 fill-current" />;
|
||||||
|
} else if (status === "submitted") {
|
||||||
|
content = <LoaderCircleIcon className="size-4 animate-spin" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroupButton
|
||||||
|
aria-busy={isBusy}
|
||||||
|
className={cn(className)}
|
||||||
|
disabled={disabled}
|
||||||
|
size={size}
|
||||||
|
type="submit"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</InputGroupButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
Mic,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -17,6 +16,23 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { FormEvent, ReactNode } from "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 {
|
import type {
|
||||||
MobileProjectView,
|
MobileProjectView,
|
||||||
MobileWorkspaceView,
|
MobileWorkspaceView,
|
||||||
@@ -36,21 +52,32 @@ export interface MobileWorkspaceRendererProps {
|
|||||||
createIssueBody: string;
|
createIssueBody: string;
|
||||||
createIssueTitle: string;
|
createIssueTitle: string;
|
||||||
data: MobileWorkspaceView;
|
data: MobileWorkspaceView;
|
||||||
|
modelId?: string;
|
||||||
|
modelLabel?: string;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onComposerChange: (value: string) => void;
|
onComposerChange: (value: string) => void;
|
||||||
|
onComposerMessageSubmit?: (message: string) => Promise<void>;
|
||||||
onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
onCreateBodyChange: (value: string) => void;
|
onCreateBodyChange: (value: string) => void;
|
||||||
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
onCreateIssueFromSignal?: () => void;
|
onCreateIssueFromSignal?: () => void;
|
||||||
onCreateTitleChange: (value: string) => void;
|
onCreateTitleChange: (value: string) => void;
|
||||||
onManageProjects?: () => void;
|
onManageProjects?: () => void;
|
||||||
|
onProjectManagerClose?: () => void;
|
||||||
onOpenAssistant?: () => void;
|
onOpenAssistant?: () => void;
|
||||||
onOpenUnit?: (workUnitId?: string) => void;
|
onOpenUnit?: (workUnitId?: string) => void;
|
||||||
onProjectSelect?: (projectId: string) => void;
|
onProjectSelect?: (projectId: string) => void;
|
||||||
|
onRepositoryChange?: (value: string) => void;
|
||||||
|
onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onSettingsClose?: () => void;
|
||||||
|
onSettingsOpen?: () => void;
|
||||||
onReviewUnit?: (reviewUrl: string) => void;
|
onReviewUnit?: (reviewUrl: string) => void;
|
||||||
onStartUnit?: (workUnitId: string) => void;
|
onStartUnit?: (workUnitId: string) => void;
|
||||||
onViewWork?: () => void;
|
onViewWork?: () => void;
|
||||||
pendingAction?: string | null;
|
pendingAction?: string | null;
|
||||||
|
projectManagerOpen?: boolean;
|
||||||
|
repositoryValue?: string;
|
||||||
|
settingsOpen?: boolean;
|
||||||
statusMessage?: string;
|
statusMessage?: string;
|
||||||
variant: MobileWorkspaceVariant;
|
variant: MobileWorkspaceVariant;
|
||||||
}
|
}
|
||||||
@@ -294,7 +321,9 @@ const ProjectSwitcher = ({
|
|||||||
) : (
|
) : (
|
||||||
data.projects.map((project: MobileProjectView) => (
|
data.projects.map((project: MobileProjectView) => (
|
||||||
<option key={project.id} value={project.id}>
|
<option key={project.id} value={project.id}>
|
||||||
{project.name} {project.connected ? "" : "(not connected)"}
|
{project.repositoryPath ?? project.name}
|
||||||
|
{project.host ? ` · ${project.host}` : ""}
|
||||||
|
{project.connected ? "" : " (not connected)"}
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -310,6 +339,184 @@ const ProjectSwitcher = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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<HTMLFormElement>) => void;
|
||||||
|
readonly onCreateTitleChange: (value: string) => void;
|
||||||
|
readonly onProjectSelect?: (projectId: string) => void;
|
||||||
|
readonly onRepositoryChange?: (value: string) => void;
|
||||||
|
readonly onRepositoryConnect?: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
readonly pendingAction?: string | null;
|
||||||
|
readonly repositoryValue?: string;
|
||||||
|
readonly statusMessage?: string;
|
||||||
|
}) => {
|
||||||
|
const connecting = pendingAction === "connect";
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Project workspace"
|
||||||
|
className="absolute inset-0 z-30 flex flex-col bg-[#f7f8f5] text-[#11110f]"
|
||||||
|
>
|
||||||
|
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[18px] font-semibold">Project workspace</h2>
|
||||||
|
<p className="text-[11px] text-[#807b76]">
|
||||||
|
Connect repositories and create durable work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Close project workspace"
|
||||||
|
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
<div className="rounded-[22px] bg-white p-4">
|
||||||
|
<p className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase">
|
||||||
|
Connected project
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
aria-label="Active project"
|
||||||
|
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
|
||||||
|
disabled={data.projects.length === 0}
|
||||||
|
onChange={(event) => onProjectSelect?.(event.target.value)}
|
||||||
|
value={data.selectedProjectId ?? ""}
|
||||||
|
>
|
||||||
|
{data.projects.length === 0 ? (
|
||||||
|
<option value="">No connected projects</option>
|
||||||
|
) : (
|
||||||
|
data.projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.repositoryPath ?? project.name}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<form className="mt-4" onSubmit={onRepositoryConnect}>
|
||||||
|
<label
|
||||||
|
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
|
||||||
|
htmlFor="mobile-repository-url"
|
||||||
|
>
|
||||||
|
Connect another repository
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
aria-label="Public Git repository URL"
|
||||||
|
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[12px] outline-none placeholder:text-[#aaa7a3]"
|
||||||
|
disabled={connecting}
|
||||||
|
id="mobile-repository-url"
|
||||||
|
onChange={(event) => onRepositoryChange?.(event.target.value)}
|
||||||
|
placeholder="https://git.openputer.com/owner/repo.git"
|
||||||
|
required
|
||||||
|
type="url"
|
||||||
|
value={repositoryValue}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="mt-2 flex h-10 w-full items-center justify-center gap-2 rounded-[14px] bg-[#c8ff00] text-[12px] font-semibold text-black disabled:opacity-50"
|
||||||
|
disabled={connecting || !repositoryValue.trim()}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{connecting ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{connecting ? "Connecting…" : "Connect repository"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{data.selectedProjectId ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<CreateWorkForm
|
||||||
|
body={createIssueBody}
|
||||||
|
onBodyChange={onCreateBodyChange}
|
||||||
|
onSubmit={onCreateIssue}
|
||||||
|
onTitleChange={onCreateTitleChange}
|
||||||
|
pending={pendingAction === "issue"}
|
||||||
|
title={createIssueTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{statusMessage ? (
|
||||||
|
<p className="mt-3 text-center text-[11px] text-[#807b76]">
|
||||||
|
{statusMessage}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatSettingsPanel = ({
|
||||||
|
modelId = "openrouter/openai/gpt-oss-20b:free",
|
||||||
|
modelLabel = "GPT-OSS 20B Free",
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
readonly modelId?: string;
|
||||||
|
readonly modelLabel?: string;
|
||||||
|
readonly onClose?: () => void;
|
||||||
|
}) => (
|
||||||
|
<section
|
||||||
|
aria-label="Chat settings"
|
||||||
|
className="absolute inset-0 z-40 flex flex-col bg-[#f7f8f5] text-[#11110f]"
|
||||||
|
>
|
||||||
|
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[18px] font-semibold">Settings</h2>
|
||||||
|
<p className="text-[11px] text-[#807b76]">
|
||||||
|
Configure how Zopu responds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Close settings"
|
||||||
|
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="rounded-[22px] bg-white p-4">
|
||||||
|
<label
|
||||||
|
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
|
||||||
|
htmlFor="mobile-chat-model"
|
||||||
|
>
|
||||||
|
Chat model
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
|
||||||
|
id="mobile-chat-model"
|
||||||
|
value={modelId}
|
||||||
|
>
|
||||||
|
<option value={modelId}>{modelLabel}</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-2 text-[11px] leading-4 text-[#807b76]">
|
||||||
|
A tool-capable free OpenRouter model is active for the local Zopu
|
||||||
|
agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
const WorkUnitActions = ({
|
const WorkUnitActions = ({
|
||||||
onReview,
|
onReview,
|
||||||
onStart,
|
onStart,
|
||||||
@@ -1224,16 +1431,126 @@ const assistantStatusDotClass: Record<
|
|||||||
red: "bg-[#e04b3f]",
|
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 (
|
||||||
|
<article
|
||||||
|
className={cn(
|
||||||
|
"mt-5 rounded-[22px] p-[14px]",
|
||||||
|
workUnitToneClass[workUnit.tone]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="block w-full text-left"
|
||||||
|
onClick={() => onOpen?.(workUnit.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-semibold uppercase",
|
||||||
|
workUnitStatusClass[workUnit.tone]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{workUnit.statusLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] opacity-60">{workUnit.code}</span>
|
||||||
|
<ChevronRight className="ml-auto size-4" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-2 text-[17px] leading-5 font-semibold">
|
||||||
|
{workUnit.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 line-clamp-2 text-[12px] leading-4 opacity-70">
|
||||||
|
{workUnit.summary}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 h-1.5 rounded-full bg-current/10">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-current"
|
||||||
|
style={{ width: `${workUnit.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex text-[10px] font-semibold">
|
||||||
|
<span>{workUnit.progress}% complete</span>
|
||||||
|
<span className="ml-auto opacity-60">{workUnit.nextAction}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<WorkUnitActions
|
||||||
|
onReview={onReview}
|
||||||
|
onStart={onStart}
|
||||||
|
pending={pendingAction === `issue:${workUnit.id}`}
|
||||||
|
workUnit={workUnit}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const CharacterChat = ({
|
const CharacterChat = ({
|
||||||
|
createIssueBody,
|
||||||
|
createIssueTitle,
|
||||||
data,
|
data,
|
||||||
|
modelId,
|
||||||
|
modelLabel,
|
||||||
onChange,
|
onChange,
|
||||||
|
onMessageSubmit,
|
||||||
onCreateIssueFromSignal,
|
onCreateIssueFromSignal,
|
||||||
onSubmit,
|
onCreateBodyChange,
|
||||||
|
onCreateIssue,
|
||||||
|
onCreateTitleChange,
|
||||||
|
onManageProjects,
|
||||||
|
onOpen,
|
||||||
|
onProjectManagerClose,
|
||||||
|
onProjectSelect,
|
||||||
|
onRepositoryChange,
|
||||||
|
onRepositoryConnect,
|
||||||
|
onReview,
|
||||||
|
onSettingsClose,
|
||||||
|
onSettingsOpen,
|
||||||
|
onStart,
|
||||||
|
pendingAction,
|
||||||
|
projectManagerOpen,
|
||||||
|
repositoryValue,
|
||||||
|
settingsOpen,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
value,
|
value,
|
||||||
}: ComposerProps & {
|
}: ComposerProps & {
|
||||||
|
createIssueBody: string;
|
||||||
|
createIssueTitle: string;
|
||||||
data: MobileWorkspaceView;
|
data: MobileWorkspaceView;
|
||||||
|
modelId?: string;
|
||||||
|
modelLabel?: string;
|
||||||
|
onCreateBodyChange: (value: string) => void;
|
||||||
|
onCreateIssue: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
onCreateIssueFromSignal?: () => 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<HTMLFormElement>) => void;
|
||||||
|
onMessageSubmit?: (message: string) => Promise<void>;
|
||||||
|
onReview?: (reviewUrl: string) => void;
|
||||||
|
onSettingsClose?: () => void;
|
||||||
|
onSettingsOpen?: () => void;
|
||||||
|
onStart?: (workUnitId: string) => void;
|
||||||
|
pendingAction?: string | null;
|
||||||
|
projectManagerOpen?: boolean;
|
||||||
|
repositoryValue?: string;
|
||||||
|
settingsOpen?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mx-auto h-[min(100dvh,858px)] w-full max-w-[390px] overflow-hidden bg-black">
|
<div className="mx-auto h-[min(100dvh,858px)] w-full max-w-[390px] overflow-hidden bg-black">
|
||||||
<main className="relative h-full overflow-hidden rounded-[30px] bg-white px-4 text-[#11110f]">
|
<main className="relative h-full overflow-hidden rounded-[30px] bg-white px-4 text-[#11110f]">
|
||||||
@@ -1253,146 +1570,218 @@ const CharacterChat = ({
|
|||||||
{data.assistant.statusLabel}
|
{data.assistant.statusLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MoreHorizontal className="ml-auto size-5" />
|
<button
|
||||||
|
aria-label="Open settings"
|
||||||
|
className="ml-auto flex size-9 items-center justify-center rounded-full bg-[#f2f3ef]"
|
||||||
|
onClick={onSettingsOpen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-5" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<section className="absolute inset-x-4 top-[106px] bottom-[105px] overflow-y-auto pb-6">
|
<div className="absolute inset-x-0 top-[82px]">
|
||||||
{data.assistant.messages.length === 0 ? (
|
<ProjectSwitcher
|
||||||
<>
|
data={data}
|
||||||
<div className="w-fit rounded-full bg-[#f4f4f2] px-2.5 py-1.5 text-[10px] text-[#aaa7a3]">
|
onManageProjects={onManageProjects}
|
||||||
TODAY
|
onProjectSelect={onProjectSelect}
|
||||||
</div>
|
/>
|
||||||
<div className="mt-10 flex items-center gap-2 text-[14px] text-[#55524f]">
|
</div>
|
||||||
<span className="flex size-6 items-center justify-center rounded-[8px] bg-[#f4f4f2]">
|
<Conversation className="absolute inset-x-4 top-[127px] bottom-[105px]">
|
||||||
<Sparkles className="size-4" />
|
<ConversationContent className="min-h-full gap-0 p-0 pb-6">
|
||||||
</span>
|
{data.assistant.messages.length === 0 ? (
|
||||||
<strong>Zopu</strong>
|
<>
|
||||||
<span className="text-[#b5b2ae]">• a fresh start</span>
|
<div className="w-fit rounded-full bg-[#f4f4f2] px-2.5 py-1.5 text-[10px] text-[#aaa7a3]">
|
||||||
</div>
|
TODAY
|
||||||
<p className="mt-3 text-[17px] leading-5">
|
</div>
|
||||||
Good morning. What are we making clearer today?
|
<div className="mt-10 flex items-center gap-2 text-[14px] text-[#55524f]">
|
||||||
</p>
|
<span className="flex size-6 items-center justify-center rounded-[8px] bg-[#f4f4f2]">
|
||||||
<div className="mt-4 flex gap-7 text-[12px] font-semibold text-[#55524f]">
|
<Sparkles className="size-4" />
|
||||||
<button
|
</span>
|
||||||
className="flex items-center gap-2"
|
<strong>Zopu</strong>
|
||||||
onClick={() => onChange("Summarize my latest project signals")}
|
<span className="text-[#b5b2ae]">• a fresh start</span>
|
||||||
type="button"
|
</div>
|
||||||
>
|
<p className="mt-3 text-[17px] leading-5">
|
||||||
<FileText className="size-4" />
|
Good morning. What are we making clearer today?
|
||||||
Summarize
|
</p>
|
||||||
</button>
|
<div className="mt-4 flex gap-7 text-[12px] font-semibold text-[#55524f]">
|
||||||
<button
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => onChange("Plan the next steps for my project")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ListChecks className="size-4" />
|
|
||||||
Plan
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={() => onChange("Explore risks in my active work")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
◉ Explore
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{data.latestSignal ? (
|
|
||||||
<section className="mt-12 rounded-[22px] bg-[#f4f4f2] p-[14px]">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#c8ff00]">
|
|
||||||
↗
|
|
||||||
</span>
|
|
||||||
<div className="ml-3 min-w-0">
|
|
||||||
<h2 className="truncate text-[15px] leading-4 font-semibold">
|
|
||||||
{data.latestSignal.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-[10px] text-[#b5b2ae]">
|
|
||||||
Latest project signal
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="ml-auto size-4" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-[13px] leading-[18px]">
|
|
||||||
{data.latestSignal.summary}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
className="mt-3 h-8 rounded-[11px] bg-black px-3 text-[11px] font-semibold text-white"
|
className="flex items-center gap-2"
|
||||||
onClick={onCreateIssueFromSignal}
|
onClick={() =>
|
||||||
|
onChange("Summarize my latest project signals")
|
||||||
|
}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Create work from signal
|
<FileText className="size-4" />
|
||||||
|
Summarize
|
||||||
</button>
|
</button>
|
||||||
</section>
|
<button
|
||||||
) : null}
|
className="flex items-center gap-2"
|
||||||
</>
|
onClick={() => onChange("Plan the next steps for my project")}
|
||||||
) : (
|
type="button"
|
||||||
<div className="grid gap-5">
|
>
|
||||||
{data.assistant.messages.map((message) =>
|
<ListChecks className="size-4" />
|
||||||
message.role === "user" ? (
|
Plan
|
||||||
<div
|
</button>
|
||||||
className="ml-auto max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[16px] leading-6 whitespace-pre-wrap text-white"
|
<button
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => onChange("Explore risks in my active work")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
◉ Explore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{data.latestSignal ? (
|
||||||
|
<section className="mt-12 rounded-[22px] bg-[#f4f4f2] p-[14px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#c8ff00]">
|
||||||
|
↗
|
||||||
|
</span>
|
||||||
|
<div className="ml-3 min-w-0">
|
||||||
|
<h2 className="truncate text-[15px] leading-4 font-semibold">
|
||||||
|
{data.latestSignal.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-[#b5b2ae]">
|
||||||
|
Latest project signal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="ml-auto size-4" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[13px] leading-[18px]">
|
||||||
|
{data.latestSignal.summary}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="mt-3 h-8 rounded-[11px] bg-black px-3 text-[11px] font-semibold text-white"
|
||||||
|
onClick={onCreateIssueFromSignal}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Create work from signal
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{data.assistant.messages.map((message) => (
|
||||||
|
<Message
|
||||||
|
className="max-w-full gap-0"
|
||||||
|
from={message.role}
|
||||||
key={message.id}
|
key={message.id}
|
||||||
>
|
>
|
||||||
{message.text}
|
<MessageContent
|
||||||
</div>
|
className={cn(
|
||||||
) : (
|
"max-w-full gap-0 overflow-visible p-0",
|
||||||
<div key={message.id}>
|
message.role === "user"
|
||||||
<div className="flex items-center gap-2">
|
? "ml-auto w-fit max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[16px] leading-6 text-white group-[.is-user]:rounded-[22px] group-[.is-user]:bg-[#0c0c0b] group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-white"
|
||||||
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
|
: "w-full bg-transparent text-[#11110f] dark:text-[#11110f]"
|
||||||
<Sparkles className="size-4" />
|
)}
|
||||||
</span>
|
>
|
||||||
<strong className="text-[14px]">Zopu</strong>
|
{message.role === "assistant" ? (
|
||||||
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
|
<>
|
||||||
THINKING PARTNER
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
|
||||||
</div>
|
<Sparkles className="size-4" />
|
||||||
<p className="mt-3 text-[16px] leading-[21px] whitespace-pre-wrap">
|
</span>
|
||||||
{message.text}
|
<strong className="text-[14px]">Zopu</strong>
|
||||||
</p>
|
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
|
||||||
</div>
|
THINKING PARTNER
|
||||||
)
|
</span>
|
||||||
)}
|
</div>
|
||||||
{data.assistant.isBusy ? (
|
<MessageResponse
|
||||||
<p className="text-[13px] text-[#807b76]">Zopu is thinking…</p>
|
className="mt-3 text-[16px] leading-[21px] text-[#11110f] dark:text-[#11110f]"
|
||||||
) : null}
|
isAnimating={data.assistant.isBusy}
|
||||||
</div>
|
>
|
||||||
)}
|
{message.text}
|
||||||
</section>
|
</MessageResponse>
|
||||||
<form
|
</>
|
||||||
className="absolute inset-x-3 bottom-[34px] flex h-[53px] items-center rounded-[27px] bg-[#f4f4f2] p-2"
|
) : (
|
||||||
onSubmit={onSubmit}
|
<span className="whitespace-pre-wrap">
|
||||||
>
|
{message.text}
|
||||||
<button
|
</span>
|
||||||
aria-label="Attach file"
|
)}
|
||||||
className="flex size-10 items-center justify-center rounded-full bg-white"
|
</MessageContent>
|
||||||
type="button"
|
</Message>
|
||||||
>
|
))}
|
||||||
<Paperclip className="size-5" />
|
{data.assistant.isBusy ? (
|
||||||
</button>
|
<p className="text-[13px] text-[#807b76]">Zopu is thinking…</p>
|
||||||
<input
|
) : null}
|
||||||
className="min-w-0 flex-1 bg-transparent px-2 text-[14px] outline-none placeholder:text-[#b5b2ae]"
|
</div>
|
||||||
onChange={(event) => onChange(event.target.value)}
|
)}
|
||||||
placeholder="Ask anything..."
|
<ChatWorkUnitCard
|
||||||
value={value}
|
onOpen={onOpen}
|
||||||
|
onReview={onReview}
|
||||||
|
onStart={onStart}
|
||||||
|
pendingAction={pendingAction}
|
||||||
|
workUnit={data.selectedWorkUnit}
|
||||||
|
/>
|
||||||
|
</ConversationContent>
|
||||||
|
<ConversationScrollButton
|
||||||
|
aria-label="Scroll to latest message"
|
||||||
|
className="bottom-2 size-9 rounded-full border border-[#dededb] bg-white text-black shadow-sm"
|
||||||
/>
|
/>
|
||||||
<button
|
</Conversation>
|
||||||
aria-label="Record voice message"
|
<PromptInput
|
||||||
className="flex size-10 items-center justify-center rounded-full bg-white"
|
className="absolute inset-x-3 bottom-[34px] w-auto [&_[data-slot=input-group]]:min-h-[56px] [&_[data-slot=input-group]]:rounded-full [&_[data-slot=input-group]]:border-0 [&_[data-slot=input-group]]:bg-[#f4f4f2] [&_[data-slot=input-group]]:px-2 [&_[data-slot=input-group]]:py-1.5 [&_[data-slot=input-group]]:shadow-none [&_[data-slot=input-group]]:dark:bg-[#f4f4f2]"
|
||||||
type="button"
|
onSubmit={async (message) => {
|
||||||
>
|
if (onMessageSubmit) {
|
||||||
<Mic className="size-5" />
|
await onMessageSubmit(message.text);
|
||||||
</button>
|
return;
|
||||||
<button
|
}
|
||||||
aria-label="Send"
|
onChange(message.text);
|
||||||
className="ml-1 flex size-10 items-center justify-center rounded-full bg-black text-white"
|
}}
|
||||||
type="submit"
|
>
|
||||||
>
|
<PromptInputBody>
|
||||||
<ArrowUp className="size-5" />
|
<PromptInputButton
|
||||||
</button>
|
aria-label="Attach file"
|
||||||
</form>
|
className="size-11 shrink-0 rounded-full bg-white text-black hover:bg-white/80"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<Paperclip className="size-5" />
|
||||||
|
</PromptInputButton>
|
||||||
|
<PromptInputTextarea
|
||||||
|
aria-label="Ask anything..."
|
||||||
|
className="max-h-20 min-h-10 flex-1 resize-none bg-transparent px-2 py-2.5 text-[15px] leading-5 shadow-none outline-none placeholder:text-[#aaa7a3] dark:bg-transparent"
|
||||||
|
onChange={(event) => onChange(event.currentTarget.value)}
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<PromptInputSubmit
|
||||||
|
aria-label="Send"
|
||||||
|
className="size-11 shrink-0 rounded-full bg-black text-white hover:bg-black/85 disabled:bg-black disabled:text-white disabled:opacity-100"
|
||||||
|
disabled={!value.trim() || data.assistant.isBusy}
|
||||||
|
size="icon-sm"
|
||||||
|
status={data.assistant.isBusy ? "streaming" : "ready"}
|
||||||
|
>
|
||||||
|
<ArrowUp className="size-5" />
|
||||||
|
</PromptInputSubmit>
|
||||||
|
</PromptInputBody>
|
||||||
|
</PromptInput>
|
||||||
<p className="absolute inset-x-0 bottom-[13px] text-center text-[9px] text-[#b5b2ae]">
|
<p className="absolute inset-x-0 bottom-[13px] text-center text-[9px] text-[#b5b2ae]">
|
||||||
{statusMessage ?? "Press Enter to send · Shift + Enter for a new line"}
|
{statusMessage ?? "Press Enter to send · Shift + Enter for a new line"}
|
||||||
</p>
|
</p>
|
||||||
|
{projectManagerOpen ? (
|
||||||
|
<ProjectManagementPanel
|
||||||
|
createIssueBody={createIssueBody}
|
||||||
|
createIssueTitle={createIssueTitle}
|
||||||
|
data={data}
|
||||||
|
onClose={onProjectManagerClose}
|
||||||
|
onCreateBodyChange={onCreateBodyChange}
|
||||||
|
onCreateIssue={onCreateIssue}
|
||||||
|
onCreateTitleChange={onCreateTitleChange}
|
||||||
|
onProjectSelect={onProjectSelect}
|
||||||
|
onRepositoryChange={onRepositoryChange}
|
||||||
|
onRepositoryConnect={onRepositoryConnect}
|
||||||
|
pendingAction={pendingAction}
|
||||||
|
repositoryValue={repositoryValue}
|
||||||
|
statusMessage={statusMessage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{settingsOpen ? (
|
||||||
|
<ChatSettingsPanel
|
||||||
|
modelId={modelId}
|
||||||
|
modelLabel={modelLabel}
|
||||||
|
onClose={onSettingsClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1402,21 +1791,32 @@ export const MobileWorkspaceScreenRenderer = ({
|
|||||||
createIssueBody,
|
createIssueBody,
|
||||||
createIssueTitle,
|
createIssueTitle,
|
||||||
data,
|
data,
|
||||||
|
modelId,
|
||||||
|
modelLabel,
|
||||||
onBack,
|
onBack,
|
||||||
onComposerChange,
|
onComposerChange,
|
||||||
|
onComposerMessageSubmit,
|
||||||
onComposerSubmit,
|
onComposerSubmit,
|
||||||
onCreateBodyChange,
|
onCreateBodyChange,
|
||||||
onCreateIssue,
|
onCreateIssue,
|
||||||
onCreateIssueFromSignal,
|
onCreateIssueFromSignal,
|
||||||
onCreateTitleChange,
|
onCreateTitleChange,
|
||||||
onManageProjects,
|
onManageProjects,
|
||||||
|
onProjectManagerClose,
|
||||||
onOpenAssistant,
|
onOpenAssistant,
|
||||||
onOpenUnit,
|
onOpenUnit,
|
||||||
onProjectSelect,
|
onProjectSelect,
|
||||||
|
onRepositoryChange,
|
||||||
|
onRepositoryConnect,
|
||||||
onReviewUnit,
|
onReviewUnit,
|
||||||
|
onSettingsClose,
|
||||||
|
onSettingsOpen,
|
||||||
onStartUnit,
|
onStartUnit,
|
||||||
onViewWork,
|
onViewWork,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
|
projectManagerOpen,
|
||||||
|
repositoryValue,
|
||||||
|
settingsOpen,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
variant,
|
variant,
|
||||||
}: MobileWorkspaceRendererProps) => {
|
}: MobileWorkspaceRendererProps) => {
|
||||||
@@ -1432,8 +1832,30 @@ export const MobileWorkspaceScreenRenderer = ({
|
|||||||
return (
|
return (
|
||||||
<CharacterChat
|
<CharacterChat
|
||||||
{...composerProps}
|
{...composerProps}
|
||||||
|
createIssueBody={createIssueBody}
|
||||||
|
createIssueTitle={createIssueTitle}
|
||||||
data={data}
|
data={data}
|
||||||
|
modelId={modelId}
|
||||||
|
modelLabel={modelLabel}
|
||||||
|
onCreateBodyChange={onCreateBodyChange}
|
||||||
|
onCreateIssue={onCreateIssue}
|
||||||
onCreateIssueFromSignal={onCreateIssueFromSignal}
|
onCreateIssueFromSignal={onCreateIssueFromSignal}
|
||||||
|
onCreateTitleChange={onCreateTitleChange}
|
||||||
|
onManageProjects={onManageProjects}
|
||||||
|
onMessageSubmit={onComposerMessageSubmit}
|
||||||
|
onOpen={onOpenUnit}
|
||||||
|
onProjectManagerClose={onProjectManagerClose}
|
||||||
|
onProjectSelect={onProjectSelect}
|
||||||
|
onRepositoryChange={onRepositoryChange}
|
||||||
|
onRepositoryConnect={onRepositoryConnect}
|
||||||
|
onReview={onReviewUnit}
|
||||||
|
onSettingsClose={onSettingsClose}
|
||||||
|
onSettingsOpen={onSettingsOpen}
|
||||||
|
onStart={onStartUnit}
|
||||||
|
pendingAction={pendingAction}
|
||||||
|
projectManagerOpen={projectManagerOpen}
|
||||||
|
repositoryValue={repositoryValue}
|
||||||
|
settingsOpen={settingsOpen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ export type MobileWorkUnitTone = "blue" | "green" | "orange" | "purple";
|
|||||||
|
|
||||||
export interface MobileProjectView {
|
export interface MobileProjectView {
|
||||||
readonly connected: boolean;
|
readonly connected: boolean;
|
||||||
|
readonly host?: string;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly repositoryPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MobileWorkUnitView {
|
export interface MobileWorkUnitView {
|
||||||
|
|||||||
Reference in New Issue
Block a user