feat: integrate mobile work chat and Gitea delivery
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"@rivet-dev/agentos": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"rivetkit": "0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0"
|
||||
"rivetkit": "2.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
|
||||
@@ -2,26 +2,27 @@ import { env } from "@code/env/server";
|
||||
import { Console, Effect, FiberSet, Result, Stream } from "effect";
|
||||
|
||||
import { makeAgentOsRuntime } from "./agent-os";
|
||||
import { ConvexControlPlane, type DaemonCommand } from "./convex";
|
||||
import { ConvexControlPlane } from "./convex";
|
||||
import type { DaemonCommand } from "./convex";
|
||||
|
||||
const errorMessage = (cause: unknown) =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
export const runDaemon = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
Effect.gen(function* runDaemon() {
|
||||
const controlPlane = yield* ConvexControlPlane;
|
||||
const agentOs = yield* makeAgentOsRuntime;
|
||||
const sessionId = crypto.randomUUID();
|
||||
const hostname = yield* Effect.promise(() => import("node:os")).pipe(
|
||||
Effect.map((os) => os.hostname())
|
||||
);
|
||||
const fibers = yield* FiberSet.make<void>();
|
||||
const fibers = yield* FiberSet.make();
|
||||
|
||||
yield* controlPlane.connect({
|
||||
sessionId,
|
||||
architecture: process.arch,
|
||||
hostname,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
sessionId,
|
||||
});
|
||||
yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`);
|
||||
|
||||
@@ -40,33 +41,37 @@ export const runDaemon = Effect.scoped(
|
||||
Effect.forkScoped
|
||||
);
|
||||
|
||||
const processCommand = Effect.fn("Daemon.processCommand")(function* (
|
||||
candidate: DaemonCommand
|
||||
) {
|
||||
const command = yield* controlPlane.claim(candidate._id, sessionId);
|
||||
if (!command) {
|
||||
return;
|
||||
const processCommand = Effect.fn("Daemon.processCommand")(
|
||||
function* processCommand(candidate: DaemonCommand) {
|
||||
const command = yield* controlPlane.claim(candidate._id, sessionId);
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
const started = yield* controlPlane.start(command._id, sessionId);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
yield* controlPlane.recordEvent({
|
||||
commandId: command._id,
|
||||
data: { actorKey: command.actorKey, method: command.method },
|
||||
kind: "command.started",
|
||||
});
|
||||
const result = yield* agentOs.execute(command).pipe(Effect.result);
|
||||
if (Result.isSuccess(result)) {
|
||||
yield* controlPlane.succeed(
|
||||
command._id,
|
||||
sessionId,
|
||||
result.success ?? null
|
||||
);
|
||||
return;
|
||||
}
|
||||
yield* controlPlane.fail(
|
||||
command._id,
|
||||
sessionId,
|
||||
errorMessage(result.failure)
|
||||
);
|
||||
}
|
||||
const started = yield* controlPlane.start(command._id, sessionId);
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
yield* controlPlane.recordEvent({
|
||||
commandId: command._id,
|
||||
kind: "command.started",
|
||||
data: { method: command.method, actorKey: command.actorKey },
|
||||
});
|
||||
const result = yield* agentOs.execute(command).pipe(Effect.result);
|
||||
if (Result.isSuccess(result)) {
|
||||
yield* controlPlane.succeed(command._id, sessionId, result.success);
|
||||
return;
|
||||
}
|
||||
yield* controlPlane.fail(
|
||||
command._id,
|
||||
sessionId,
|
||||
errorMessage(result.failure)
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
yield* controlPlane.commands.pipe(
|
||||
Stream.runForEach((commands) =>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { signOutWeb, useWebAuth } from "@code/auth/web";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
MobileAssistantChatScreen,
|
||||
MobileExpandedWorkScreen,
|
||||
MobileHomeScreen,
|
||||
MobileWorkChatScreen,
|
||||
MobileWorkListScreen,
|
||||
MobileWorkStackScreen,
|
||||
MobileWorkUnitDetailScreen,
|
||||
} from "@code/ui/components/mobile-product";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
||||
import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack";
|
||||
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
||||
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
|
||||
import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message";
|
||||
import { getUserInitials } from "@/lib/users/get-user-initials";
|
||||
|
||||
export type MobileFlowScreen =
|
||||
| "assistant-chat"
|
||||
| "home"
|
||||
| "stack-home"
|
||||
| "work-chat"
|
||||
| "work-list"
|
||||
| "work-unit-detail";
|
||||
|
||||
@@ -26,6 +34,7 @@ interface MobileFlowPageProps {
|
||||
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { workUnitId } = useParams();
|
||||
const auth = useWebAuth();
|
||||
const projectWorkspace = useMobileProjectWorkspace({
|
||||
selectedWorkUnitId: workUnitId,
|
||||
});
|
||||
@@ -34,8 +43,21 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
projectWorkspace.raiseIssue({ body, title }),
|
||||
onSend: projectWorkspace.sendMessage,
|
||||
});
|
||||
const workStack = useMobileWorkStack(projectWorkspace.data.workUnits);
|
||||
const workPath = "/work";
|
||||
const chatPath = "/chat";
|
||||
const handleRestoreChecked = workStack.restoreChecked;
|
||||
const handleWorkUnitChecked = workStack.markChecked;
|
||||
const handleWorkUnitSentBack = workStack.sendToBack;
|
||||
const user = auth.status === "authenticated" ? auth.user : undefined;
|
||||
const handleSignOut = async () => {
|
||||
await signOutWeb();
|
||||
navigate("/login");
|
||||
};
|
||||
const statusMessage = getMobileStatusMessage({
|
||||
assistantError: projectWorkspace.error?.message,
|
||||
localStatus: workspace.statusMessage,
|
||||
});
|
||||
|
||||
const handleOpenUnit = (selectedWorkUnitId?: string) => {
|
||||
const targetId =
|
||||
@@ -48,7 +70,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
workspace.setExpanded(true);
|
||||
return;
|
||||
}
|
||||
navigate(`/work/${targetId}`);
|
||||
navigate(`/chat/${targetId}`);
|
||||
};
|
||||
|
||||
const screenProps = {
|
||||
@@ -58,7 +80,7 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
data: projectWorkspace.data,
|
||||
modelId: MODEL_ID,
|
||||
modelLabel: MODEL_LABEL,
|
||||
onBack: () => navigate(workPath),
|
||||
onBack: () => navigate(chatPath),
|
||||
onComposerChange: workspace.handleComposerChange,
|
||||
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
|
||||
onComposerSubmit: workspace.handleComposerSubmit,
|
||||
@@ -99,12 +121,55 @@ export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
||||
projectManagerOpen: workspace.projectManagerOpen,
|
||||
repositoryValue: projectWorkspace.projectWorkspace.repository,
|
||||
settingsOpen: workspace.settingsOpen,
|
||||
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
|
||||
statusMessage,
|
||||
};
|
||||
|
||||
if (screen === "assistant-chat") {
|
||||
return <MobileAssistantChatScreen {...screenProps} />;
|
||||
}
|
||||
if (screen === "stack-home") {
|
||||
return (
|
||||
<MobileWorkStackScreen
|
||||
checkedCount={workStack.checkedCount}
|
||||
composerValue={workspace.composerValue}
|
||||
data={projectWorkspace.data}
|
||||
modelId={MODEL_ID}
|
||||
modelLabel={MODEL_LABEL}
|
||||
onComposerChange={workspace.handleComposerChange}
|
||||
onComposerSubmit={workspace.handleComposerSubmit}
|
||||
onOpenUnit={(selectedWorkUnitId) =>
|
||||
navigate(`/chat/${selectedWorkUnitId}`)
|
||||
}
|
||||
onRestoreChecked={handleRestoreChecked}
|
||||
onSettingsClose={() => workspace.setSettingsOpen(false)}
|
||||
onSettingsOpen={() => workspace.setSettingsOpen(true)}
|
||||
onSignOut={() => {
|
||||
void handleSignOut();
|
||||
}}
|
||||
onWorkUnitChecked={handleWorkUnitChecked}
|
||||
onWorkUnitSentBack={handleWorkUnitSentBack}
|
||||
settingsOpen={workspace.settingsOpen}
|
||||
statusMessage={statusMessage}
|
||||
userEmail={user?.email}
|
||||
userInitials={getUserInitials(user?.name)}
|
||||
userName={user?.name}
|
||||
workUnits={workStack.visibleWorkUnits}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "work-chat") {
|
||||
return (
|
||||
<MobileWorkChatScreen
|
||||
composerValue={workspace.composerValue}
|
||||
data={projectWorkspace.data}
|
||||
onBack={() => navigate(chatPath)}
|
||||
onComposerChange={workspace.handleComposerChange}
|
||||
onComposerMessageSubmit={workspace.handleComposerMessageSubmit}
|
||||
onComposerSubmit={workspace.handleComposerSubmit}
|
||||
statusMessage={statusMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (screen === "home") {
|
||||
return <MobileHomeScreen {...screenProps} />;
|
||||
}
|
||||
|
||||
@@ -13,24 +13,45 @@ import { STATUS_COPY } from "@/lib/chat/constants";
|
||||
import { getMessageText } from "@/lib/chat/transforms";
|
||||
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
|
||||
|
||||
const WORK_UNIT_SCOPE_PATTERN =
|
||||
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
|
||||
|
||||
const toAssistantMessages = (
|
||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
|
||||
): MobileAssistantMessageView[] =>
|
||||
messages.flatMap((message) => {
|
||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
|
||||
selectedWorkUnitId?: string
|
||||
): MobileAssistantMessageView[] => {
|
||||
const result: MobileAssistantMessageView[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant" && message.role !== "user") {
|
||||
return [];
|
||||
continue;
|
||||
}
|
||||
const text = getMessageText(message);
|
||||
return text
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const rawText = getMessageText(message);
|
||||
if (!rawText) {
|
||||
continue;
|
||||
}
|
||||
if (!selectedWorkUnitId) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
continue;
|
||||
}
|
||||
if (message.role === "user") {
|
||||
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
|
||||
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
|
||||
result.length = 0;
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
text: match.groups.message?.trim() ?? rawText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (result.length > 0) {
|
||||
result.push({ id: message.id, role: message.role, text: rawText });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getStatusTone = (
|
||||
status: ReturnType<typeof useOrganizationChatAgent>["status"]
|
||||
@@ -57,12 +78,23 @@ export const useMobileProjectWorkspace = ({
|
||||
? { organizationId: organization.organizationId }
|
||||
: "skip"
|
||||
);
|
||||
const selectedIssue = projectWorkspace.issues?.find(
|
||||
(issue) => String(issue._id) === selectedWorkUnitId
|
||||
);
|
||||
const sendMessage = (message: string) => {
|
||||
if (!selectedIssue) {
|
||||
return chatAgent.sendMessage(message);
|
||||
}
|
||||
return chatAgent.sendMessage(
|
||||
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
|
||||
);
|
||||
};
|
||||
const assistant: MobileAssistantView = {
|
||||
isBusy:
|
||||
chatAgent.status === "connecting" ||
|
||||
chatAgent.status === "streaming" ||
|
||||
chatAgent.status === "submitted",
|
||||
messages: toAssistantMessages(chatAgent.messages),
|
||||
messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
|
||||
statusLabel: STATUS_COPY[chatAgent.status],
|
||||
statusTone: getStatusTone(chatAgent.status),
|
||||
};
|
||||
@@ -88,7 +120,7 @@ export const useMobileProjectWorkspace = ({
|
||||
raiseIssue: projectWorkspace.raiseIssue,
|
||||
raiseIssueFromSignal: (signalId: string) =>
|
||||
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
|
||||
sendMessage: chatAgent.sendMessage,
|
||||
sendMessage,
|
||||
startWorkUnit: projectWorkspace.startWorkUnit,
|
||||
} as const;
|
||||
};
|
||||
|
||||
66
apps/web/src/hooks/use-mobile-work-stack.ts
Normal file
66
apps/web/src/hooks/use-mobile-work-stack.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export const useMobileWorkStack = (
|
||||
workUnits: readonly MobileWorkUnitView[]
|
||||
) => {
|
||||
const [order, setOrder] = useState<readonly string[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
|
||||
const workUnitsById = useMemo(
|
||||
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
|
||||
[workUnits]
|
||||
);
|
||||
const availableIds = useMemo(
|
||||
() => new Set(workUnitsById.keys()),
|
||||
[workUnitsById]
|
||||
);
|
||||
const visibleWorkUnits = useMemo(() => {
|
||||
const retainedOrder = order.filter(
|
||||
(workUnitId) =>
|
||||
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
const retainedIds = new Set(retainedOrder);
|
||||
const addedIds = workUnits
|
||||
.map((workUnit) => workUnit.id)
|
||||
.filter(
|
||||
(workUnitId) =>
|
||||
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
|
||||
);
|
||||
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
|
||||
const workUnit = workUnitsById.get(workUnitId);
|
||||
return workUnit ? [workUnit] : [];
|
||||
});
|
||||
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
|
||||
|
||||
const sendToBack = useCallback(
|
||||
(workUnitId: string) => {
|
||||
if (visibleWorkUnits[0]?.id !== workUnitId) {
|
||||
return;
|
||||
}
|
||||
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
|
||||
},
|
||||
[visibleWorkUnits]
|
||||
);
|
||||
|
||||
const markChecked = useCallback((workUnitId: string) => {
|
||||
setCheckedIds((current) => new Set(current).add(workUnitId));
|
||||
}, []);
|
||||
|
||||
const restoreChecked = useCallback(() => {
|
||||
setCheckedIds(new Set());
|
||||
setOrder([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
checkedCount: [...checkedIds].filter((workUnitId) =>
|
||||
availableIds.has(workUnitId)
|
||||
).length,
|
||||
markChecked,
|
||||
restoreChecked,
|
||||
sendToBack,
|
||||
visibleWorkUnits,
|
||||
} as const;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getMobileStatusMessage } from "./mobile-status-message";
|
||||
|
||||
describe("getMobileStatusMessage", () => {
|
||||
it("keeps local composer feedback", () => {
|
||||
expect(
|
||||
getMobileStatusMessage({
|
||||
assistantError: "Flue API error 401",
|
||||
localStatus: "Sent to Zopu",
|
||||
})
|
||||
).toBe("Sent to Zopu");
|
||||
});
|
||||
|
||||
it("does not expose Flue authorization internals", () => {
|
||||
expect(
|
||||
getMobileStatusMessage({
|
||||
assistantError:
|
||||
'Flue API error 401: GET /agents/zopu/example: {"error":"Unauthorized"}',
|
||||
})
|
||||
).toBe("Zopu is reconnecting…");
|
||||
});
|
||||
});
|
||||
25
apps/web/src/lib/mobile-workspace/mobile-status-message.ts
Normal file
25
apps/web/src/lib/mobile-workspace/mobile-status-message.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
interface MobileStatusMessageInput {
|
||||
readonly assistantError?: string;
|
||||
readonly localStatus?: string;
|
||||
}
|
||||
|
||||
const isAgentAuthorizationError = (message: string) =>
|
||||
message.includes("Flue API error 401") ||
|
||||
message.includes('"Unauthorized"') ||
|
||||
message.includes("GET /agents/");
|
||||
|
||||
export const getMobileStatusMessage = ({
|
||||
assistantError,
|
||||
localStatus,
|
||||
}: MobileStatusMessageInput): string | undefined => {
|
||||
if (localStatus) {
|
||||
return localStatus;
|
||||
}
|
||||
if (!assistantError) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAgentAuthorizationError(assistantError)) {
|
||||
return "Zopu is reconnecting…";
|
||||
}
|
||||
return "Zopu needs attention";
|
||||
};
|
||||
13
apps/web/src/lib/users/get-user-initials.test.ts
Normal file
13
apps/web/src/lib/users/get-user-initials.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getUserInitials } from "./get-user-initials";
|
||||
|
||||
describe("getUserInitials", () => {
|
||||
it("uses the first two name parts", () => {
|
||||
expect(getUserInitials("Sai Karthik")).toBe("SK");
|
||||
});
|
||||
|
||||
it("uses a neutral fallback while auth loads", () => {
|
||||
expect(getUserInitials()).toBe("U");
|
||||
});
|
||||
});
|
||||
10
apps/web/src/lib/users/get-user-initials.ts
Normal file
10
apps/web/src/lib/users/get-user-initials.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const getUserInitials = (name?: string): string => {
|
||||
const parts = name?.trim().split(/\s+/u).filter(Boolean) ?? [];
|
||||
if (parts.length === 0) {
|
||||
return "U";
|
||||
}
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export default [
|
||||
layout("./routes/app/mobile/layout.tsx", [
|
||||
index("./routes/app/mobile/page.tsx"),
|
||||
route("chat", "./routes/app/mobile/chat/page.tsx"),
|
||||
route("chat/:workUnitId", "./routes/app/mobile/chat/work/page.tsx"),
|
||||
route("work", "./routes/app/mobile/work-list/page.tsx"),
|
||||
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
|
||||
]),
|
||||
|
||||
@@ -8,5 +8,5 @@ export const meta = (_args: Route.MetaArgs) => [
|
||||
];
|
||||
|
||||
export default function MobileChatPage() {
|
||||
return <MobileFlowPage screen="assistant-chat" />;
|
||||
return <MobileFlowPage screen="stack-home" />;
|
||||
}
|
||||
|
||||
15
apps/web/src/routes/app/mobile/chat/work/page.tsx
Normal file
15
apps/web/src/routes/app/mobile/chat/work/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export const meta = (_args: Route.MetaArgs) => [
|
||||
{ title: "Work chat | Zopu" },
|
||||
{
|
||||
content: "Chat with Zopu in the context of a selected work unit",
|
||||
name: "description",
|
||||
},
|
||||
];
|
||||
|
||||
export default function MobileWorkChatPage() {
|
||||
return <MobileFlowPage screen="work-chat" />;
|
||||
}
|
||||
Reference in New Issue
Block a user