Slice 1 polish: archive dormant code, merge work-os into primitives, add futures

- Archive dormant apps (daemon, desktop, native, tui) and superseded
  packages/server into repos/ for reference
- Archive 21 dead web components (chat page shell, work-os cluster, strays)
  into repos/web/; keep reused message-rendering primitives in components/chat
- Merge @code/work-os into @code/primitives; work.ts absorbed via ./work
  subpath and barrel export; update all four importers
- Add docs/futures.md tracking audio/video (blocked at Flue + provider),
  web layout cleanup, and message rendering quality
- Repoint dev:zopu script to @code/agents (the live Flue server)
- Note repos/ reference-code convention in AGENTS.md
This commit is contained in:
-Puter
2026-07-27 16:34:17 +05:30
parent 302fd159df
commit cc47007fa9
101 changed files with 38 additions and 93 deletions

29
repos/daemon/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "daemon",
"version": "0.0.0",
"private": true,
"type": "module",
"module": "src/index.ts",
"scripts": {
"dev": "bun --env-file=../../.env run --watch src/index.ts",
"build": "bun --env-file=../../.env build src/index.ts --compile --outfile dist/code-daemon",
"start": "bun --env-file=../../.env ./dist/code-daemon",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@effect/platform-bun": "catalog:",
"@rivet-dev/agentos": "catalog:",
"convex": "catalog:",
"effect": "catalog:",
"rivetkit": "2.3.7"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/bun": "catalog:",
"@types/node": "^22.13.14",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,77 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { env } from "@code/env/server";
import { AgentOs, createAgentOsActorEffect } from "@code/primitives/agent-os";
import { Effect, Schema } from "effect";
import { setup } from "rivetkit";
import { createClient } from "rivetkit/client";
import type { DaemonCommand } from "./convex";
import { ConvexControlPlane } from "./convex";
export class AgentOsCommandError extends Schema.TaggedErrorClass<AgentOsCommandError>()(
"AgentOsCommandError",
{
method: Schema.String,
cause: Schema.Defect(),
}
) {}
const asArgs = (value: unknown): unknown[] => {
if (!Array.isArray(value)) {
throw new TypeError("agentOS command args must be an array");
}
return value;
};
export const makeAgentOsRuntime = Effect.gen(function* () {
const actor = yield* createAgentOsActorEffect();
const registry = setup({ use: { agentOs: actor } });
registry.start();
const client = createClient<typeof registry>(env.RIVET_ENDPOINT);
const execute = Effect.fn("AgentOsRuntime.execute")(function* (
command: DaemonCommand
) {
const controlPlane = yield* ConvexControlPlane;
const handle = client.agentOs.getOrCreate([...command.actorKey]);
return yield* Effect.tryPromise({
try: () =>
handle.action({
name: command.method,
args: asArgs(command.args),
}),
catch: (cause) =>
new AgentOsCommandError({ method: command.method, cause }),
}).pipe(
Effect.map((result) =>
result instanceof Uint8Array ? result.slice().buffer : result
),
Effect.tap((result) =>
controlPlane.recordEvent({
commandId: command._id,
kind: "agentos.action.succeeded",
data: { method: command.method, result },
})
),
Effect.tapError((error) =>
controlPlane.recordEvent({
commandId: command._id,
kind: "agentos.action.failed",
data: { method: command.method, error: String(error.cause) },
})
)
);
});
const recordLifecycleEvent = (
commandId: Id<"daemonCommands"> | undefined,
kind: string,
data: unknown
) =>
Effect.gen(function* () {
const controlPlane = yield* ConvexControlPlane;
yield* controlPlane.recordEvent({ commandId, kind, data });
});
return { execute, recordLifecycleEvent } as const;
}).pipe(Effect.provide(AgentOs.layer));

174
repos/daemon/src/convex.ts Normal file
View File

@@ -0,0 +1,174 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { env } from "@code/env/server";
import { ConvexClient } from "convex/browser";
import { Cause, Context, Effect, Layer, Queue, Schema, Stream } from "effect";
export class ConvexControlPlaneError extends Schema.TaggedErrorClass<ConvexControlPlaneError>()(
"ConvexControlPlaneError",
{
operation: Schema.String,
cause: Schema.Defect(),
}
) {}
export interface DaemonCommand {
readonly _id: Id<"daemonCommands">;
readonly daemonId: string;
readonly actorKey: readonly string[];
readonly method: string;
readonly args: unknown;
}
const tryPromise = <A>(operation: string, evaluate: () => Promise<A>) =>
Effect.tryPromise({
try: evaluate,
catch: (cause) => new ConvexControlPlaneError({ operation, cause }),
});
export class ConvexControlPlane extends Context.Service<
ConvexControlPlane,
{
readonly commands: Stream.Stream<
readonly DaemonCommand[],
ConvexControlPlaneError
>;
readonly connect: (input: {
readonly sessionId: string;
readonly hostname: string;
readonly platform: string;
readonly architecture: string;
}) => Effect.Effect<void, ConvexControlPlaneError>;
readonly heartbeat: (
sessionId: string
) => Effect.Effect<void, ConvexControlPlaneError>;
readonly disconnect: (
sessionId: string
) => Effect.Effect<void, ConvexControlPlaneError>;
readonly claim: (
commandId: Id<"daemonCommands">,
sessionId: string
) => Effect.Effect<DaemonCommand | null, ConvexControlPlaneError>;
readonly start: (
commandId: Id<"daemonCommands">,
sessionId: string
) => Effect.Effect<boolean, ConvexControlPlaneError>;
readonly succeed: (
commandId: Id<"daemonCommands">,
sessionId: string,
result: unknown
) => Effect.Effect<boolean, ConvexControlPlaneError>;
readonly fail: (
commandId: Id<"daemonCommands">,
sessionId: string,
error: string
) => Effect.Effect<boolean, ConvexControlPlaneError>;
readonly recordEvent: (input: {
readonly commandId?: Id<"daemonCommands">;
readonly kind: string;
readonly data: unknown;
}) => Effect.Effect<void, ConvexControlPlaneError>;
readonly close: Effect.Effect<void, ConvexControlPlaneError>;
}
>()("@code/daemon/ConvexControlPlane") {
static readonly layer = Layer.effect(
ConvexControlPlane,
Effect.gen(function* () {
const client = yield* Effect.acquireRelease(
Effect.sync(() => new ConvexClient(env.CONVEX_URL)),
(client) => Effect.promise(() => client.close())
);
const commands = Stream.callback<
readonly DaemonCommand[],
ConvexControlPlaneError
>((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
client.onUpdate(
api.daemonCommands.available,
{ daemonId: env.DAEMON_ID },
(items) => {
Queue.offerUnsafe(queue, items);
},
(cause) => {
Queue.failCauseUnsafe(
queue,
Cause.fail(
new ConvexControlPlaneError({
operation: "subscribe commands",
cause,
})
)
);
}
)
),
(unsubscribe) => Effect.sync(unsubscribe)
)
);
return ConvexControlPlane.of({
commands,
connect: (input) =>
tryPromise("connect daemon", async () => {
await client.mutation(api.daemonRuntime.connect, {
daemonId: env.DAEMON_ID,
version: env.DAEMON_VERSION,
...input,
});
}),
heartbeat: (sessionId) =>
tryPromise("heartbeat daemon", async () => {
await client.mutation(api.daemonRuntime.heartbeat, {
daemonId: env.DAEMON_ID,
sessionId,
});
}),
disconnect: (sessionId) =>
tryPromise("disconnect daemon", async () => {
await client.mutation(api.daemonRuntime.disconnect, {
daemonId: env.DAEMON_ID,
sessionId,
});
}),
claim: (commandId, sessionId) =>
tryPromise("claim command", () =>
client.mutation(api.daemonCommands.claim, {
commandId,
daemonId: env.DAEMON_ID,
sessionId,
leaseDurationMs: env.DAEMON_COMMAND_LEASE_MS,
})
),
start: (commandId, sessionId) =>
tryPromise("start command", () =>
client.mutation(api.daemonCommands.start, { commandId, sessionId })
),
succeed: (commandId, sessionId, result) =>
tryPromise("complete command", () =>
client.mutation(api.daemonCommands.succeed, {
commandId,
sessionId,
result,
})
),
fail: (commandId, sessionId, error) =>
tryPromise("fail command", () =>
client.mutation(api.daemonCommands.fail, {
commandId,
sessionId,
error,
})
),
recordEvent: (input) =>
tryPromise("record daemon event", async () => {
await client.mutation(api.daemonRuntime.recordEvent, {
daemonId: env.DAEMON_ID,
...input,
});
}),
close: tryPromise("close Convex client", () => client.close()),
});
})
);
}

View File

@@ -0,0 +1,7 @@
import { BunRuntime } from "@effect/platform-bun";
import { Effect } from "effect";
import { ConvexControlPlane } from "./convex";
import { runDaemon } from "./runtime";
BunRuntime.runMain(runDaemon.pipe(Effect.provide(ConvexControlPlane.layer)));

View File

@@ -0,0 +1,88 @@
import { env } from "@code/env/server";
import { Console, Effect, FiberSet, Result, Stream } from "effect";
import { makeAgentOsRuntime } from "./agent-os";
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* 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();
yield* controlPlane.connect({
architecture: process.arch,
hostname,
platform: process.platform,
sessionId,
});
yield* Console.log(`daemon ${env.DAEMON_ID} connected as ${sessionId}`);
yield* Effect.addFinalizer(() =>
controlPlane
.disconnect(sessionId)
.pipe(
Effect.catchCause((cause) =>
Console.error("failed to mark daemon offline", cause)
)
)
);
yield* Stream.tick(env.DAEMON_HEARTBEAT_MS).pipe(
Stream.runForEach(() => controlPlane.heartbeat(sessionId)),
Effect.forkScoped
);
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)
);
}
);
yield* controlPlane.commands.pipe(
Stream.runForEach((commands) =>
Effect.forEach(
commands,
(command) => FiberSet.run(fibers, processCommand(command)),
{
discard: true,
}
)
)
);
})
);

View File

@@ -0,0 +1,8 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": {
"types": ["node", "bun"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist"]
}

1
repos/desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
artifacts

View File

@@ -0,0 +1,35 @@
import type { ElectrobunConfig } from "electrobun";
const webBuildDir = "../web/build/client";
export default {
app: {
name: "code",
identifier: "dev.bettertstack.code.desktop",
version: "0.0.1",
},
runtime: {
exitOnLastWindowClosed: true,
},
build: {
bun: {
entrypoint: "src/bun/index.ts",
},
copy: {
[webBuildDir]: "views/mainview",
},
watchIgnore: [`${webBuildDir}/**`],
mac: {
bundleCEF: true,
defaultRenderer: "cef",
},
linux: {
bundleCEF: true,
defaultRenderer: "cef",
},
win: {
bundleCEF: true,
defaultRenderer: "cef",
},
},
} satisfies ElectrobunConfig;

View File

@@ -0,0 +1,23 @@
{
"name": "desktop",
"private": true,
"type": "module",
"scripts": {
"start": "electrobun dev",
"dev:hmr": "concurrently \"bun run hmr\" \"electrobun dev --watch\"",
"hmr": "vp run --filter web dev",
"build": "vp run --filter web build && electrobun build",
"build:stable": "vp run --filter web build && electrobun build --env=stable",
"build:canary": "vp run --filter web build && electrobun build --env=canary",
"check-types": "tsc --noEmit"
},
"dependencies": {
"electrobun": "^1.18.1"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/three": "^0.165.0",
"concurrently": "^10.0.3",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,34 @@
import { BrowserWindow, Updater } from "electrobun/bun";
const DEV_SERVER_PORT = 5173;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
async function getMainViewUrl(): Promise<string> {
const channel = await Updater.localInfo.channel();
if (channel === "dev") {
try {
await fetch(DEV_SERVER_URL, { method: "HEAD" });
console.log(`HMR enabled: Using web dev server at ${DEV_SERVER_URL}`);
return DEV_SERVER_URL;
} catch {
console.log("Web dev server not running. Run dev:hmr for live reload.");
}
}
return "views://mainview/index.html";
}
const url = await getMainViewUrl();
new BrowserWindow({
title: "code",
url,
frame: {
width: 1280,
height: 820,
x: 120,
y: 120,
},
});
console.log("Electrobun desktop shell started.");

View File

@@ -0,0 +1,15 @@
{
"extends": "../../packages/config/tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"strictNullChecks": true
},
"include": ["src/**/*.ts", "electrobun.config.ts"]
}

21
repos/native/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# UniWind generated types
uniwind-types.d.ts

17
repos/native/app.json Normal file
View File

@@ -0,0 +1,17 @@
{
"expo": {
"scheme": "code",
"userInterfaceStyle": "automatic",
"orientation": "default",
"web": {
"bundler": "metro"
},
"name": "code",
"slug": "code",
"plugins": ["expo-font"],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,5 @@
import { Stack } from "expo-router";
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,24 @@
import { NativeLoginForm } from "@code/auth/native";
import { router } from "expo-router";
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
export default function LoginScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1 bg-background"
>
<ScrollView
contentContainerClassName="flex-grow justify-center p-6"
keyboardShouldPersistTaps="handled"
>
<View className="mx-auto w-full max-w-md">
<NativeLoginForm
onNavigateSignUp={() => router.push("/(auth)/signup")}
onSuccess={() => router.replace("/")}
/>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,24 @@
import { NativeSignupForm } from "@code/auth/native";
import { router } from "expo-router";
import { KeyboardAvoidingView, Platform, ScrollView, View } from "react-native";
export default function SignupScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1 bg-background"
>
<ScrollView
contentContainerClassName="flex-grow justify-center p-6"
keyboardShouldPersistTaps="handled"
>
<View className="mx-auto w-full max-w-md py-6">
<NativeSignupForm
onNavigateSignIn={() => router.push("/(auth)/login")}
onSuccess={() => router.replace("/")}
/>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -0,0 +1,42 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { useThemeColor } from "heroui-native";
export default function TabLayout() {
const themeColorForeground = useThemeColor("foreground");
const themeColorBackground = useThemeColor("background");
return (
<Tabs
screenOptions={{
headerShown: false,
headerStyle: {
backgroundColor: themeColorBackground,
},
headerTintColor: themeColorForeground,
headerTitleStyle: {
color: themeColorForeground,
fontWeight: "600",
},
tabBarStyle: {
backgroundColor: themeColorBackground,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: "Explore",
tabBarIcon: ({ color, size }) => <Ionicons name="compass" size={size} color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,16 @@
import { Card } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function Home() {
return (
<Container className="p-6">
<View className="flex-1 justify-center items-center">
<Card variant="secondary" className="p-8 items-center">
<Card.Title className="text-3xl mb-2">Tab One</Card.Title>
</Card>
</View>
</Container>
);
}

View File

@@ -0,0 +1,16 @@
import { Card } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function TabTwo() {
return (
<Container className="p-6">
<View className="flex-1 justify-center items-center">
<Card variant="secondary" className="p-8 items-center">
<Card.Title className="text-3xl mb-2">TabTwo</Card.Title>
</Card>
</View>
</Container>
);
}

View File

@@ -0,0 +1,88 @@
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
import { Drawer } from "expo-router/drawer";
import { useThemeColor } from "heroui-native";
import React, { useCallback } from "react";
import { Pressable, Text } from "react-native";
import { ThemeToggle } from "@/components/theme-toggle";
function DrawerLayout() {
const themeColorForeground = useThemeColor("foreground");
const themeColorBackground = useThemeColor("background");
const renderThemeToggle = useCallback(() => <ThemeToggle />, []);
return (
<Drawer
screenOptions={{
headerTintColor: themeColorForeground,
headerStyle: { backgroundColor: themeColorBackground },
headerTitleStyle: {
fontWeight: "600",
color: themeColorForeground,
},
headerRight: renderThemeToggle,
drawerStyle: { backgroundColor: themeColorBackground },
}}
>
<Drawer.Screen
name="index"
options={{
headerTitle: "Home",
drawerLabel: ({ color, focused }) => (
<Text style={{ color: focused ? color : themeColorForeground }}>Home</Text>
),
drawerIcon: ({ size, color, focused }) => (
<Ionicons
name="home-outline"
size={size}
color={focused ? color : themeColorForeground}
/>
),
}}
/>
<Drawer.Screen
name="(tabs)"
options={{
headerTitle: "Tabs",
drawerLabel: ({ color, focused }) => (
<Text style={{ color: focused ? color : themeColorForeground }}>Tabs</Text>
),
drawerIcon: ({ size, color, focused }) => (
<MaterialIcons
name="border-bottom"
size={size}
color={focused ? color : themeColorForeground}
/>
),
headerRight: () => (
<Link href="/modal" asChild>
<Pressable className="mr-4">
<Ionicons name="add-outline" size={24} color={themeColorForeground} />
</Pressable>
</Link>
),
}}
/>
<Drawer.Screen
name="todos"
options={{
headerTitle: "Todos",
drawerLabel: ({ color, focused }) => (
<Text style={{ color: focused ? color : themeColorForeground }}>Todos</Text>
),
drawerIcon: ({ size, color, focused }) => (
<Ionicons
name="checkbox-outline"
size={size}
color={focused ? color : themeColorForeground}
/>
),
}}
/>
</Drawer>
);
}
export default DrawerLayout;

View File

@@ -0,0 +1,53 @@
import { authClient } from "@code/auth/native";
import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import { Button, Spinner, Surface } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function Home() {
const healthCheck = useQuery(api.healthCheck.get);
const user = useQuery(api.auth.getCurrentUser);
return (
<Container className="px-4 pb-4">
<View className="mb-5 py-6">
<Text className="text-3xl font-semibold tracking-tight text-foreground">Zopu</Text>
<Text className="mt-1 text-sm text-muted">Your authenticated workspace</Text>
</View>
<Surface className="mb-4 rounded-xl p-4" variant="secondary">
{user === undefined ? (
<Spinner size="sm" />
) : (
<View className="flex-row items-center justify-between gap-4">
<View className="flex-1">
<Text className="font-medium text-foreground">{user?.name ?? "Signed in"}</Text>
<Text className="mt-0.5 text-xs text-muted">{user?.email}</Text>
</View>
<Button onPress={() => void authClient.signOut()} size="sm" variant="danger">
<Button.Label>Sign out</Button.Label>
</Button>
</View>
)}
</Surface>
<Surface className="rounded-xl p-4" variant="secondary">
<Text className="mb-2 font-medium text-foreground">API status</Text>
<View className="flex-row items-center gap-2">
<View
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-success" : "bg-danger"}`}
/>
<Text className="text-xs text-muted">
{healthCheck === undefined
? "Checking…"
: healthCheck === "OK"
? "Connected to Convex"
: "Convex unavailable"}
</Text>
</View>
</Surface>
</Container>
);
}

View File

@@ -0,0 +1,149 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { Ionicons } from "@expo/vector-icons";
import { useMutation, useQuery } from "convex/react";
import {
Button,
Checkbox,
Chip,
Spinner,
Surface,
Input,
TextField,
useThemeColor,
} from "heroui-native";
import { useState } from "react";
import { View, Text, ScrollView, Alert } from "react-native";
import { Container } from "@/components/container";
export default function TodosScreen() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodoMutation = useMutation(api.todos.create);
const toggleTodoMutation = useMutation(api.todos.toggle);
const deleteTodoMutation = useMutation(api.todos.deleteTodo);
const mutedColor = useThemeColor("muted");
const dangerColor = useThemeColor("danger");
const foregroundColor = useThemeColor("foreground");
const handleAddTodo = async () => {
const text = newTodoText.trim();
if (!text) return;
await createTodoMutation({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodoMutation({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteTodoMutation({ id }),
},
]);
};
const isLoading = !todos;
const completedCount = todos?.filter((t: Doc<"todos">) => t.completed).length || 0;
const totalCount = todos?.length || 0;
return (
<Container>
<ScrollView className="flex-1" contentContainerClassName="p-4">
<View className="py-4 mb-4">
<View className="flex-row items-center justify-between">
<Text className="text-2xl font-semibold text-foreground tracking-tight">Tasks</Text>
{totalCount > 0 && (
<Chip variant="secondary" color="accent" size="sm">
<Chip.Label>
{completedCount}/{totalCount}
</Chip.Label>
</Chip>
)}
</View>
</View>
<Surface variant="secondary" className="mb-4 p-3 rounded-lg">
<View className="flex-row items-center gap-2">
<View className="flex-1">
<TextField>
<Input
value={newTodoText}
onChangeText={setNewTodoText}
placeholder="Add a new task..."
onSubmitEditing={handleAddTodo}
returnKeyType="done"
/>
</TextField>
</View>
<Button
isIconOnly
variant={!newTodoText.trim() ? "secondary" : "primary"}
isDisabled={!newTodoText.trim()}
onPress={handleAddTodo}
size="sm"
>
<Ionicons
name="add"
size={20}
color={newTodoText.trim() ? foregroundColor : mutedColor}
/>
</Button>
</View>
</Surface>
{isLoading && (
<View className="items-center justify-center py-12">
<Spinner size="lg" />
<Text className="text-muted text-sm mt-3">Loading tasks...</Text>
</View>
)}
{todos && todos.length === 0 && !isLoading && (
<Surface variant="secondary" className="items-center justify-center py-10 rounded-lg">
<Ionicons name="checkbox-outline" size={40} color={mutedColor} />
<Text className="text-foreground font-medium mt-3">No tasks yet</Text>
<Text className="text-muted text-xs mt-1">Add your first task to get started</Text>
</Surface>
)}
{todos && todos.length > 0 && (
<View className="gap-2">
{todos.map((todo: Doc<"todos">) => (
<Surface key={todo._id} variant="secondary" className="p-3 rounded-lg">
<View className="flex-row items-center gap-3">
<Checkbox
isSelected={todo.completed}
onSelectedChange={() => handleToggleTodo(todo._id, todo.completed)}
/>
<View className="flex-1">
<Text
className={`text-sm ${todo.completed ? "text-muted line-through" : "text-foreground"}`}
>
{todo.text}
</Text>
</View>
<Button
isIconOnly
variant="ghost"
onPress={() => handleDeleteTodo(todo._id)}
size="sm"
>
<Ionicons name="trash-outline" size={16} color={dangerColor} />
</Button>
</View>
</Surface>
))}
</View>
)}
</ScrollView>
</Container>
);
}

View File

@@ -0,0 +1,27 @@
import { Link, Stack } from "expo-router";
import { Button, Surface } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Not Found" }} />
<Container>
<View className="flex-1 justify-center items-center p-4">
<Surface variant="secondary" className="items-center p-6 max-w-sm rounded-lg">
<Text className="text-4xl mb-3">🤔</Text>
<Text className="text-foreground font-medium text-lg mb-1">Page Not Found</Text>
<Text className="text-muted text-sm text-center mb-4">
The page you're looking for doesn't exist.
</Text>
<Link href="/" asChild>
<Button size="sm">Go Home</Button>
</Link>
</Surface>
</View>
</Container>
</>
);
}

View File

@@ -0,0 +1,53 @@
import "@/global.css";
import { NativeAuthProvider } from "@code/auth/native";
import { useConvexAuth } from "convex/react";
import { Stack } from "expo-router";
import { HeroUINativeProvider, Spinner } from "heroui-native";
import { View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { AppThemeProvider } from "@/contexts/app-theme-context";
function RootNavigator() {
const { isAuthenticated, isLoading } = useConvexAuth();
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-background">
<Spinner size="lg" />
</View>
);
}
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={!isAuthenticated}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={isAuthenticated}>
<Stack.Screen name="(drawer)" />
<Stack.Screen
name="modal"
options={{ headerShown: true, presentation: "modal", title: "Modal" }}
/>
</Stack.Protected>
</Stack>
);
}
export default function Layout() {
return (
<NativeAuthProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppThemeProvider>
<HeroUINativeProvider>
<RootNavigator />
</HeroUINativeProvider>
</AppThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</NativeAuthProvider>
);
}

View File

@@ -0,0 +1,37 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Button, Surface, useThemeColor } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
function Modal() {
const accentForegroundColor = useThemeColor("accent-foreground");
function handleClose() {
router.back();
}
return (
<Container>
<View className="flex-1 justify-center items-center p-4">
<Surface variant="secondary" className="p-5 w-full max-w-sm rounded-lg">
<View className="items-center">
<View className="w-12 h-12 bg-accent rounded-lg items-center justify-center mb-3">
<Ionicons name="checkmark" size={24} color={accentForegroundColor} />
</View>
<Text className="text-foreground font-medium text-lg mb-1">Modal Screen</Text>
<Text className="text-muted text-sm text-center mb-4">
This is an example modal screen for dialogs and confirmations.
</Text>
</View>
<Button onPress={handleClose} className="w-full" size="sm">
<Button.Label>Close</Button.Label>
</Button>
</Surface>
</View>
</Container>
);
}
export default Modal;

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,46 @@
import { cn } from "heroui-native";
import { type PropsWithChildren } from "react";
import { ScrollView, View, type ScrollViewProps, type ViewProps } from "react-native";
import Animated, { type AnimatedProps } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const AnimatedView = Animated.createAnimatedComponent(View);
type Props = AnimatedProps<ViewProps> & {
className?: string;
isScrollable?: boolean;
scrollViewProps?: Omit<ScrollViewProps, "contentContainerStyle">;
};
export function Container({
children,
className,
isScrollable = true,
scrollViewProps,
...props
}: PropsWithChildren<Props>) {
const insets = useSafeAreaInsets();
return (
<AnimatedView
className={cn("flex-1 bg-background", className)}
style={{
paddingBottom: insets.bottom,
}}
{...props}
>
{isScrollable ? (
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
{...scrollViewProps}
>
{children}
</ScrollView>
) : (
<View className="flex-1">{children}</View>
)}
</AnimatedView>
);
}

View File

@@ -0,0 +1,35 @@
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import { Platform, Pressable } from "react-native";
import Animated, { FadeOut, ZoomIn } from "react-native-reanimated";
import { withUniwind } from "uniwind";
import { useAppTheme } from "@/contexts/app-theme-context";
const StyledIonicons = withUniwind(Ionicons);
export function ThemeToggle() {
const { toggleTheme, isLight } = useAppTheme();
return (
<Pressable
onPress={() => {
if (Platform.OS === "ios") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
toggleTheme();
}}
className="px-2.5"
>
{isLight ? (
<Animated.View key="moon" entering={ZoomIn} exiting={FadeOut}>
<StyledIonicons name="moon" size={20} className="text-foreground" />
</Animated.View>
) : (
<Animated.View key="sun" entering={ZoomIn} exiting={FadeOut}>
<StyledIonicons name="sunny" size={20} className="text-foreground" />
</Animated.View>
)}
</Pressable>
);
}

View File

@@ -0,0 +1,55 @@
import React, { createContext, useCallback, useContext, useMemo } from "react";
import { Uniwind, useUniwind } from "uniwind";
type ThemeName = "light" | "dark";
type AppThemeContextType = {
currentTheme: string;
isLight: boolean;
isDark: boolean;
setTheme: (theme: ThemeName) => void;
toggleTheme: () => void;
};
const AppThemeContext = createContext<AppThemeContextType | undefined>(undefined);
export const AppThemeProvider = ({ children }: { children: React.ReactNode }) => {
const { theme } = useUniwind();
const isLight = useMemo(() => {
return theme === "light";
}, [theme]);
const isDark = useMemo(() => {
return theme === "dark";
}, [theme]);
const setTheme = useCallback((newTheme: ThemeName) => {
Uniwind.setTheme(newTheme);
}, []);
const toggleTheme = useCallback(() => {
Uniwind.setTheme(theme === "light" ? "dark" : "light");
}, [theme]);
const value = useMemo(
() => ({
currentTheme: theme,
isLight,
isDark,
setTheme,
toggleTheme,
}),
[theme, isLight, isDark, setTheme, toggleTheme],
);
return <AppThemeContext.Provider value={value}>{children}</AppThemeContext.Provider>;
};
export function useAppTheme() {
const context = useContext(AppThemeContext);
if (!context) {
throw new Error("useAppTheme must be used within AppThemeProvider");
}
return context;
}

5
repos/native/global.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
@import "uniwind";
@import "heroui-native/styles";
@source './node_modules/heroui-native/lib';

View File

@@ -0,0 +1,13 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withUniwindConfig } = require("uniwind/metro");
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
cssEntryFile: "./global.css",
dtsFile: "./uniwind-types.d.ts",
});
module.exports = uniwindConfig;

52
repos/native/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "native",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "bun --env-file=../../.env expo start",
"dev": "bun --env-file=../../.env expo start --clear",
"android": "bun --env-file=../../.env expo run:android",
"ios": "bun --env-file=../../.env expo run:ios",
"prebuild": "bun --env-file=../../.env expo prebuild",
"web": "bun --env-file=../../.env expo start --web"
},
"dependencies": {
"@code/auth": "workspace:*",
"@code/backend": "workspace:*",
"@expo/metro-runtime": "~57.0.2",
"@expo/vector-icons": "^15.1.1",
"@gorhom/bottom-sheet": "^5.2.14",
"convex": "catalog:",
"expo": "~57.0.1",
"expo-font": "~57.0.0",
"expo-haptics": "~57.0.0",
"expo-linking": "~57.0.1",
"expo-network": "~57.0.0",
"expo-router": "~57.0.2",
"expo-status-bar": "~57.0.0",
"expo-web-browser": "~57.0.0",
"heroui-native": "catalog:",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.9",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.0",
"tailwind-merge": "catalog:",
"tailwind-variants": "^3.2.2",
"tailwindcss": "catalog:",
"uniwind": "^1.10.0"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/node": "^26.0.1",
"@types/react": "~19.2.17",
"typescript": "~6.0.3"
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

3
repos/native/uniwind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="uniwind/types" />
declare module "*.css";

BIN
repos/server/data/zopu.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "@flue/cli/config";
export default defineConfig({ target: "node" });
export const vite = {
server: {
watch: {
ignored: ["**/data/**", "**/dist/**"],
},
},
};

29
repos/server/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@code/server",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --env-file=../../.env flue dev --port 3590",
"build": "bun --env-file=../../.env flue build --target node",
"start": "bun --env-file=../../.env dist/server.mjs",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@agentos-software/codex-cli": "0.3.4",
"@agentos-software/git": "0.3.3",
"@agentos-software/opencode": "0.2.7",
"@agentos-software/pi": "0.2.7",
"@flue/runtime": "1.0.0-beta.9",
"@rivet-dev/agentos-core": "catalog:",
"effect": "catalog:",
"hono": "4.12.30",
"valibot": "^1.4.2"
},
"devDependencies": {
"@code/config": "workspace:*",
"@flue/cli": "latest",
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,42 @@
import { defineAgent } from "@flue/runtime";
import { createIssueTool, editIssueTool } from "../tools/issues";
import {
checkPrStatusTool,
listPullRequestsTool,
} from "../tools/pull-requests";
import { env } from "../tools/runtime";
import { triggerWorkflowTool } from "../tools/workflows";
const INSTRUCTIONS = `You are Zopu, a lean coding-agent orchestrator for a single Git repository.
You chat with the user and help them manage work on the repo. You have these tools:
- create_issue: Track new work as a Gitea issue when the user describes something actionable.
- edit_issue: Update or close an existing issue (set state to "closed" to resolve).
- trigger_workflow: Start the resolve-issue work actor for a given issue number. The actor boots an isolated AgentOS VM, implements the fix, commits, and opens a pull request.
- list_pull_requests: Show recent PRs and their state.
- check_pr_status: Check whether a specific PR is open, merged, or closed — use this to tell the user whether an issue's work landed.
Guidelines:
- Keep conversation natural. Only create issues when the user describes concrete work.
- When the user asks to "work on" or "fix" an issue, trigger the workflow with the issue number.
- After triggering, tell the user the run ID. They can ask you to check the PR status later.
- Be concise. Confirm actions with the key detail (issue number, PR number, URL).`;
export default defineAgent(() => ({
description:
"Lean chat agent that manages issues, triggers the resolve-issue work actor, and checks PR status.",
instructions: INSTRUCTIONS,
model: `${env.AGENT_MODEL_PROVIDER}/${env.AGENT_MODEL_NAME}`,
tools: [
createIssueTool,
editIssueTool,
triggerWorkflowTool,
listPullRequestsTool,
checkPrStatusTool,
],
}));
// Allow HTTP prompts and event streaming for this agent.
export const route = (_c: unknown, next: () => Promise<void>) => next();

31
repos/server/src/app.ts Normal file
View File

@@ -0,0 +1,31 @@
import { registerProvider } from "@flue/runtime";
import { flue } from "@flue/runtime/routing";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { env } from "./tools/runtime";
// Register the GLM 5.2 provider once at module load.
registerProvider(env.AGENT_MODEL_PROVIDER, {
api: env.AGENT_MODEL_API,
apiKey: env.AGENT_MODEL_API_KEY,
baseUrl: env.AGENT_MODEL_BASE_URL,
models: {
[env.AGENT_MODEL_NAME]: {
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
},
},
});
const app = new Hono();
// The chat UI is served from the web app origin, so browser calls here are
// cross-origin; without CORS headers every preflight 405s and sends fail.
app.use("*", cors());
app.get("/health", (c) => c.json({ model: env.AGENT_MODEL_NAME, ok: true }));
app.route("/", flue());
export default app;

4
repos/server/src/db.ts Normal file
View File

@@ -0,0 +1,4 @@
import { sqlite } from "@flue/runtime/node";
// File-based SQLite persistence so conversations survive restarts.
export default sqlite("./data/zopu.db");

46
repos/server/src/env.ts Normal file
View File

@@ -0,0 +1,46 @@
// Lean env config for the zopu server. Read from process.env; no zod dependency.
const required = (key: string): string => {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required env var: ${key}`);
}
return value;
};
export interface ServerEnv {
readonly AGENT_MODEL_PROVIDER: string;
readonly AGENT_MODEL_NAME: string;
readonly AGENT_MODEL_API: string;
readonly AGENT_MODEL_API_KEY: string;
readonly AGENT_MODEL_BASE_URL: string;
readonly AGENT_MODEL_CONTEXT_WINDOW: number;
readonly AGENT_MODEL_MAX_TOKENS: number;
readonly GITEA_URL: string;
readonly GITEA_TOKEN: string;
readonly GIT_REPO: string;
readonly GIT_SSH_REMOTE: string;
readonly GIT_DEFAULT_BRANCH: string;
readonly WORKTREE_ROOT: string;
readonly PORT: string;
}
export const parseServerEnv = (): ServerEnv => ({
AGENT_MODEL_API: process.env.AGENT_MODEL_API ?? "openai-completions",
AGENT_MODEL_API_KEY: required("AGENT_MODEL_API_KEY"),
AGENT_MODEL_BASE_URL: required("AGENT_MODEL_BASE_URL"),
AGENT_MODEL_CONTEXT_WINDOW: Number(
process.env.AGENT_MODEL_CONTEXT_WINDOW ?? 262_000
),
AGENT_MODEL_MAX_TOKENS: Number(process.env.AGENT_MODEL_MAX_TOKENS ?? 131_072),
AGENT_MODEL_NAME: process.env.AGENT_MODEL_NAME ?? "glm-5.2",
AGENT_MODEL_PROVIDER: process.env.AGENT_MODEL_PROVIDER ?? "cheaptricks",
GITEA_TOKEN: process.env.GITEA_TOKEN ?? "",
GITEA_URL: process.env.GITEA_URL ?? "https://git.openputer.com",
GIT_DEFAULT_BRANCH: process.env.GIT_DEFAULT_BRANCH ?? "main",
GIT_REPO: process.env.GIT_REPO ?? "puter/zopu-code",
GIT_SSH_REMOTE:
process.env.GIT_SSH_REMOTE ??
"ssh://git@git.openputer.com:2222/puter/zopu-code.git",
PORT: process.env.PORT ?? "3590",
WORKTREE_ROOT: process.env.WORKTREE_ROOT ?? "/tmp/zopu-worktrees",
});

View File

@@ -0,0 +1,360 @@
/* eslint-disable max-classes-per-file -- GiteaError and Gitea form one adapter contract. */
import { Context, Effect, Layer, Schema } from "effect";
const toPrState = (
merged: boolean,
state: string
): "merged" | "closed" | "open" => {
if (merged) {
return "merged";
}
if (state === "closed") {
return "closed";
}
return "open";
};
// ---------------------------------------------------------------------------
// Schemas / errors
// ---------------------------------------------------------------------------
export class GiteaError extends Schema.TaggedErrorClass<GiteaError>()(
"GiteaError",
{
message: Schema.String,
status: Schema.Number.pipe(Schema.optional),
}
) {}
export interface GiteaIssue {
readonly number: number;
readonly title: string;
readonly body: string;
readonly state: "open" | "closed";
readonly htmlUrl: string;
}
export interface GiteaPullRequest {
readonly number: number;
readonly title: string;
readonly state: "open" | "closed" | "merged";
readonly htmlUrl: string;
readonly head: string;
readonly base: string;
readonly merged: boolean;
}
export interface GiteaRepository {
readonly cloneUrl: string;
readonly sshUrl: string;
readonly htmlUrl: string;
readonly defaultBranch: string;
readonly name: string;
}
export interface CreateIssueInput {
readonly title: string;
readonly body: string;
}
export interface EditIssueInput {
readonly title?: string;
readonly body?: string;
readonly state?: "open" | "closed";
}
export interface CreatePrInput {
readonly title: string;
readonly body: string;
readonly head: string;
readonly base: string;
}
// ---------------------------------------------------------------------------
// Service interface
// ---------------------------------------------------------------------------
export interface GiteaService {
readonly getIssue: (number: number) => Effect.Effect<GiteaIssue, GiteaError>;
readonly createIssue: (
input: CreateIssueInput
) => Effect.Effect<GiteaIssue, GiteaError>;
readonly editIssue: (
number: number,
input: EditIssueInput
) => Effect.Effect<GiteaIssue, GiteaError>;
readonly listPullRequests: () => Effect.Effect<
GiteaPullRequest[],
GiteaError
>;
readonly getPullRequest: (
number: number
) => Effect.Effect<GiteaPullRequest, GiteaError>;
readonly createPullRequest: (
input: CreatePrInput
) => Effect.Effect<GiteaPullRequest, GiteaError>;
readonly getRepository: () => Effect.Effect<GiteaRepository, GiteaError>;
}
export interface GiteaConfig {
readonly baseUrl: string;
readonly token: string;
readonly repoPath: string;
}
// ---------------------------------------------------------------------------
// HTTP helper
// ---------------------------------------------------------------------------
const toHttpPath = (repoPath: string): string =>
repoPath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
const authHeaders = (token: string): Record<string, string> => {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `token ${token}`;
}
return headers;
};
const giteaFetch = <T>(
config: GiteaConfig,
path: string,
init?: RequestInit
): Effect.Effect<T, GiteaError> =>
Effect.tryPromise({
catch: (cause) => {
if (
cause instanceof Error &&
"status" in cause &&
typeof cause.status === "number"
) {
return new GiteaError({
message: cause.message,
status: cause.status,
});
}
return new GiteaError({
message: cause instanceof Error ? cause.message : String(cause),
});
},
try: async () => {
const response = await fetch(
`${config.baseUrl.replace(/\/$/u, "")}${path}`,
{
...init,
headers: { ...authHeaders(config.token), ...init?.headers },
}
);
if (!response.ok) {
const detail = await response.text();
throw Object.assign(new Error(`Gitea ${response.status}: ${detail}`), {
status: response.status,
});
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
},
});
// ---------------------------------------------------------------------------
// Service + layer
// ---------------------------------------------------------------------------
export class Gitea extends Context.Service<Gitea, GiteaService>()(
"@zopu/server/Gitea"
) {
static readonly layer = (config: GiteaConfig) =>
Layer.succeed(
Gitea,
Gitea.of({
createIssue: (input) =>
giteaFetch<{
number: number;
title: string;
body: string;
state: string;
html_url: string;
}>(config, `/api/v1/repos/${toHttpPath(config.repoPath)}/issues`, {
body: JSON.stringify({ body: input.body, title: input.title }),
method: "POST",
}).pipe(
Effect.map(
(i): GiteaIssue => ({
body: i.body ?? "",
htmlUrl: i.html_url,
number: i.number,
state: "open",
title: i.title,
})
)
),
createPullRequest: (input) =>
giteaFetch<{
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
base: { ref: string };
head: { ref: string };
}>(config, `/api/v1/repos/${toHttpPath(config.repoPath)}/pulls`, {
body: JSON.stringify({
base: input.base,
body: input.body,
head: input.head,
title: input.title,
}),
method: "POST",
}).pipe(
Effect.map(
(pr): GiteaPullRequest => ({
base: pr.base.ref,
head: pr.head.ref,
htmlUrl: pr.html_url,
merged: pr.merged ?? false,
number: pr.number,
state: "open",
title: pr.title,
})
)
),
editIssue: (number, input) =>
giteaFetch<{
number: number;
title: string;
body: string;
state: string;
html_url: string;
}>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/issues/${number}`,
{
body: JSON.stringify({
...(input.body === undefined ? {} : { body: input.body }),
...(input.state === undefined ? {} : { state: input.state }),
...(input.title === undefined ? {} : { title: input.title }),
}),
method: "PATCH",
}
).pipe(
Effect.map(
(i): GiteaIssue => ({
body: i.body ?? "",
htmlUrl: i.html_url,
number: i.number,
state: i.state === "closed" ? "closed" : "open",
title: i.title,
})
)
),
getIssue: (number) =>
giteaFetch<{
number: number;
title: string;
body: string;
state: string;
html_url: string;
}>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/issues/${number}`
).pipe(
Effect.map(
(i): GiteaIssue => ({
body: i.body ?? "",
htmlUrl: i.html_url,
number: i.number,
state: i.state === "closed" ? "closed" : "open",
title: i.title,
})
)
),
getPullRequest: (number) =>
giteaFetch<{
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
base: { ref: string };
head: { ref: string };
}>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/pulls/${number}`
).pipe(
Effect.map(
(pr): GiteaPullRequest => ({
base: pr.base.ref,
head: pr.head.ref,
htmlUrl: pr.html_url,
merged: pr.merged ?? false,
number: pr.number,
state: toPrState(pr.merged, pr.state),
title: pr.title,
})
)
),
getRepository: () =>
giteaFetch<{
clone_url: string;
ssh_url: string;
html_url: string;
default_branch: string;
name: string;
}>(config, `/api/v1/repos/${toHttpPath(config.repoPath)}`).pipe(
Effect.map(
(r): GiteaRepository => ({
cloneUrl: r.clone_url,
defaultBranch: r.default_branch,
htmlUrl: r.html_url,
name: r.name,
sshUrl: r.ssh_url,
})
)
),
listPullRequests: () =>
giteaFetch<
{
number: number;
title: string;
state: string;
html_url: string;
merged: boolean;
base: { ref: string };
head: { ref: string };
}[]
>(
config,
`/api/v1/repos/${toHttpPath(config.repoPath)}/pulls?state=all&limit=20&sort=recentupdate`
).pipe(
Effect.map((prs): GiteaPullRequest[] =>
prs.map(
(pr): GiteaPullRequest => ({
base: pr.base.ref,
head: pr.head.ref,
htmlUrl: pr.html_url,
merged: pr.merged ?? false,
number: pr.number,
state: toPrState(pr.merged, pr.state),
title: pr.title,
})
)
)
),
})
);
}

View File

@@ -0,0 +1,114 @@
import codex from "@agentos-software/codex-cli";
import git from "@agentos-software/git";
import opencode from "@agentos-software/opencode";
import pi from "@agentos-software/pi";
import { createSandboxSessionEnv } from "@flue/runtime";
import type { FileStat, SandboxApi, SandboxFactory } from "@flue/runtime";
import { AgentOs, createHostDirBackend } from "@rivet-dev/agentos-core";
import type { AgentOsOptions, SoftwareInput } from "@rivet-dev/agentos-core";
const WORK_SOFTWARE: SoftwareInput[] = [git, opencode, codex, pi];
export interface WorkVmOptions {
readonly extraSoftware?: SoftwareInput[];
readonly worktreePath: string;
}
const statResult = (s: {
isDirectory: boolean;
isSymbolicLink: boolean;
mtimeMs: number;
size: number;
}): FileStat => ({
isDirectory: s.isDirectory,
isFile: !s.isDirectory && !s.isSymbolicLink,
isSymbolicLink: s.isSymbolicLink,
mtime: new Date(s.mtimeMs),
size: s.size,
});
export const createWorkVm = async (
options: WorkVmOptions
): Promise<{
readonly destroy: () => Promise<void>;
readonly sandbox: SandboxFactory;
}> => {
const vm = await AgentOs.create({
defaultSoftware: true,
mounts: [
{
path: "/workspace/repository",
plugin: createHostDirBackend({
hostPath: options.worktreePath,
readOnly: false,
}),
readOnly: false,
},
],
software: [...WORK_SOFTWARE, ...(options.extraSoftware ?? [])],
} satisfies AgentOsOptions);
const api: SandboxApi = {
async exec(
command: string,
opts?: {
cwd?: string;
env?: Record<string, string>;
signal?: AbortSignal;
timeoutMs?: number;
}
) {
const result = await vm.exec(command, {
...(opts?.cwd === undefined ? {} : { cwd: opts.cwd }),
...(opts?.env === undefined ? {} : { env: opts.env }),
...(opts?.timeoutMs === undefined ? {} : { timeout: opts.timeoutMs }),
});
return {
exitCode: result.exitCode,
stderr: result.stderr,
stdout: result.stdout,
};
},
async exists(path: string): Promise<boolean> {
try {
return await vm.exists(path);
} catch {
return false;
}
},
async mkdir(path: string, opts?: { recursive?: boolean }) {
await vm.mkdir(path, { recursive: opts?.recursive ?? false });
},
readFile(path: string): Promise<string> {
return vm.readFile(path).then((b) => new TextDecoder().decode(b));
},
readFileBuffer(path: string) {
return vm.readFile(path);
},
readdir(path: string): Promise<string[]> {
return vm.readdir(path);
},
async rm(path: string, opts?: { force?: boolean; recursive?: boolean }) {
if (opts?.force && !(await vm.exists(path))) {
return;
}
await vm.remove(path, { recursive: opts?.recursive });
},
async stat(path: string): Promise<FileStat> {
return statResult(await vm.stat(path));
},
writeFile(path: string, content: string | Uint8Array): Promise<void> {
return vm.writeFile(path, content);
},
};
const sandbox: SandboxFactory = {
createSessionEnv: () =>
Promise.resolve(createSandboxSessionEnv(api, "/workspace")),
};
return {
destroy: () => Promise.resolve(),
sandbox,
};
};

View File

@@ -0,0 +1,68 @@
import { defineTool } from "@flue/runtime";
import { Effect } from "effect";
import * as v from "valibot";
import { Gitea } from "../git/gitea-service";
import { runWithGitea } from "./runtime";
export const createIssueTool = defineTool({
description:
"Create a new issue in the repository. Use when the user describes actionable work that should be tracked.",
input: v.object({
body: v.pipe(v.string(), v.maxLength(20_000)),
title: v.pipe(v.string(), v.minLength(1), v.maxLength(200)),
}),
name: "create_issue",
output: v.object({
htmlUrl: v.string(),
number: v.number(),
state: v.string(),
title: v.string(),
}),
async run({ input }) {
const issue = await runWithGitea(
Effect.gen(function* issue() {
const gitea = yield* Gitea;
return yield* gitea.createIssue(input);
})
);
return {
htmlUrl: issue.htmlUrl,
number: issue.number,
state: issue.state,
title: issue.title,
};
},
});
export const editIssueTool = defineTool({
description:
"Edit an existing issue's title, body, or state. Set state to 'closed' to resolve it.",
input: v.object({
body: v.optional(v.pipe(v.string(), v.maxLength(20_000))),
number: v.pipe(v.number(), v.integer()),
state: v.optional(v.picklist(["open", "closed"])),
title: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(200))),
}),
name: "edit_issue",
output: v.object({
htmlUrl: v.string(),
number: v.number(),
state: v.string(),
title: v.string(),
}),
async run({ input }) {
const issue = await runWithGitea(
Effect.gen(function* issue() {
const gitea = yield* Gitea;
return yield* gitea.editIssue(input.number, input);
})
);
return {
htmlUrl: issue.htmlUrl,
number: issue.number,
state: issue.state,
title: issue.title,
};
},
});

View File

@@ -0,0 +1,68 @@
import { defineTool } from "@flue/runtime";
import { Effect } from "effect";
import * as v from "valibot";
import { Gitea } from "../git/gitea-service";
import type { GiteaPullRequest } from "../git/gitea-service";
import { runWithGitea } from "./runtime";
export const listPullRequestsTool = defineTool({
description:
"List recent pull requests in the repository with their state (open, closed, merged).",
input: v.object({}),
name: "list_pull_requests",
async run() {
const prs: GiteaPullRequest[] = await runWithGitea(
Effect.gen(function* prs() {
const gitea = yield* Gitea;
return yield* gitea.listPullRequests();
})
);
return {
pullRequests: prs.map((pr) => ({
base: pr.base,
head: pr.head,
merged: pr.merged,
number: pr.number,
state: pr.state,
title: pr.title,
url: pr.htmlUrl,
})),
};
},
});
export const checkPrStatusTool = defineTool({
description:
"Check whether a specific pull request is open, merged, or closed. Use to verify if an issue's work was resolved.",
input: v.object({
number: v.pipe(v.number(), v.integer()),
}),
name: "check_pr_status",
output: v.object({
base: v.string(),
head: v.string(),
merged: v.boolean(),
number: v.number(),
state: v.string(),
title: v.string(),
url: v.string(),
}),
async run({ input }) {
const pr: GiteaPullRequest = await runWithGitea(
Effect.gen(function* pr() {
const gitea = yield* Gitea;
return yield* gitea.getPullRequest(input.number);
})
);
return {
base: pr.base,
head: pr.head,
merged: pr.merged,
number: pr.number,
state: pr.state,
title: pr.title,
url: pr.htmlUrl,
};
},
});

View File

@@ -0,0 +1,22 @@
import { Effect } from "effect";
import { parseServerEnv } from "../env";
import { Gitea } from "../git/gitea-service";
import type { GiteaConfig } from "../git/gitea-service";
const env = parseServerEnv();
const config: GiteaConfig = {
baseUrl: env.GITEA_URL,
repoPath: env.GIT_REPO,
token: env.GITEA_TOKEN,
};
const giteaLayer = Gitea.layer(config);
/** Run any Effect that depends on the Gitea service. */
export const runWithGitea = <A, E>(
effect: Effect.Effect<A, E, Gitea>
): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(giteaLayer)));
export { env };

View File

@@ -0,0 +1,22 @@
import { defineTool, invoke } from "@flue/runtime";
import * as v from "valibot";
import resolveIssueWorkflow from "../workflows/resolve-issue";
export const triggerWorkflowTool = defineTool({
description:
"Trigger the resolve-issue workflow for a specific issue number. The workflow boots an isolated AgentOS VM, runs opencode to resolve the issue, then commits and opens a pull request. Returns the workflow run ID.",
input: v.object({
issueNumber: v.pipe(v.number(), v.integer()),
}),
name: "trigger_workflow",
output: v.object({
runId: v.string(),
}),
async run({ input }) {
const receipt = await invoke(resolveIssueWorkflow, {
input: { issueNumber: input.issueNumber },
});
return { runId: receipt.runId };
},
});

View File

@@ -0,0 +1,159 @@
import { execSync } from "node:child_process";
import { mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { defineAgent, defineWorkflow } from "@flue/runtime";
import type { WorkflowRouteHandler } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import { Effect } from "effect";
import * as v from "valibot";
import { Gitea } from "../git/gitea-service";
import type { GiteaIssue } from "../git/gitea-service";
import { env, runWithGitea } from "../tools/runtime";
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const runHost = (command: string, options?: { cwd?: string }): string =>
execSync(command, {
encoding: "utf-8",
timeout: 120_000,
...(options?.cwd ? { cwd: options.cwd } : {}),
}).trim();
const createWorktree = (
issueNumber: number
): { branch: string; path: string } => {
const branch = `zopu/issue-${issueNumber}-${Date.now()}`;
const worktreePath = path.join(env.WORKTREE_ROOT, `issue-${issueNumber}`);
mkdirSync(env.WORKTREE_ROOT, { recursive: true });
rmSync(worktreePath, { force: true, recursive: true });
runHost(
`git worktree add -b ${shellQuote(branch)} ${shellQuote(worktreePath)} ${shellQuote(env.GIT_DEFAULT_BRANCH)}`
);
return { branch, path: worktreePath };
};
const publishChanges = async (
worktreePath: string,
branch: string,
issueNumber: number,
issueTitle: string
): Promise<{
base: string;
head: string;
number: number;
state: string;
status: string;
url: string;
}> => {
const status = runHost("git status --porcelain=v1", { cwd: worktreePath });
if (!status) {
return {
base: "",
head: branch,
number: 0,
state: "",
status: "no_changes",
url: "",
};
}
const commitMessage = `Resolve #${issueNumber}: ${issueTitle}`;
runHost(`git add --all && git commit -m ${shellQuote(commitMessage)}`, {
cwd: worktreePath,
});
runHost(
`git push ${shellQuote(env.GIT_SSH_REMOTE)} HEAD:${shellQuote(branch)}`,
{ cwd: worktreePath }
);
const pr = await runWithGitea(
Effect.gen(function* pr() {
const gitea = yield* Gitea;
return yield* gitea.createPullRequest({
base: env.GIT_DEFAULT_BRANCH,
body: `Resolves #${issueNumber}.\n\nGenerated by the Zopu work actor using GLM 5.2.`,
head: branch,
title: `Resolve #${issueNumber}: ${issueTitle}`,
});
})
);
return {
base: pr.base,
head: pr.head,
number: pr.number,
state: pr.state,
status: "pull_request_open",
url: pr.htmlUrl,
};
};
const cleanupWorktree = (worktreePath: string): void => {
try {
runHost(`git worktree remove ${shellQuote(worktreePath)} --force`);
} catch {
// Best-effort cleanup.
}
};
const workActor = defineAgent(() => ({
cwd: "/workspace/repository",
instructions: `You are the Zopu work actor operating inside an isolated AgentOS VM with a git worktree at /workspace/repository.
Your job:
1. Read the issue you received.
2. Inspect the codebase to find the relevant files.
3. Implement the fix or feature directly.
4. Verify your changes if tests or a typechecker are available.
Do NOT run git commit, push, or create pull requests. The host-side workflow handles publishing after you finish. Focus on correct code changes only.`,
model: `${env.AGENT_MODEL_PROVIDER}/${env.AGENT_MODEL_NAME}`,
sandbox: local({ cwd: "/workspace/repository" }),
}));
export default defineWorkflow({
agent: workActor,
input: v.object({
issueNumber: v.pipe(v.number(), v.integer()),
}),
async run({ harness, input }) {
const issue: GiteaIssue = await runWithGitea(
Effect.gen(function* issue() {
const gitea = yield* Gitea;
return yield* gitea.getIssue(input.issueNumber);
})
);
const { branch, path: worktreePath } = createWorktree(input.issueNumber);
const session = await harness.session();
await session.prompt(
`Resolve issue #${issue.number}: ${issue.title}\n\n${issue.body}\n\nInspect /workspace/repository and implement the fix.`
);
const result = await publishChanges(
worktreePath,
branch,
input.issueNumber,
issue.title
);
cleanupWorktree(worktreePath);
return {
branch,
issueNumber: input.issueNumber,
pullRequest: {
base: result.base,
head: result.head,
number: result.number,
state: result.state,
url: result.url,
},
status: result.status,
};
},
});
export const route: WorkflowRouteHandler = (_c, next) => next();

View File

@@ -0,0 +1,6 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": { "types": ["bun"] },
"include": ["src/**/*.ts"],
"exclude": ["dist"]
}

34
repos/tui/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
*.log
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
repos/tui/README.md Normal file
View File

@@ -0,0 +1,15 @@
# react
To install dependencies:
```bash
bun install
```
To run:
```bash
bun dev
```
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.

21
repos/tui/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "tui",
"private": true,
"type": "module",
"module": "src/index.tsx",
"scripts": {
"dev": "bun run --watch src/index.tsx",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@opentui/core": "^0.4.4",
"@opentui/react": "^0.4.4",
"react": "^19.2.6"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

16
repos/tui/src/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { createCliRenderer, TextAttributes } from "@opentui/core";
import { createRoot } from "@opentui/react";
function App() {
return (
<box alignItems="center" justifyContent="center" flexGrow={1}>
<box justifyContent="center" alignItems="flex-end">
<ascii-font font="tiny" text="OpenTUI" />
<text attributes={TextAttributes.DIM}>What will you build?</text>
</box>
</box>
);
}
const renderer = await createCliRenderer();
createRoot(renderer).render(<App />);

30
repos/tui/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -0,0 +1,40 @@
import { MobileChatComposer } from "@code/ui/components/mobile-chat";
import { LoaderCircle } from "lucide-react";
import type { ReactNode } from "react";
import { useChatComposer } from "@/hooks/chat/use-chat-composer";
import type { ChatComposerProps } from "@/lib/chat/types";
export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
const busy =
status === "connecting" || status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend });
let statusMessage: ReactNode;
if (busy) {
statusMessage = (
<span className="inline-flex items-center gap-1.5">
<LoaderCircle className="size-3 animate-spin" />
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
</span>
);
}
return (
<MobileChatComposer
busy={busy}
canSend={composer.canSend}
errorMessage={
error ? error.message || "Message failed to send" : undefined
}
onSubmit={composer.handleSubmit}
statusMessage={statusMessage}
textareaProps={{
...composer.inputProps,
"aria-describedby": error ? "composer-error" : undefined,
"aria-invalid": error ? true : undefined,
disabled: busy,
}}
/>
);
};

View File

@@ -0,0 +1,56 @@
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import { MobileChatMark } from "@code/ui/components/mobile-chat";
import { SUGGESTIONS } from "@/lib/chat/constants";
import type { ChatConversationProps } from "@/lib/chat/types";
import { ChatMessage } from "./chat-message";
import { ChatThinkingResponse } from "./chat-thinking-response";
export const ChatConversation = ({
historyReady,
messages,
onSuggestion,
status,
}: ChatConversationProps) => {
if (historyReady && messages.length === 0) {
return (
<ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
<div className="space-y-1">
<h2 className="text-[22px] font-semibold tracking-[-0.035em]">
What can I help with?
</h2>
<p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
Think, write, plan, or explore an idea with Zopu.
</p>
</div>
<div className="mt-3 grid w-full gap-2">
{SUGGESTIONS.map(([title, prompt]) => (
<Button
key={title}
className="group h-auto min-h-14 w-full flex-col items-start justify-center gap-0.5 rounded-[16px] border-[#e7e7e4] bg-[#f7f7f5] px-4 py-3 text-left shadow-none transition-colors active:bg-[#eeeeeb]"
onClick={() => onSuggestion(prompt)}
variant="outline"
>
<span className="font-medium text-foreground">{title}</span>
<span className="text-xs leading-4 font-normal text-muted-foreground whitespace-normal">
{prompt}
</span>
</Button>
))}
</div>
</ConversationEmptyState>
);
}
return (
<div className="flex min-w-0 flex-col gap-5 pb-5">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{status === "submitted" && <ChatThinkingResponse />}
</div>
);
};

View File

@@ -0,0 +1,11 @@
import { MobileChatHeader } from "@code/ui/components/mobile-chat";
import { STATUS_COPY } from "@/lib/chat/constants";
import type { ChatHeaderProps } from "@/lib/chat/types";
export const ChatHeader = ({ status }: ChatHeaderProps) => (
<MobileChatHeader
active={status !== "connecting" && status !== "error"}
statusLabel={STATUS_COPY[status]}
/>
);

View File

@@ -0,0 +1,45 @@
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@code/ui/components/ai-elements/conversation";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { ChatComposer } from "./chat-composer";
import { ChatConversation } from "./chat-conversation";
import { ChatHeader } from "./chat-header";
export const ChatPage = () => {
const agent = useChatAgent();
const handleSendMessage = (message: string) => agent.sendMessage(message);
return (
<section className="chat-surface flex h-full min-h-0 flex-col bg-[#fefefe]">
<ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1">
<Conversation className="ai-conversation-mobile h-full">
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
status={agent.status}
/>
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
/>
</Conversation>
</main>
<ChatComposer
error={agent.error}
onSend={handleSendMessage}
status={agent.status}
/>
</section>
);
};

View File

@@ -0,0 +1,36 @@
import { NavLink } from "react-router";
import { ModeToggle } from "./mode-toggle";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
{ to: "/todos", label: "Todos" },
] as const;
return (
<div>
<div className="flex flex-row items-center justify-between px-2 py-1">
<nav className="flex gap-4 text-lg">
{links.map(({ to, label }) => {
return (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "font-bold" : "")}
end
>
{label}
</NavLink>
);
})}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
</div>
</div>
<hr />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";
export default function Loader() {
return (
<div className="flex h-full items-center justify-center pt-8">
<Loader2 className="animate-spin" />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="icon" />}>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,907 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { Input } from "@code/ui/components/input";
import { Textarea } from "@code/ui/components/textarea";
import {
ArrowUpRight,
Bot,
CircleAlert,
ExternalLink,
FolderGit2,
GitFork,
GitPullRequest,
LoaderCircle,
Plus,
Sparkles,
} from "lucide-react";
import UserMenu from "@/components/user-menu";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import type {
ProjectIssueSummary,
ProjectLoopView,
} from "@/lib/projects/project-evidence";
interface Project {
readonly id: string;
readonly name: string;
readonly sources: readonly {
readonly defaultBranch?: string;
readonly host: string;
readonly repositoryPath: string;
readonly url: string;
}[];
}
const formatStatus = (status: Doc<"projectIssues">["status"]): string => {
if (status === "needs-input") {
return "Needs input";
}
if (status === "working") {
return "In progress";
}
return status.charAt(0).toUpperCase() + status.slice(1);
};
const statusClasses: Record<Doc<"projectIssues">["status"], string> = {
completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
failed: "bg-destructive/15 text-destructive",
"needs-input": "bg-orange-500/15 text-orange-700 dark:text-orange-300",
open: "bg-muted text-muted-foreground",
queued: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
working: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
};
const verificationIconClass = (
verification: ProjectLoopView["verification"]
): string => {
if (verification === "passed") {
return "text-emerald-600";
}
if (verification === "failed" || verification === "blocked") {
return "text-orange-600";
}
return "text-muted-foreground";
};
const checkStateClass = (
state: ProjectLoopView["verificationChecks"][number]["state"]
): string => {
if (state === "complete") {
return "bg-emerald-500 text-white";
}
if (state === "attention") {
return "bg-orange-500 text-white";
}
if (state === "current") {
return "bg-blue-500 text-white";
}
return "bg-muted text-muted-foreground";
};
const LoadingLine = ({ label }: { readonly label: string }) => (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{label}
</div>
);
interface RepositoryFormProps {
readonly busy: boolean;
readonly onRepositoryChange: (value: string) => void;
readonly onRepositorySubmit: () => void;
readonly value: string;
}
const RepositoryForm = ({
busy,
onRepositoryChange,
onRepositorySubmit,
value,
}: RepositoryFormProps) => (
<form
className="space-y-3 border-b border-border/70 p-4"
onSubmit={(event) => {
event.preventDefault();
onRepositorySubmit();
}}
>
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Connect project
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Bring a public repository into the project loop.
</p>
</div>
<Input
aria-label="Public Git repository URL"
onChange={(event) => onRepositoryChange(event.target.value)}
placeholder="https://github.com/owner/repo"
required
value={value}
/>
<Button className="w-full" disabled={busy} type="submit">
{busy ? (
<LoaderCircle className="animate-spin" data-icon="inline-start" />
) : (
<GitFork data-icon="inline-start" />
)}
{busy ? "Connecting" : "Connect repository"}
</Button>
</form>
);
interface ProjectSidebarProps {
readonly onProjectSelect: (projectId: Id<"projects">) => void;
readonly projects: readonly Project[] | undefined;
readonly selectedId: Id<"projects"> | null;
}
const ProjectSidebar = ({
onProjectSelect,
projects,
selectedId,
}: ProjectSidebarProps) => {
const renderProjects = () => {
if (projects === undefined) {
return <LoadingLine label="Loading projects" />;
}
if (projects.length === 0) {
return (
<p className="text-xs leading-5 text-muted-foreground">
Connect a repository to create the first project workspace.
</p>
);
}
return (
<div className="grid gap-1.5">
{projects.map((project) => {
const [source] = project.sources;
const selected = selectedId === project.id;
return (
<button
className={`w-full border px-3 py-2.5 text-left transition-colors ${
selected
? "border-foreground/20 bg-foreground text-background"
: "border-transparent hover:border-border hover:bg-muted/60"
}`}
key={project.id}
onClick={() => onProjectSelect(project.id as Id<"projects">)}
type="button"
>
<span className="block truncate text-sm font-medium">
{project.name}
</span>
<span
className={`mt-1 block truncate text-[11px] ${
selected ? "text-background/65" : "text-muted-foreground"
}`}
>
{source
? `${source.host}/${source.repositoryPath}`
: "No source"}
</span>
</button>
);
})}
</div>
);
};
return (
<aside className="flex min-h-0 flex-col border-b border-border/70 bg-card/35 md:border-r md:border-b-0">
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
<div>
<p className="text-sm font-semibold">Projects</p>
<p className="text-xs text-muted-foreground">
Your connected workspaces
</p>
</div>
<FolderGit2 className="size-4 text-muted-foreground" />
</div>
<nav
aria-label="Connected projects"
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{renderProjects()}
</nav>
<div className="border-t border-border/70 p-3">
<div className="flex items-start gap-2 border bg-muted/45 p-3">
<Sparkles className="mt-0.5 size-3.5 shrink-0 text-lime-600 dark:text-lime-300" />
<p className="text-[11px] leading-4 text-muted-foreground">
Project context stays attached to every issue and run.
</p>
</div>
</div>
</aside>
);
};
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
readonly onIssueBodyChange: (value: string) => void;
readonly onIssueSubmit: () => void;
readonly onIssueTitleChange: (value: string) => void;
readonly title: string;
}
const IssueComposer = ({
body,
busy,
onIssueBodyChange,
onIssueSubmit,
onIssueTitleChange,
title,
}: IssueComposerProps) => (
<form
className="border border-border/70 bg-card/60 p-4 shadow-[0_14px_32px_-24px_rgb(0_0_0/0.45)] sm:p-5"
onSubmit={(event) => {
event.preventDefault();
onIssueSubmit();
}}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Create work
</p>
<h2 className="mt-1 text-base font-semibold tracking-tight">
What should Zopu move forward?
</h2>
</div>
<div className="grid size-8 shrink-0 place-items-center bg-lime-400 text-black">
<Plus className="size-4" />
</div>
</div>
<div className="mt-4 grid gap-2.5">
<Input
aria-label="Issue title"
onChange={(event) => onIssueTitleChange(event.target.value)}
placeholder="Outcome or issue title"
required
value={title}
/>
<Textarea
aria-label="Issue description"
onChange={(event) => onIssueBodyChange(event.target.value)}
placeholder="Describe the desired result, constraints, or evidence to start from."
required
rows={3}
value={body}
/>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[11px] leading-4 text-muted-foreground">
The issue becomes durable project work before an agent run starts.
</p>
<Button className="w-full sm:w-auto" disabled={busy} type="submit">
{busy ? (
<LoaderCircle className="animate-spin" data-icon="inline-start" />
) : (
<Plus data-icon="inline-start" />
)}
{busy ? "Creating" : "Create issue"}
</Button>
</div>
</form>
);
interface IssueCardProps {
readonly issue: Doc<"projectIssues">;
readonly selected: boolean;
readonly onIssueSelect: (issueId: Id<"projectIssues">) => void;
readonly onIssueStart: (
issueId: Id<"projectIssues">,
issueNumber: number,
title: string
) => void;
readonly pendingAction: string | null;
}
const IssueCard = ({
issue,
onIssueSelect,
onIssueStart,
pendingAction,
selected,
}: IssueCardProps) => {
const canStart = issue.status === "open" || issue.status === "failed";
const startPending = pendingAction === `issue:${issue._id}`;
return (
<article
className={`border p-4 transition-colors ${
selected
? "border-blue-500/35 bg-blue-500/5"
: "border-border/70 bg-card/45 hover:border-foreground/20"
}`}
>
<button
className="w-full text-left"
onClick={() => onIssueSelect(issue._id)}
type="button"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Issue #{issue.number}
</span>
<span
className={`rounded-full px-2 py-1 text-[10px] font-semibold ${statusClasses[issue.status]}`}
>
{formatStatus(issue.status)}
</span>
</div>
<h3 className="mt-3 text-sm font-semibold leading-5">{issue.title}</h3>
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{issue.body}
</p>
</button>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<p className="text-[11px] text-muted-foreground">
Updated{" "}
{new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(issue.updatedAt)}
</p>
{canStart ? (
<Button
disabled={startPending}
onClick={() => onIssueStart(issue._id, issue.number, issue.title)}
size="sm"
type="button"
variant="outline"
>
{startPending ? (
<LoaderCircle className="animate-spin" data-icon="inline-start" />
) : (
<Bot data-icon="inline-start" />
)}
{startPending ? "Starting" : "Start work"}
</Button>
) : null}
</div>
</article>
);
};
const IssueList = ({
issues,
onIssueSelect,
onIssueStart,
pendingAction,
selectedId,
}: {
readonly issues: readonly Doc<"projectIssues">[] | undefined;
readonly onIssueSelect: (issueId: Id<"projectIssues">) => void;
readonly onIssueStart: IssueCardProps["onIssueStart"];
readonly pendingAction: string | null;
readonly selectedId: Id<"projectIssues"> | null;
}) => {
if (issues === undefined) {
return <LoadingLine label="Loading issues" />;
}
if (issues.length === 0) {
return (
<div className="border border-dashed border-border p-7 text-center">
<Bot className="mx-auto size-5 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">
No issues in this project yet
</p>
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
Create a small, outcome-shaped issue and it will become the first
visible loop.
</p>
</div>
);
}
return (
<div className="space-y-3">
{issues.map((issue) => (
<IssueCard
issue={issue}
key={issue._id}
onIssueSelect={onIssueSelect}
onIssueStart={onIssueStart}
pendingAction={pendingAction}
selected={issue._id === selectedId}
/>
))}
</div>
);
};
const Stat = ({
label,
tone,
value,
}: {
readonly label: string;
readonly tone: string;
readonly value: number;
}) => (
<div className={`border border-border/70 p-3 ${tone}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{label}
</p>
<p className="mt-1 text-2xl font-semibold tracking-tight">{value}</p>
</div>
);
const ProjectStats = ({
summary,
}: {
readonly summary: ProjectIssueSummary;
}) => (
<div className="grid grid-cols-3 gap-2 sm:gap-3">
<Stat label="Active" tone="bg-blue-500/5" value={summary.active} />
<Stat label="Needs you" tone="bg-orange-500/5" value={summary.needsInput} />
<Stat label="Shipped" tone="bg-emerald-500/5" value={summary.completed} />
</div>
);
interface IssueDetailProps {
readonly issue: Doc<"projectIssues"> | undefined;
readonly onOpenPullRequest: (url: string) => void;
readonly onIssueStart: (
issueId: Id<"projectIssues">,
issueNumber: number,
title: string
) => void;
readonly pendingAction: string | null;
readonly projectLoop: ProjectLoopView | null;
}
const IssueDetail = ({
issue,
onOpenPullRequest,
onIssueStart,
pendingAction,
projectLoop,
}: IssueDetailProps) => {
if (!issue || !projectLoop) {
return (
<aside className="border border-dashed border-border p-6 lg:sticky lg:top-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Selected work
</p>
<h2 className="mt-2 text-lg font-semibold">
Choose an issue to inspect its loop
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Progress, verification, activity, and review artifacts will stay
together here.
</p>
</aside>
);
}
const canStart = issue.status === "open" || issue.status === "failed";
const startPending = pendingAction === `issue:${issue._id}`;
return (
<aside className="space-y-4 lg:sticky lg:top-6">
<section className="border border-border/70 bg-card/60 p-5 shadow-[0_14px_32px_-24px_rgb(0_0_0/0.45)]">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Issue #{issue.number}
</p>
<h2 className="mt-2 text-xl font-semibold leading-7 tracking-tight">
{issue.title}
</h2>
</div>
<span
className={`rounded-full px-2 py-1 text-[10px] font-semibold ${statusClasses[issue.status]}`}
>
{formatStatus(issue.status)}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{issue.body}
</p>
<div className="mt-5">
<div className="flex items-center justify-between gap-3 text-xs font-medium">
<span>{projectLoop.progress}% complete</span>
<span className="text-muted-foreground">
{projectLoop.status.label}
</span>
</div>
<div className="mt-2 h-2 bg-muted">
<div
className="h-full bg-blue-600 transition-[width] dark:bg-blue-400"
style={{ width: `${projectLoop.progress}%` }}
/>
</div>
</div>
<div className="mt-5 border-t border-border/70 pt-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Current state
</p>
<p className="mt-2 text-sm leading-6">{projectLoop.currentState}</p>
</div>
<div className="mt-4 flex items-start gap-3 border border-lime-500/25 bg-lime-400/10 p-3">
<ArrowUpRight className="mt-0.5 size-4 shrink-0 text-lime-700 dark:text-lime-300" />
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-lime-800 dark:text-lime-200">
Next move
</p>
<p className="mt-1 text-sm font-medium">{projectLoop.nextAction}</p>
</div>
</div>
{canStart ? (
<Button
className="mt-4 w-full"
disabled={startPending}
onClick={() => onIssueStart(issue._id, issue.number, issue.title)}
type="button"
>
{startPending ? (
<LoaderCircle className="animate-spin" data-icon="inline-start" />
) : (
<Bot data-icon="inline-start" />
)}
{startPending
? "Starting project manager"
: "Start project manager"}
</Button>
) : null}
</section>
<section className="border border-border/70 bg-card/45 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Verification
</p>
<h3 className="mt-1 text-base font-semibold">
Evidence earns the next step
</h3>
</div>
<CircleAlert
className={`size-4 ${verificationIconClass(projectLoop.verification)}`}
/>
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{projectLoop.verificationNote}
</p>
<div className="mt-4 space-y-2">
{projectLoop.verificationChecks.map((check) => (
<div
className="flex items-start gap-3 border border-border/60 p-3"
key={check.label}
>
<span
className={`mt-0.5 grid size-4 place-items-center rounded-full ${checkStateClass(check.state)}`}
>
<span className="text-[9px]">
{check.state === "complete" ? "✓" : ""}
</span>
</span>
<div className="min-w-0">
<p className="text-xs font-medium">{check.label}</p>
<p className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
{check.detail}
</p>
</div>
</div>
))}
</div>
</section>
<section className="border border-border/70 bg-card/45 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Gitea artifact
</p>
<h3 className="mt-1 text-base font-semibold">
Pull request review
</h3>
</div>
<GitPullRequest className="size-4 text-muted-foreground" />
</div>
{projectLoop.pullRequest.state === "available" &&
projectLoop.pullRequest.reviewUrl ? (
<div className="mt-4 border border-emerald-500/25 bg-emerald-500/10 p-3">
<p className="text-sm font-medium">
{projectLoop.pullRequest.number
? `PR #${projectLoop.pullRequest.number} is ready for review`
: "Pull request is ready for review"}
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Verification evidence is attached to this project loop.
</p>
<Button
className="mt-3"
onClick={() =>
onOpenPullRequest(projectLoop.pullRequest.reviewUrl as string)
}
type="button"
variant="outline"
>
<ExternalLink data-icon="inline-start" />
Review changes
</Button>
</div>
) : (
<div className="mt-4 border border-dashed border-border p-3">
<p className="text-sm font-medium">Review link pending</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
The slot is ready for a Gitea pull request when the agent
publishes one. The rest of the run remains observable now.
</p>
</div>
)}
</section>
<section className="border border-border/70 bg-card/45 p-5">
<div className="flex items-center gap-2">
<Sparkles className="size-3.5 text-lime-600 dark:text-lime-300" />
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Recent activity
</p>
</div>
<div className="mt-4 space-y-4">
{projectLoop.activity.map((item) => (
<div className="flex gap-3" key={`${item.label}-${item.time}`}>
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground/50" />
<div className="min-w-0">
<p className="text-xs font-medium">{item.label}</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{item.detail}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/75">
{item.time}
</p>
</div>
</div>
))}
</div>
</section>
</aside>
);
};
export const ProjectWorkspacePage = () => {
const workspace = useProjectWorkspace();
const projectSource = workspace.selectedProject?.sources[0];
const handleIssueBodyChange = workspace.setIssueBody;
const handleIssueSelect = workspace.setSelectedIssueId;
const handleIssueStart = workspace.startIssue;
const handleIssueSubmit = workspace.raiseIssue;
const handleIssueTitleChange = workspace.setIssueTitle;
const handleProjectSelect = workspace.setSelectedProjectId;
const handleRepositoryChange = workspace.setRepository;
const handleRepositorySubmit = workspace.connectRepository;
return (
<main className="min-h-svh bg-background text-foreground">
<header className="border-b border-border/70 bg-background/90 backdrop-blur">
<div className="mx-auto flex max-w-[1540px] items-center justify-between gap-4 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className="grid size-9 shrink-0 place-items-center bg-lime-400 text-black">
<span className="text-base font-semibold">Z</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">Zopu</p>
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="text-xs text-muted-foreground">
Project loop
</span>
</div>
<p className="truncate text-[11px] text-muted-foreground">
Connect context, move issues, review outcomes.
</p>
</div>
</div>
<UserMenu />
</div>
</header>
<div className="mx-auto grid min-h-[calc(100svh-65px)] max-w-[1540px] md:grid-cols-[250px_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col">
<RepositoryForm
busy={workspace.pendingAction === "connect"}
onRepositoryChange={handleRepositoryChange}
onRepositorySubmit={handleRepositorySubmit}
value={workspace.repository}
/>
<ProjectSidebar
onProjectSelect={handleProjectSelect}
projects={workspace.projects}
selectedId={workspace.selectedProjectId}
/>
</div>
<div className="min-w-0 px-4 py-5 sm:px-6 sm:py-7 lg:px-8">
{workspace.error ? (
<div className="mb-5 flex items-start gap-3 border border-destructive/35 bg-destructive/10 p-3 text-sm text-destructive">
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<p>{workspace.error}</p>
</div>
) : null}
{workspace.selectedProject ? (
<div className="space-y-6">
<section className="flex flex-col gap-4 border-b border-border/70 pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
<span>Project workspace</span>
<span>/</span>
<span className="truncate">
{projectSource?.host ?? "Repository"}
</span>
</div>
<h1 className="mt-2 text-3xl font-semibold sm:text-4xl">
{workspace.selectedProject.name}
</h1>
<p className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
<FolderGit2 className="size-4" />
<span className="truncate">
{projectSource
? `${projectSource.host}/${projectSource.repositoryPath}`
: "No repository source"}
</span>
</p>
</div>
{projectSource ? (
<Button
className="w-full sm:w-auto"
onClick={() =>
window.open(
projectSource.url,
"_blank",
"noopener,noreferrer"
)
}
type="button"
variant="outline"
>
<ExternalLink data-icon="inline-start" />
Open repository
</Button>
) : null}
</section>
<section className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(300px,0.72fr)]">
<div className="border border-border/70 bg-blue-500/5 p-5 sm:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="grid size-7 place-items-center rounded-full bg-blue-600 text-white dark:bg-blue-400 dark:text-blue-950">
<Bot className="size-3.5" />
</span>
<p className="text-sm font-semibold">Work in motion</p>
</div>
<span className="rounded-full bg-blue-500/15 px-2 py-1 text-[10px] font-semibold text-blue-700 dark:text-blue-300">
Context attached
</span>
</div>
<h2 className="mt-7 max-w-xl text-2xl font-semibold tracking-tight sm:text-3xl">
Make the next useful move visible.
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Every issue carries the project context, agent run,
verification evidence, and review artifact as one durable
loop.
</p>
<div className="mt-6 flex flex-wrap items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
{[
["01", "Issue"],
["02", "Run"],
["03", "Verify"],
["04", "Review"],
].map(([number, label]) => (
<div className="flex items-center gap-2" key={number}>
<span className="grid size-5 place-items-center rounded-full bg-background text-[9px] text-foreground">
{number}
</span>
<span>{label}</span>
{number === "04" ? null : (
<span className="px-1 text-border">/</span>
)}
</div>
))}
</div>
</div>
<div className="border border-border/70 bg-card/50 p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Project pulse
</p>
<p className="mt-1 text-sm font-semibold">
No hidden queue
</p>
</div>
<Sparkles className="size-4 text-lime-600 dark:text-lime-300" />
</div>
<p className="mt-4 text-sm leading-6 text-muted-foreground">
The cards below are the current durable issues. Open one to
follow the work, not a transient chat session.
</p>
<div className="mt-5">
<ProjectStats summary={workspace.issueSummary} />
</div>
</div>
</section>
<IssueComposer
body={workspace.issueBody}
busy={workspace.pendingAction === "issue"}
onIssueBodyChange={handleIssueBodyChange}
onIssueSubmit={handleIssueSubmit}
onIssueTitleChange={handleIssueTitleChange}
title={workspace.issueTitle}
/>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1.05fr)_minmax(350px,0.8fr)]">
<section
aria-labelledby="active-work-heading"
className="min-w-0"
>
<div className="mb-3 flex items-end justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Durable queue
</p>
<h2
className="mt-1 text-lg font-semibold"
id="active-work-heading"
>
Issues in this project
</h2>
</div>
<span className="text-xs text-muted-foreground">
{workspace.issueSummary.total} total
</span>
</div>
<IssueList
issues={workspace.issues}
onIssueSelect={handleIssueSelect}
onIssueStart={handleIssueStart}
pendingAction={workspace.pendingAction}
selectedId={workspace.selectedIssueId}
/>
</section>
<IssueDetail
issue={workspace.selectedIssue}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onIssueStart={handleIssueStart}
pendingAction={workspace.pendingAction}
projectLoop={workspace.projectLoop}
/>
</div>
</div>
) : (
<section className="grid min-h-[65svh] place-items-center border border-dashed border-border p-8 text-center">
<div className="max-w-md">
<div className="mx-auto grid size-12 place-items-center bg-lime-400 text-black">
<GitFork className="size-5" />
</div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight">
Connect a project to start the loop
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Import a repository, keep its context attached, then turn a
clear outcome into an issue that can be started, verified, and
reviewed.
</p>
</div>
</section>
)}
</div>
</div>
</main>
);
};

View File

@@ -0,0 +1,43 @@
import { signOutWeb, useWebAuth } from "@code/auth/web";
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { useNavigate } from "react-router";
export default function UserMenu() {
const navigate = useNavigate();
const auth = useWebAuth();
const user = auth.status === "authenticated" ? auth.user : null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>
{user?.name}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuGroup>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{user?.email}</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={async () => {
await signOutWeb();
navigate("/login", { replace: true });
}}
>
Sign Out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,53 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { LoaderCircle, MessagesSquare } from "lucide-react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import type { ChatAgentState } from "@/lib/chat/types";
interface ConversationPanelProps {
readonly agent: ChatAgentState;
readonly title: string;
readonly emptyHint: string;
}
export const ConversationPanel = ({
agent,
emptyHint,
title,
}: ConversationPanelProps) => {
if (agent.status === "connecting" && !agent.historyReady) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<LoaderCircle className="mr-2 size-4 animate-spin" />
Loading conversation
</div>
);
}
if (agent.historyReady && agent.messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
<MessagesSquare className="size-6 text-muted-foreground/50" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
{emptyHint}
</p>
</div>
);
}
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="min-h-full gap-4 px-4 py-4">
{agent.messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{agent.status === "submitted" ? <ChatThinkingResponse /> : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,54 @@
import { Bot, FolderGit2 } from "lucide-react";
interface EmptyStateProps {
readonly onConnectRepository: () => void;
readonly busy: boolean;
readonly repository: string;
readonly onRepositoryChange: (value: string) => void;
}
export const EmptyState = ({
busy,
onConnectRepository,
onRepositoryChange,
repository,
}: EmptyStateProps) => (
<div className="grid min-h-[60vh] place-items-center">
<div className="max-w-md text-center">
<div className="mx-auto grid size-14 place-items-center rounded-2xl bg-foreground text-background">
<FolderGit2 className="size-6" />
</div>
<h2 className="mt-6 text-2xl font-semibold tracking-tight">
Connect a project to begin
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Import a repository to start the Work OS loop. Turn a clear outcome into
a Work Unit that Zopu can start, verify, and review.
</p>
<form
className="mx-auto mt-6 max-w-sm space-y-3"
onSubmit={(event) => {
event.preventDefault();
onConnectRepository();
}}
>
<input
aria-label="Public Git repository URL"
className="w-full rounded-xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onRepositoryChange(event.target.value)}
placeholder="https://github.com/owner/repo"
required
value={repository}
/>
<button
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
<Bot className="size-4" />
{busy ? "Connecting" : "Connect repository"}
</button>
</form>
</div>
</div>
);

View File

@@ -0,0 +1,71 @@
import { Radio } from "lucide-react";
import type { SignalItem } from "@/hooks/work-os/use-work-os";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
interface SignalsPanelProps {
readonly signals: readonly SignalItem[];
readonly loading: boolean;
}
export const SignalsPanel = ({ loading, signals }: SignalsPanelProps) => {
if (loading) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">Loading signals</p>
</section>
);
}
if (signals.length === 0) {
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<p className="text-xs text-muted-foreground">
No Signals detected for this project yet.
</p>
</section>
);
}
return (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
<Radio className="size-3" />
Signals
</div>
<span className="text-[10px] text-muted-foreground/60">
{signals.length} total
</span>
</div>
<div className="space-y-3">
{signals.map((signal) => (
<div
className="rounded-xl border border-border/20 bg-background/30 p-3"
key={`${signal.title}-${signal.createdAt}`}
>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<div className="mt-2 flex items-center gap-3 text-[10px] text-muted-foreground/60">
<span>
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"}
</span>
<span>{formatRelativeTime(signal.createdAt)}</span>
</div>
</div>
))}
</div>
</section>
);
};

View File

@@ -0,0 +1,143 @@
import { Button } from "@code/ui/components/button";
import { LoaderCircle, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ComposerMode } from "@/hooks/work-os/use-work-os";
interface WorkOsComposerProps {
readonly mode: ComposerMode;
readonly onModeChange: (mode: ComposerMode) => void;
readonly onSend: (message: string) => Promise<void>;
readonly busy: boolean;
readonly error?: Error;
readonly workUnitTitle?: string;
readonly placeholder?: string;
}
export const WorkOsComposer = ({
busy,
error,
mode,
onModeChange,
onSend,
placeholder,
workUnitTitle,
}: WorkOsComposerProps) => {
const [draft, setDraft] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSend = draft.trim().length > 0 && !busy;
useEffect(() => {
if (!busy) {
textareaRef.current?.focus();
}
}, [busy, mode]);
const handleSubmit = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
setDraft("");
try {
await onSend(message);
} finally {
textareaRef.current?.focus();
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void handleSubmit();
}
};
const scopeLabel =
mode === "project" ? "Project" : (workUnitTitle ?? "Work Unit");
return (
<div className="border-t border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
{/* Mode switcher */}
<div className="mb-2 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg bg-muted/60 p-0.5">
<button
className={`rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "project"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onModeChange("project")}
type="button"
>
Project
</button>
<button
className={`flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
mode === "work-unit"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
disabled={!workUnitTitle}
onClick={() => onModeChange("work-unit")}
type="button"
>
Work Unit
</button>
</div>
{mode === "work-unit" && workUnitTitle ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<X
className="size-3 cursor-pointer"
onClick={() => onModeChange("project")}
/>
<span className="max-w-40 truncate">{workUnitTitle}</span>
</span>
) : null}
<span className="ml-auto text-[10px] text-muted-foreground/50">
{scopeLabel}
</span>
</div>
{error ? (
<p className="mb-2 text-[11px] text-destructive" id="composer-error">
{error.message || "Message failed to send"}
</p>
) : null}
<div className="flex items-end gap-2.5">
<textarea
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label={`Message ${scopeLabel}`}
className="min-h-11 max-h-32 flex-1 resize-none rounded-2xl border border-border/40 bg-card/40 px-4 py-2.5 text-sm leading-5 outline-none transition-colors placeholder:text-muted-foreground/60 focus-visible:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-60"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? `Message ${scopeLabel}`}
ref={textareaRef}
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0 rounded-full"
disabled={!canSend}
onClick={() => void handleSubmit()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { ChevronDown, Circle, FolderGit2, Zap } from "lucide-react";
import UserMenu from "@/components/user-menu";
interface ProjectOption {
readonly id: string;
readonly name: string;
readonly host?: string;
}
interface WorkOsHeaderProps {
readonly projects: readonly ProjectOption[] | undefined;
readonly selectedProject: ProjectOption | null;
readonly onSelectProject: (id: string) => void;
readonly connected: boolean;
readonly onBack?: () => void;
readonly showBack: boolean;
}
const StatusDot = ({ connected }: { readonly connected: boolean }) => (
<span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Circle
className={`size-2 ${connected ? "fill-emerald-500 text-emerald-500" : "fill-amber-500 text-amber-500"}`}
/>
{connected ? "Connected" : "Connecting"}
</span>
);
export const WorkOsHeader = ({
connected,
onBack,
onSelectProject,
projects,
selectedProject,
showBack,
}: WorkOsHeaderProps) => (
<header className="flex items-center justify-between gap-3 border-b border-border/40 bg-background/80 px-4 py-3 backdrop-blur-xl sm:px-6">
<div className="flex min-w-0 items-center gap-3">
{showBack && onBack ? (
<Button
className="shrink-0"
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronDown className="size-4 rotate-90" />
</Button>
) : null}
<div className="grid size-9 shrink-0 place-items-center rounded-xl bg-foreground text-background">
<Zap className="size-4 fill-current" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold tracking-tight">Zopu</p>
<StatusDot connected={connected} />
</div>
{selectedProject ? (
<p className="truncate text-[11px] text-muted-foreground">
{selectedProject.name}
</p>
) : (
<p className="text-[11px] text-muted-foreground">No project</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{projects && projects.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger>
<Button size="sm" type="button" variant="ghost">
<FolderGit2 className="size-3.5" />
<span className="max-w-32 truncate">
{selectedProject?.name ?? "Select"}
</span>
<ChevronDown className="size-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<DropdownMenuSeparator />
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => onSelectProject(project.id)}
>
<FolderGit2 className="size-3.5" />
<span className="truncate">{project.name}</span>
{project.host ? (
<span className="ml-auto text-[10px] text-muted-foreground">
{project.host}
</span>
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<UserMenu />
</div>
</header>
);

View File

@@ -0,0 +1,401 @@
import { CircleAlert, GitFork, LoaderCircle, Plus } from "lucide-react";
import { useState } from "react";
import { ConversationPanel } from "@/components/work-os/conversation-panel";
import { EmptyState } from "@/components/work-os/empty-state";
import { SignalsPanel } from "@/components/work-os/signals-panel";
import { WorkOsComposer } from "@/components/work-os/work-os-composer";
import { WorkOsHeader } from "@/components/work-os/work-os-header";
import { WorkUnitCard } from "@/components/work-os/work-unit-card";
import { WorkUnitDetail } from "@/components/work-os/work-unit-detail";
import { useWorkOs } from "@/hooks/work-os/use-work-os";
import type { WorkOsState } from "@/hooks/work-os/use-work-os";
type CardData = WorkOsState["workUnitCards"][number];
type IssueId = WorkOsState["selectedIssueId"];
interface ProjectOption {
readonly host?: string;
readonly id: string;
readonly name: string;
}
const toProjectOption = (project: {
readonly id: string;
readonly name: string;
readonly sources: readonly { readonly host?: string }[];
}): ProjectOption => ({
host: project.sources[0]?.host,
id: project.id,
name: project.name,
});
const LoadingLine = ({ label }: { readonly label: string }) => (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{label}
</div>
);
const isAgentBusy = (status: string): boolean =>
status === "connecting" || status === "submitted" || status === "streaming";
const CONVERSATION_COPY = {
projectHint:
"Describe what you want Zopu to work on. Work Units created from the conversation will appear in the right panel.",
projectTitle: "Project conversation",
workUnitHint:
"Send a message to continue this Work Unit. The project manager agent will pick up the existing context.",
workUnitTitle: "Work Unit conversation",
} as const;
interface IssueComposerProps {
readonly body: string;
readonly busy: boolean;
readonly onBodyChange: (value: string) => void;
readonly onSubmit: () => void;
readonly onTitleChange: (value: string) => void;
readonly title: string;
}
const IssueComposer = ({
body,
busy,
onBodyChange,
onSubmit,
onTitleChange,
title,
}: IssueComposerProps) => (
<form
className="rounded-2xl border border-border/40 bg-card/40 p-4"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<div className="mb-3 flex items-center gap-2">
<div className="grid size-7 place-items-center rounded-lg bg-foreground text-background">
<Plus className="size-3.5" />
</div>
<p className="text-sm font-semibold">Create a Work Unit</p>
</div>
<div className="space-y-2">
<input
aria-label="Work Unit title"
className="w-full rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onTitleChange(event.target.value)}
placeholder="Outcome title"
required
value={title}
/>
<textarea
aria-label="Work Unit description"
className="min-h-20 w-full resize-none rounded-xl border border-border/40 bg-background/40 px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/20"
onChange={(event) => onBodyChange(event.target.value)}
placeholder="Describe the desired result, constraints, or evidence."
required
rows={3}
value={body}
/>
</div>
<button
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-60"
disabled={busy}
type="submit"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
{busy ? "Creating" : "Create Work Unit"}
</button>
</form>
);
const WorkUnitCards = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-6 text-center">
<GitFork className="mx-auto size-4 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">No Work Units yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create one above, or describe what you need in the conversation.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
const MobileWorkUnitList = ({
cards,
onSelect,
selectedId,
}: {
readonly cards: readonly CardData[];
readonly onSelect: (issueId: IssueId) => void;
readonly selectedId: IssueId;
}) => {
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border/40 p-4 text-center">
<p className="text-xs text-muted-foreground">
No Work Units yet. Create one or chat with Zopu.
</p>
</div>
);
}
return (
<div className="space-y-2">
{cards.map((card) => (
<WorkUnitCard
card={card}
key={card.issueId}
onSelect={() => onSelect(card.issueId)}
selected={selectedId === card.issueId}
/>
))}
</div>
);
};
interface NoProjectProps {
readonly busy: boolean;
readonly onConnectRepository: () => Promise<void>;
readonly onRepositoryChange: (value: string) => void;
readonly projects: WorkOsState["projects"];
readonly repository: string;
}
const NoProjectView = ({
busy,
onConnectRepository,
onRepositoryChange,
projects,
repository,
}: NoProjectProps) => {
if (projects === undefined) {
return (
<div className="grid h-full place-items-center">
<LoadingLine label="Loading projects" />
</div>
);
}
return (
<EmptyState
busy={busy}
onConnectRepository={onConnectRepository}
onRepositoryChange={onRepositoryChange}
repository={repository}
/>
);
};
export const WorkOsPage = () => {
const os = useWorkOs();
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const [issueTitle, setIssueTitle] = useState("");
const [issueBody, setIssueBody] = useState("");
const handleSelectIssue = (issueId: IssueId) => {
os.selectIssue(issueId);
setMobileDetailOpen(issueId !== null);
};
const handleStart = () => {
if (!os.selectedIssue) {
return;
}
void os.startIssue(
os.selectedIssue._id,
os.selectedIssue.number,
os.selectedIssue.title
);
};
const handleRaiseIssue = () => {
void os.raiseIssue({ body: issueBody, title: issueTitle });
setIssueTitle("");
setIssueBody("");
};
const handleSend = (message: string): Promise<void> =>
os.composerMode === "work-unit" && os.selectedIssueId
? os.workUnitAgent.sendMessage(message)
: os.projectAgent.sendMessage(message);
const handleModeChange = (mode: "project" | "work-unit") =>
os.setComposerMode(mode);
const handleConnectRepository = os.connectRepository;
const handleRepositoryChange = os.setRepository;
const isWorkUnitMode =
os.composerMode === "work-unit" && !!os.selectedIssueId;
const activeAgent = isWorkUnitMode ? os.workUnitAgent : os.projectAgent;
const conversationTitle = isWorkUnitMode
? CONVERSATION_COPY.workUnitTitle
: CONVERSATION_COPY.projectTitle;
const conversationHint = isWorkUnitMode
? CONVERSATION_COPY.workUnitHint
: CONVERSATION_COPY.projectHint;
const selectedProjectOption = os.selectedProject
? toProjectOption(os.selectedProject)
: null;
const projectOptions = os.projects?.map(toProjectOption) ?? undefined;
const cards = os.workUnitCards ?? [];
const startPending = os.pendingAction === `issue:${os.selectedIssue?._id}`;
const showConversation = !(mobileDetailOpen && os.selectedDetail);
return (
<div className="flex h-svh flex-col bg-background text-foreground">
<WorkOsHeader
connected={!!os.selectedProject}
onBack={() => {
os.selectIssue(null);
setMobileDetailOpen(false);
}}
onSelectProject={() => os.selectIssue(null)}
projects={projectOptions}
selectedProject={selectedProjectOption}
showBack={mobileDetailOpen && !!os.selectedIssue}
/>
{os.error ? (
<div className="flex items-start gap-3 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<p>{os.error}</p>
</div>
) : null}
{os.selectedProject ? (
<main className="flex min-h-0 flex-1">
{/* Left column: conversation + composer */}
<div className="flex min-w-0 flex-1 flex-col">
<div
className={`min-h-0 flex-1 ${showConversation ? "block" : "hidden md:block"}`}
>
<ConversationPanel
agent={activeAgent}
emptyHint={conversationHint}
title={conversationTitle}
/>
</div>
{/* Mobile: detail overlay */}
{mobileDetailOpen && os.selectedDetail ? (
<div className="min-h-0 flex-1 overflow-y-auto md:hidden">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<WorkOsComposer
busy={isAgentBusy(activeAgent.status)}
error={
os.composerMode === "work-unit"
? os.workUnitAgent.error
: os.projectAgent.error
}
mode={os.composerMode}
onModeChange={handleModeChange}
onSend={handleSend}
workUnitTitle={os.selectedIssue?.title}
/>
</div>
{/* Right column: Work Units, signals, detail */}
<aside className="hidden w-[400px] shrink-0 flex-col overflow-y-auto border-l border-border/40 bg-background/50 p-4 md:flex lg:w-[440px]">
<IssueComposer
body={issueBody}
busy={os.pendingAction === "issue"}
onBodyChange={setIssueBody}
onSubmit={handleRaiseIssue}
onTitleChange={setIssueTitle}
title={issueTitle}
/>
<div className="mt-4">
<SignalsPanel
loading={os.signals === undefined}
signals={os.signals}
/>
</div>
{os.selectedDetail ? (
<div className="mt-4">
<WorkUnitDetail
detail={os.selectedDetail}
onOpenPullRequest={(url) =>
window.open(url, "_blank", "noopener,noreferrer")
}
onStart={handleStart}
startPending={startPending}
/>
</div>
) : null}
<div className="mt-4">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<WorkUnitCards
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</aside>
{/* Mobile: Work Unit cards list */}
<div className="border-t border-border/40 p-4 md:hidden">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Work Units
</p>
<MobileWorkUnitList
cards={cards}
onSelect={handleSelectIssue}
selectedId={os.selectedIssueId}
/>
</div>
</main>
) : (
<main className="flex-1 overflow-y-auto">
<NoProjectView
busy={os.pendingAction === "connect"}
onConnectRepository={handleConnectRepository}
onRepositoryChange={handleRepositoryChange}
projects={os.projects}
repository={os.repository}
/>
</main>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
import { Badge } from "@code/ui/components/badge";
import {
Activity,
ChevronRight,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitCard as WorkUnitCardData } from "@/lib/work-os/work-unit-projection";
const STATUS_LABEL: Record<WorkUnitCardData["status"], string> = {
completed: "Completed",
failed: "Failed",
"needs-input": "Needs input",
open: "Ready",
queued: "Queued",
working: "In progress",
};
const STATUS_TONE: Record<WorkUnitCardData["status"], string> = {
completed: "bg-emerald-500/15 text-emerald-400",
failed: "bg-destructive/15 text-destructive",
"needs-input": "bg-amber-500/15 text-amber-400",
open: "bg-muted text-muted-foreground",
queued: "bg-violet-500/15 text-violet-400",
working: "bg-blue-500/15 text-blue-400",
};
interface IndicatorProps {
readonly icon: React.ReactNode;
readonly label: string;
readonly value: number;
readonly tone?: string;
}
const Indicator = ({ icon, label, tone, value }: IndicatorProps) => (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<span className={tone}>{icon}</span>
{value}
<span className="sr-only">{label}</span>
</span>
);
interface WorkUnitCardProps {
readonly card: WorkUnitCardData;
readonly selected: boolean;
readonly onSelect: () => void;
}
export const WorkUnitCard = ({
card,
onSelect,
selected,
}: WorkUnitCardProps) => (
<button
className={`w-full cursor-pointer rounded-2xl border p-4 text-left transition-all duration-200 ${
selected
? "border-foreground/20 bg-foreground/5"
: "border-border/40 bg-card/40 hover:border-border/70 hover:bg-card/60"
}`}
onClick={onSelect}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium text-muted-foreground">
#{card.number}
</p>
<h3 className="mt-0.5 text-sm font-semibold leading-5 tracking-tight">
{card.title}
</h3>
</div>
<Badge
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[card.status]}`}
variant="secondary"
>
{STATUS_LABEL[card.status]}
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">
{card.summary}
</p>
<div className="mt-3 flex flex-wrap items-center gap-3 border-t border-border/30 pt-3">
{card.currentActivity ? (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Activity className="size-3" />
{card.currentActivity}
</span>
) : null}
<Indicator
icon={<Radio className="size-3" />}
label="signals"
value={card.signalCount}
/>
<Indicator
icon={<Layers className="size-3" />}
label="steps"
value={card.stepCount}
/>
<Indicator
icon={<FileText className="size-3" />}
label="artifacts"
value={card.artifactCount}
/>
{card.hasPullRequest ? (
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400">
<GitPullRequest className="size-3" />
PR
</span>
) : null}
{card.needsInput ? (
<span className="inline-flex items-center gap-1 text-[11px] text-amber-400">
<TriangleAlert className="size-3" />
Input needed
</span>
) : null}
<span className="ml-auto inline-flex items-center gap-1">
<span className="text-[11px] text-muted-foreground/70">
{formatRelativeTime(card.updatedAt)}
</span>
<ChevronRight className="size-3.5 text-muted-foreground/50" />
</span>
</div>
</button>
);

View File

@@ -0,0 +1,237 @@
import { Button } from "@code/ui/components/button";
import {
ArrowUpRight,
Bot,
ExternalLink,
FileText,
GitPullRequest,
Layers,
Radio,
TriangleAlert,
} from "lucide-react";
import { formatRelativeTime } from "@/lib/work-os/work-unit-projection";
import type { WorkUnitDetail as WorkUnitDetailData } from "@/lib/work-os/work-unit-projection";
const formatStatus = (
status: WorkUnitDetailData["issue"]["status"]
): string => {
if (status === "needs-input") {
return "Needs input";
}
if (status === "working") {
return "In progress";
}
return status.charAt(0).toUpperCase() + status.slice(1);
};
interface WorkUnitDetailProps {
readonly detail: WorkUnitDetailData;
readonly onOpenPullRequest: (url: string) => void;
readonly onStart: () => void;
readonly startPending: boolean;
}
const Section = ({
children,
icon,
title,
}: {
readonly children: React.ReactNode;
readonly icon: React.ReactNode;
readonly title: string;
}) => (
<section className="rounded-2xl border border-border/30 bg-card/30 p-4">
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{icon}
{title}
</div>
{children}
</section>
);
export const WorkUnitDetail = ({
detail,
onOpenPullRequest,
onStart,
startPending,
}: WorkUnitDetailProps) => {
const { issue } = detail;
const canStart = issue.status === "open" || issue.status === "failed";
return (
<div className="space-y-4">
{/* Objective */}
<section className="rounded-2xl border border-border/40 bg-card/40 p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-medium text-muted-foreground">
Work Unit #{issue.number}
</p>
<h2 className="mt-1 text-lg font-semibold leading-7 tracking-tight">
{issue.title}
</h2>
</div>
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 text-[11px] font-medium">
{formatStatus(issue.status)}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
{issue.body}
</p>
{canStart ? (
<Button
className="mt-4 w-full"
disabled={startPending}
onClick={onStart}
type="button"
>
<Bot className="size-4" />
{startPending ? "Starting" : "Start work"}
</Button>
) : null}
</section>
{/* Needs input alert */}
{detail.needsInput ? (
<div className="flex items-start gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div>
<p className="text-sm font-medium text-amber-300">
This work unit needs your input
</p>
<p className="mt-1 text-xs leading-5 text-amber-400/70">
Resolve the open question or decision to unblock the agent.
</p>
</div>
</div>
) : null}
{/* Latest summary */}
{detail.latestSummary ? (
<Section icon={<ArrowUpRight className="size-3" />} title="Latest">
<p className="text-sm leading-6">{detail.latestSummary}</p>
</Section>
) : null}
{/* Linked Signals — authenticated via signalIssueAttachments */}
<Section
icon={<Radio className="size-3" />}
title={`Signals (${detail.signalCount})`}
>
{detail.linkedSignals.length > 0 ? (
<div className="space-y-3">
{detail.linkedSignals.map((signal) => (
<div key={String(signal.signalId)}>
<p className="text-sm font-medium">{signal.title}</p>
<p className="mt-0.5 line-clamp-2 text-xs leading-5 text-muted-foreground">
{signal.summary}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">
{signal.sourceCount} source
{signal.sourceCount === 1 ? "" : "s"} ·{" "}
{formatRelativeTime(signal.createdAt)}
</p>
</div>
))}
</div>
) : (
<p className="text-xs leading-5 text-muted-foreground">
No Signals linked to this Work Unit.
</p>
)}
</Section>
{/* Activity timeline */}
<Section
icon={<Layers className="size-3" />}
title={`Timeline (${detail.stepCount} steps)`}
>
{detail.activity.length > 0 ? (
<div className="space-y-4">
{detail.activity.map((item) => (
<div
className="flex gap-3"
key={`${item.kind}-${item.createdAt}`}
>
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-foreground/40" />
<div className="min-w-0">
<p className="text-xs font-medium">{item.label}</p>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
{item.detail}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{item.time}
</p>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No activity recorded yet.
</p>
)}
</Section>
{/* Artifacts */}
<Section
icon={<FileText className="size-3" />}
title={`Artifacts (${detail.artifactCount})`}
>
{detail.artifactPaths.length > 0 ? (
<div className="flex flex-wrap gap-2">
{detail.artifactPaths.map((path) => (
<span
className="inline-flex items-center gap-1.5 rounded-lg bg-muted/60 px-2.5 py-1 text-[11px]"
key={path}
>
<FileText className="size-3 text-muted-foreground" />
{path}
</span>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
No artifacts produced yet.
</p>
)}
</Section>
{/* Pull request */}
{detail.hasPullRequest ? (
<Section
icon={<GitPullRequest className="size-3" />}
title="Pull request"
>
{detail.pullRequestUrl ? (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-sm font-medium text-emerald-300">
{detail.pullRequestNumber
? `PR #${detail.pullRequestNumber} ready for review`
: "Pull request ready"}
</p>
<Button
className="mt-3"
onClick={() =>
detail.pullRequestUrl &&
onOpenPullRequest(detail.pullRequestUrl)
}
size="sm"
type="button"
variant="outline"
>
<ExternalLink className="size-3.5" />
Review changes
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground">
A pull request was opened but the URL is unavailable.
</p>
)}
</Section>
) : null}
</div>
);
};

View File

@@ -0,0 +1,58 @@
import type { ChangeEvent, FormEvent, KeyboardEvent } from "react";
import { useRef, useState } from "react";
interface UseChatComposerOptions {
busy: boolean;
onSend: (message: string) => Promise<void>;
}
export const useChatComposer = ({ busy, onSend }: UseChatComposerOptions) => {
const [draft, setDraft] = useState("");
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSend = draft.trim().length > 0 && !busy;
const handleSubmit = async (event?: FormEvent) => {
event?.preventDefault();
const message = draft.trim();
if (!message || busy) {
return;
}
setDraft("");
try {
await onSend(message);
} finally {
textareaRef.current?.focus();
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void handleSubmit();
}
};
const inputProps = {
onBlur: () => setIsFocused(false),
onChange: (event: ChangeEvent<HTMLTextAreaElement>) =>
setDraft(event.target.value),
onFocus: () => setIsFocused(true),
onKeyDown: handleKeyDown,
ref: textareaRef,
value: draft,
};
return {
canSend,
draft,
handleSubmit,
inputProps,
isFocused,
};
};

View File

@@ -0,0 +1,203 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueAgent } from "@flue/react";
import { useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
import type { ChatAgentState } from "@/lib/chat/types";
import {
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
} from "@/lib/work-os/work-unit-projection";
import type {
LinkedSignal,
WorkUnitCard,
WorkUnitDetail,
} from "@/lib/work-os/work-unit-projection";
export interface SignalItem {
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export type ComposerMode = "project" | "work-unit";
export interface WorkOsState {
readonly projects: ReturnType<typeof useProjectWorkspace>["projects"];
readonly selectedProject: ReturnType<
typeof useProjectWorkspace
>["selectedProject"];
readonly selectedProjectId: ReturnType<
typeof useProjectWorkspace
>["selectedProjectId"];
readonly repository: string;
readonly setRepository: (value: string) => void;
readonly connectRepository: () => Promise<void>;
readonly workUnitCards: readonly WorkUnitCard[];
readonly selectedIssue: ReturnType<
typeof useProjectWorkspace
>["selectedIssue"];
readonly selectedIssueId: Id<"projectIssues"> | null;
readonly selectedDetail: WorkUnitDetail | null;
readonly selectIssue: (issueId: Id<"projectIssues"> | null) => void;
readonly startIssue: ReturnType<typeof useProjectWorkspace>["startIssue"];
readonly raiseIssue: ReturnType<typeof useProjectWorkspace>["raiseIssue"];
readonly issueTitle: string;
readonly setIssueTitle: (value: string) => void;
readonly issueBody: string;
readonly setIssueBody: (value: string) => void;
readonly signals: readonly SignalItem[];
readonly composerMode: ComposerMode;
readonly setComposerMode: (mode: ComposerMode) => void;
readonly projectAgent: ChatAgentState;
readonly workUnitAgent: ChatAgentState;
readonly pendingAction: string | null;
readonly error: string | null;
}
const toLinkedSignals = (
entries:
| readonly {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}[]
| undefined
): readonly LinkedSignal[] =>
(entries ?? []).map((entry) => ({
createdAt: entry.createdAt,
signalId: entry.signalId,
sourceCount: entry.sourceCount,
summary: entry.summary,
title: entry.title,
}));
export const useWorkOs = (): WorkOsState => {
const workspace = useProjectWorkspace();
const orgState = usePersonalOrganization();
const projectAgent = useChatAgent();
const activeProjectId = workspace.selectedProjectId;
const workUnitFlue = useFlueAgent({
id: workspace.selectedIssueId
? String(workspace.selectedIssueId)
: undefined,
live: "sse",
name: "project-manager",
});
const workUnitAgent: ChatAgentState = {
error: workUnitFlue.error,
historyReady: workUnitFlue.historyReady,
messages: workUnitFlue.messages,
sendMessage: workUnitFlue.sendMessage,
status: workspace.selectedIssueId ? workUnitFlue.status : "idle",
};
// Project-level signals for the global sidebar panel.
const allSignals = useQuery(
api.signals.list,
orgState.organizationId
? { organizationId: orgState.organizationId }
: "skip"
);
const signals = useMemo<readonly SignalItem[]>(() => {
if (!allSignals || !activeProjectId) {
return [];
}
return allSignals
.filter((entry) => entry.signal.projectId === activeProjectId)
.map((entry) => ({
createdAt: entry.signal.createdAt,
sourceCount: entry.sources.length,
summary: entry.signal.problemStatement.summary,
title: entry.signal.problemStatement.title,
}));
}, [allSignals, activeProjectId]);
// All linked signals for the active project, grouped by issue id.
// Used for card counts and detail display.
const linkedByIssue = useQuery(
api.signals.listLinkedSignalsForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const [composerMode, setComposerMode] = useState<ComposerMode>("project");
const selectIssue = (issueId: Id<"projectIssues"> | null) => {
workspace.setSelectedIssueId(issueId);
setComposerMode(issueId ? "work-unit" : "project");
};
const workUnitCards = useMemo<readonly WorkUnitCard[]>(() => {
if (!workspace.issues) {
return [];
}
return workspace.issues.map((issue) => {
const issueEvents = eventsForIssue(workspace.events, issue._id);
const linked = toLinkedSignals(linkedByIssue?.[String(issue._id)]);
return buildWorkUnitCard({
issue,
issueEvents,
linkedSignals: linked,
});
});
}, [workspace.issues, workspace.events, linkedByIssue]);
const selectedDetail = useMemo<WorkUnitDetail | null>(() => {
if (!workspace.selectedIssue) {
return null;
}
const issueEvents = eventsForIssue(
workspace.events,
workspace.selectedIssue._id
);
const linked = toLinkedSignals(
linkedByIssue?.[String(workspace.selectedIssue._id)]
);
return buildWorkUnitDetail({
issue: workspace.selectedIssue,
issueEvents,
linkedSignals: linked,
});
}, [workspace.selectedIssue, workspace.events, linkedByIssue]);
return {
composerMode,
connectRepository: workspace.connectRepository,
error: workspace.error,
issueBody: workspace.issueBody,
issueTitle: workspace.issueTitle,
pendingAction: workspace.pendingAction,
projectAgent,
projects: workspace.projects,
raiseIssue: workspace.raiseIssue,
repository: workspace.repository,
selectIssue,
selectedDetail,
selectedIssue: workspace.selectedIssue,
selectedIssueId: workspace.selectedIssueId,
selectedProject: workspace.selectedProject,
selectedProjectId: activeProjectId,
setComposerMode,
setIssueBody: workspace.setIssueBody,
setIssueTitle: workspace.setIssueTitle,
setRepository: workspace.setRepository,
signals,
startIssue: workspace.startIssue,
workUnitAgent,
workUnitCards,
};
};

View File

@@ -0,0 +1,484 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { describe, expect, test } from "vitest";
import {
buildActivityTimeline,
buildWorkUnitCard,
buildWorkUnitDetail,
eventsForIssue,
extractArtifactPaths,
extractLatestSummary,
extractPullRequestFromEvents,
} from "./work-unit-projection";
import type { LinkedSignal } from "./work-unit-projection";
type Issue = Doc<"projectIssues">;
type Event = Doc<"projectEvents">;
const ISSUE_A = "issue-a" as Id<"projectIssues">;
const ISSUE_B = "issue-b" as Id<"projectIssues">;
const makeLinkedSignal = (
overrides: Partial<LinkedSignal> = {}
): LinkedSignal => ({
createdAt: 5000,
signalId: "sig-1" as Id<"signals">,
sourceCount: 2,
summary: "Three users hit the same error.",
title: "OAuth crash on Safari",
...overrides,
});
const makeIssue = (overrides: Partial<Issue> = {}): Issue =>
({
_creationTime: 1,
_id: ISSUE_A,
body: "Fix the Safari OAuth callback so users can complete sign-in.",
createdAt: 1000,
number: 1,
projectId: "project-1" as Id<"projects">,
status: "open",
title: "Fix Safari OAuth callback",
updatedAt: 2000,
...overrides,
}) as Issue;
const makeEvent = (
kind: string,
data: Record<string, unknown>,
overrides: Partial<Event> = {}
): Event =>
({
_creationTime: 1,
_id: `evt-${kind}-${overrides.createdAt ?? 1000}` as Id<"projectEvents">,
createdAt: overrides.createdAt ?? 1000,
data,
issueId: ISSUE_A,
kind,
projectId: "project-1" as Id<"projects">,
...overrides,
}) as Event;
describe("eventsForIssue", () => {
test("filters to a specific issue and ignores undefined", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 2000, issueId: ISSUE_B }
),
];
expect(eventsForIssue(events, ISSUE_A)).toHaveLength(1);
expect(eventsForIssue(undefined, ISSUE_A)).toEqual([]);
});
});
describe("extractArtifactPaths", () => {
test("collects distinct paths from artifact.updated events", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 3000 }
),
makeEvent("issue.created", { number: 1 }, { createdAt: 4000 }),
];
expect(extractArtifactPaths(events)).toEqual(["artifacts.md", "work.md"]);
});
test("returns empty for no artifact events", () => {
expect(extractArtifactPaths([])).toEqual([]);
});
});
describe("buildWorkUnitCard", () => {
test("projects a fresh issue with no events and no linked signals", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(card.title).toBe("Fix Safari OAuth callback");
expect(card.status).toBe("open");
expect(card.stepCount).toBe(0);
expect(card.currentActivity).toBeNull();
expect(card.needsInput).toBe(false);
expect(card.hasPullRequest).toBe(false);
expect(card.artifactCount).toBe(0);
expect(card.signalCount).toBe(0);
expect(card.summary).toContain("Fix the Safari OAuth");
});
test("counts per-issue artifacts from events, not project-wide", () => {
const events: Event[] = [
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 1000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "working" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.artifactCount).toBe(2);
expect(card.stepCount).toBe(2);
});
test("reflects needs-input status and activity from events", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("issue.queued", { number: 1 }, { createdAt: 2000 }),
makeEvent("agent.needs-input", {}, { createdAt: 3000 }),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "needs-input" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.needsInput).toBe(true);
expect(card.stepCount).toBe(3);
expect(card.currentActivity).toBe("Needs your input");
});
test("detects PR from gitea event", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"gitea.pull_request.created",
{
pullRequest: { number: 42, url: "https://git.example.com/pulls/42" },
},
{ createdAt: 5000 }
),
];
const card = buildWorkUnitCard({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
linkedSignals: [],
});
expect(card.hasPullRequest).toBe(true);
});
});
describe("linked signal projection", () => {
test("card signalCount reflects real linked signals", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [
makeLinkedSignal(),
makeLinkedSignal({
signalId: "sig-2" as Id<"signals">,
title: "Build error",
}),
],
});
expect(card.signalCount).toBe(2);
});
test("card signalCount is 0 when no signals are linked", () => {
const card = buildWorkUnitCard({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(card.signalCount).toBe(0);
});
test("detail exposes linked signals with title, summary, and source count", () => {
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [
makeLinkedSignal(),
makeLinkedSignal({
signalId: "sig-2" as Id<"signals">,
sourceCount: 5,
summary: "CI broke",
}),
],
});
expect(detail.signalCount).toBe(2);
expect(detail.linkedSignals).toHaveLength(2);
expect(detail.linkedSignals[0].title).toBe("OAuth crash on Safari");
expect(detail.linkedSignals[0].sourceCount).toBe(2);
expect(detail.linkedSignals[1].sourceCount).toBe(5);
});
test("detail linked signals empty when no attachment exists", () => {
const detail = buildWorkUnitDetail({
issue: makeIssue(),
issueEvents: [],
linkedSignals: [],
});
expect(detail.signalCount).toBe(0);
expect(detail.linkedSignals).toEqual([]);
});
});
describe("extractLatestSummary", () => {
test("returns the most recent agent summary", () => {
const events = [
makeEvent("agent.working", {}, { createdAt: 1000 }),
makeEvent(
"agent.completed",
{ summary: "All tests passed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("All tests passed.");
});
test("returns null when no terminal agent event exists", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
];
expect(extractLatestSummary(events)).toBeNull();
});
test("prefers failure error over earlier completion", () => {
const events = [
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 1000 }),
makeEvent(
"agent.failed",
{ error: "Tests failed." },
{ createdAt: 5000 }
),
];
expect(extractLatestSummary(events)).toBe("Tests failed.");
});
});
describe("extractPullRequestFromEvents", () => {
test("extracts URL and number from gitea event", () => {
const events = [
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 7, url: "https://git.example.com/pulls/7" } },
{ createdAt: 1000 }
),
];
const info = extractPullRequestFromEvents(events);
expect(info.hasPR).toBe(true);
expect(info.url).toBe("https://git.example.com/pulls/7");
expect(info.number).toBe(7);
});
test("returns false when no PR event exists", () => {
expect(extractPullRequestFromEvents([]).hasPR).toBe(false);
});
});
describe("buildActivityTimeline", () => {
test("sorts newest first and maps labels", () => {
const events = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent("agent.completed", { summary: "Done." }, { createdAt: 5000 }),
];
const timeline = buildActivityTimeline(events);
expect(timeline).toHaveLength(2);
expect(timeline[0].kind).toBe("agent.completed");
expect(timeline[0].label).toBe("Work completed");
expect(timeline[1].kind).toBe("issue.created");
expect(timeline[1].label).toBe("Work created");
});
test("maps signal.attached event to activity", () => {
const events = [
makeEvent(
"signal.attached",
{ signalId: "sig-1", signalProblemTitle: "OAuth crash" },
{ createdAt: 3000 }
),
];
const timeline = buildActivityTimeline(events);
expect(timeline[0].label).toBe("Signal linked");
expect(timeline[0].detail).toContain("OAuth crash");
});
});
describe("buildWorkUnitDetail", () => {
test("aggregates all detail fields from per-issue events", () => {
const events: Event[] = [
makeEvent("issue.created", { number: 1 }, { createdAt: 1000 }),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 2 },
{ createdAt: 2000 }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500 }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 3, url: "https://git.example.com/pulls/3" } },
{ createdAt: 3000 }
),
];
const detail = buildWorkUnitDetail({
issue: makeIssue({ status: "completed" }),
issueEvents: events,
linkedSignals: [makeLinkedSignal()],
});
expect(detail.stepCount).toBe(4);
expect(detail.artifactCount).toBe(2);
expect(detail.artifactPaths).toEqual(["artifacts.md", "work.md"]);
expect(detail.signalCount).toBe(1);
expect(detail.hasPullRequest).toBe(true);
expect(detail.pullRequestUrl).toBe("https://git.example.com/pulls/3");
expect(detail.pullRequestNumber).toBe(3);
expect(detail.activity).toHaveLength(4);
expect(detail.activity[0].kind).toBe("gitea.pull_request.created");
});
});
// ---------------------------------------------------------------------------
// Cross-issue isolation: one issue must never inherit another issue's
// artifacts, PR, signals, or activity.
// ---------------------------------------------------------------------------
describe("cross-issue isolation", () => {
const SIGNAL_A = makeLinkedSignal({ signalId: "sig-a" as Id<"signals"> });
const SIGNAL_B = makeLinkedSignal({
signalId: "sig-b" as Id<"signals">,
title: "Different signal",
});
const sharedEvents: Event[] = [
makeEvent(
"issue.created",
{ number: 1 },
{ createdAt: 1000, issueId: ISSUE_A }
),
makeEvent(
"artifact.updated",
{ path: "work.md", revision: 1 },
{ createdAt: 2000, issueId: ISSUE_A }
),
makeEvent(
"gitea.pull_request.created",
{ pullRequest: { number: 5, url: "https://git.example.com/pulls/5" } },
{ createdAt: 3000, issueId: ISSUE_A }
),
makeEvent(
"agent.completed",
{ summary: "Done for A." },
{ createdAt: 4000, issueId: ISSUE_A }
),
// Issue B events — completely separate
makeEvent(
"issue.created",
{ number: 2 },
{ createdAt: 1500, issueId: ISSUE_B }
),
makeEvent(
"artifact.updated",
{ path: "artifacts.md", revision: 1 },
{ createdAt: 2500, issueId: ISSUE_B }
),
makeEvent(
"agent.failed",
{ error: "Failed for B." },
{ createdAt: 3500, issueId: ISSUE_B }
),
];
test("issue A does not inherit issue B's artifacts", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const cardA = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
});
expect(cardA.artifactCount).toBe(1);
expect(cardA.stepCount).toBe(4);
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [SIGNAL_B],
});
expect(cardB.artifactCount).toBe(1);
expect(cardB.stepCount).toBe(3);
});
test("issue A does not inherit issue B's PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const cardB = buildWorkUnitCard({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [],
});
expect(cardB.hasPullRequest).toBe(false);
});
test("issue A does not inherit issue B's summary", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
expect(extractLatestSummary(eventsA)).toBe("Done for A.");
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
expect(extractLatestSummary(eventsB)).toBe("Failed for B.");
});
test("detail for issue A lists only issue A's artifact paths", () => {
const eventsA = eventsForIssue(sharedEvents, ISSUE_A);
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsA,
linkedSignals: [SIGNAL_A],
});
expect(detailA.artifactPaths).toEqual(["work.md"]);
expect(detailA.pullRequestNumber).toBe(5);
});
test("detail for issue B lists only issue B's artifact paths and no PR", () => {
const eventsB = eventsForIssue(sharedEvents, ISSUE_B);
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsB,
linkedSignals: [],
});
expect(detailB.artifactPaths).toEqual(["artifacts.md"]);
expect(detailB.hasPullRequest).toBe(false);
expect(detailB.pullRequestUrl).toBeNull();
});
test("issue A does not inherit issue B's linked signals", () => {
const detailA = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_A, number: 1 }),
issueEvents: eventsForIssue(sharedEvents, ISSUE_A),
linkedSignals: [SIGNAL_A],
});
expect(detailA.signalCount).toBe(1);
expect(detailA.linkedSignals[0].title).toBe("OAuth crash on Safari");
const detailB = buildWorkUnitDetail({
issue: makeIssue({ _id: ISSUE_B, number: 2, title: "Issue B" }),
issueEvents: eventsForIssue(sharedEvents, ISSUE_B),
linkedSignals: [SIGNAL_B],
});
expect(detailB.signalCount).toBe(1);
expect(detailB.linkedSignals[0].title).toBe("Different signal");
});
});

View File

@@ -0,0 +1,329 @@
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
// ---------------------------------------------------------------------------
// Work Unit card projection — pure functions that turn Convex documents into
// the collapsed/expanded Work Unit views used by the Work OS surface.
//
// Counts are derived from issue-scoped evidence: events for artifacts/steps/
// PRs, and authenticated signalIssueAttachments for linked Signals. We never
// use project-wide artifact lists or signal lists.
// ---------------------------------------------------------------------------
export type ProjectEvent = Doc<"projectEvents">;
export type ProjectIssue = Doc<"projectIssues">;
/** A Signal linked to a Work Unit via signalIssueAttachments. */
export interface LinkedSignal {
readonly signalId: Id<"signals">;
readonly title: string;
readonly summary: string;
readonly sourceCount: number;
readonly createdAt: number;
}
export interface WorkUnitCard {
readonly issueId: Id<"projectIssues">;
readonly number: number;
readonly title: string;
readonly status: ProjectIssue["status"];
readonly summary: string;
readonly signalCount: number;
readonly currentActivity: string | null;
readonly stepCount: number;
readonly artifactCount: number;
readonly hasPullRequest: boolean;
readonly needsInput: boolean;
readonly updatedAt: number;
}
export interface WorkUnitActivityItem {
readonly label: string;
readonly detail: string;
readonly time: string;
readonly kind: string;
readonly createdAt: number;
}
export interface WorkUnitDetail {
readonly issue: ProjectIssue;
readonly activity: readonly WorkUnitActivityItem[];
readonly stepCount: number;
readonly artifactCount: number;
readonly artifactPaths: readonly string[];
readonly signalCount: number;
readonly linkedSignals: readonly LinkedSignal[];
readonly hasPullRequest: boolean;
readonly pullRequestUrl: string | null;
readonly pullRequestNumber: number | null;
readonly needsInput: boolean;
readonly latestSummary: string | null;
}
// ---------------------------------------------------------------------------
// Time formatting
// ---------------------------------------------------------------------------
export const formatRelativeTime = (timestamp: number): string => {
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
return `${Math.round(hours / 24)}d ago`;
};
export const formatClockTime = (timestamp: number): string =>
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(timestamp);
// ---------------------------------------------------------------------------
// Event filtering
// ---------------------------------------------------------------------------
export const eventsForIssue = (
events: readonly ProjectEvent[] | undefined,
issueId: Id<"projectIssues">
): readonly ProjectEvent[] =>
(events ?? []).filter((e) => e.issueId === issueId);
// ---------------------------------------------------------------------------
// Event-to-activity projection
// ---------------------------------------------------------------------------
interface ActivityProjection {
readonly label: string;
readonly detail: string;
}
const getString = (
data: Record<string, unknown>,
key: string,
fallback: string
): string => (typeof data[key] === "string" ? (data[key] as string) : fallback);
const ACTIVITY_HANDLERS: Record<
string,
(data: Record<string, unknown>, event: ProjectEvent) => ActivityProjection
> = {
"agent.completed": (data) => ({
detail: getString(data, "summary", "Run completed successfully"),
label: "Work completed",
}),
"agent.failed": (data) => ({
detail: getString(data, "error", "The run stopped unexpectedly"),
label: "Run failed",
}),
"agent.needs-input": () => ({
detail: "Waiting for a decision or missing information",
label: "Needs your input",
}),
"agent.working": () => ({
detail: "The project manager is progressing the issue",
label: "Agent working",
}),
"agent.workspace.created": (data) => ({
detail: `Workspace ready on ${getString(data, "branchName", "work branch")}`,
label: "Workspace prepared",
}),
"artifact.updated": (data) => ({
detail: `Evidence revised in ${getString(data, "path", "artifact")} (r${data.revision ?? "?"})`,
label: "Evidence updated",
}),
"gitea.lifecycle.updated": (data) => ({
detail: `${getString(data, "status", "updated")} on ${getString(data, "branch", "branch")}`,
label: "Git lifecycle",
}),
"gitea.pull_request.created": (data) => {
const pr = data.pullRequest as { number?: number } | undefined;
return {
detail: `Pull request #${pr?.number ?? "?"} opened for review`,
label: "Pull request opened",
};
},
"issue.created": (data) => ({
detail: `Issue #${data.number ?? "?"} entered the loop`,
label: "Work created",
}),
"issue.queued": (data) => ({
detail: `Queued #${data.number ?? "?"} for the project manager`,
label: "Work queued",
}),
"signal.attached": (data) => ({
detail: `Signal "${getString(data, "signalProblemTitle", "Unknown")}" linked to this Work Unit`,
label: "Signal linked",
}),
};
const projectEventToActivity = (event: ProjectEvent): ActivityProjection => {
const data = (event.data ?? {}) as Record<string, unknown>;
const handler = ACTIVITY_HANDLERS[event.kind];
if (handler) {
return handler(data, event);
}
return { detail: event.kind, label: "Activity" };
};
export const buildActivityTimeline = (
issueEvents: readonly ProjectEvent[]
): readonly WorkUnitActivityItem[] =>
[...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.map((event) => {
const projection = projectEventToActivity(event);
return {
createdAt: event.createdAt,
detail: projection.detail,
kind: event.kind,
label: projection.label,
time: formatRelativeTime(event.createdAt),
};
});
// ---------------------------------------------------------------------------
// PR extraction from issue-scoped events
// ---------------------------------------------------------------------------
export interface PullRequestInfo {
readonly hasPR: boolean;
readonly url: string | null;
readonly number: number | null;
}
export const extractPullRequestFromEvents = (
issueEvents: readonly ProjectEvent[]
): PullRequestInfo => {
const prEvent = issueEvents.find(
(e) => e.kind === "gitea.pull_request.created"
);
if (!prEvent) {
return { hasPR: false, number: null, url: null };
}
const data = prEvent.data as {
pullRequest?: { url?: string; number?: number };
};
const pr = data.pullRequest;
return {
hasPR: true,
number: pr?.number ?? null,
url: pr?.url ?? null,
};
};
// ---------------------------------------------------------------------------
// Latest agent summary
// ---------------------------------------------------------------------------
export const extractLatestSummary = (
issueEvents: readonly ProjectEvent[]
): string | null => {
const summaryEvent = [...issueEvents]
.toSorted((a, b) => b.createdAt - a.createdAt)
.find((e) => e.kind === "agent.completed" || e.kind === "agent.failed");
if (!summaryEvent) {
return null;
}
const data = summaryEvent.data as { summary?: string; error?: string };
if (summaryEvent.kind === "agent.completed") {
return data.summary ?? null;
}
return data.error ?? null;
};
// ---------------------------------------------------------------------------
// Artifact path extraction from issue-scoped artifact.updated events
// ---------------------------------------------------------------------------
export const extractArtifactPaths = (
issueEvents: readonly ProjectEvent[]
): readonly string[] => {
const paths = new Set<string>();
for (const event of issueEvents) {
if (event.kind !== "artifact.updated") {
continue;
}
const data = event.data as { path?: string };
if (typeof data.path === "string") {
paths.add(data.path);
}
}
return [...paths].toSorted();
};
// ---------------------------------------------------------------------------
// Card builder — derives counts from issue-scoped events + linked signals
// ---------------------------------------------------------------------------
export const buildWorkUnitCard = ({
issue,
issueEvents,
linkedSignals,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitCard => {
const sorted = [...issueEvents].toSorted((a, b) => b.createdAt - a.createdAt);
const [latestEvent] = sorted;
const currentActivity = latestEvent
? projectEventToActivity(latestEvent).label
: null;
const prInfo = extractPullRequestFromEvents(issueEvents);
const summary = extractLatestSummary(issueEvents) ?? issue.body.slice(0, 140);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
artifactCount: artifactPaths.length,
currentActivity,
hasPullRequest: prInfo.hasPR,
issueId: issue._id,
needsInput: issue.status === "needs-input",
number: issue.number,
signalCount: linkedSignals.length,
status: issue.status,
stepCount: issueEvents.length,
summary,
title: issue.title,
updatedAt: issue.updatedAt,
};
};
// ---------------------------------------------------------------------------
// Detail builder — same per-issue derivation, plus linked signals
// ---------------------------------------------------------------------------
export const buildWorkUnitDetail = ({
issue,
issueEvents,
linkedSignals,
}: {
readonly issue: ProjectIssue;
readonly issueEvents: readonly ProjectEvent[];
readonly linkedSignals: readonly LinkedSignal[];
}): WorkUnitDetail => {
const activity = buildActivityTimeline(issueEvents);
const prInfo = extractPullRequestFromEvents(issueEvents);
const artifactPaths = extractArtifactPaths(issueEvents);
return {
activity,
artifactCount: artifactPaths.length,
artifactPaths,
hasPullRequest: prInfo.hasPR,
issue,
latestSummary: extractLatestSummary(issueEvents),
linkedSignals,
needsInput: issue.status === "needs-input",
pullRequestNumber: prInfo.number,
pullRequestUrl: prInfo.url,
signalCount: linkedSignals.length,
stepCount: issueEvents.length,
};
};