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
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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));
|
||||
@@ -1,174 +0,0 @@
|
||||
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()),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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)));
|
||||
@@ -1,88 +0,0 @@
|
||||
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,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
1
apps/desktop/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
artifacts
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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.");
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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
apps/native/.gitignore
vendored
@@ -1,21 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "code",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"orientation": "default",
|
||||
"web": {
|
||||
"bundler": "metro"
|
||||
},
|
||||
"name": "code",
|
||||
"slug": "code",
|
||||
"plugins": ["expo-font"],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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;
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@import "uniwind";
|
||||
@import "heroui-native/styles";
|
||||
|
||||
@source './node_modules/heroui-native/lib';
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
3
apps/native/uniwind-env.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
/// <reference types="uniwind/types" />
|
||||
|
||||
declare module "*.css";
|
||||
34
apps/tui/.gitignore
vendored
@@ -1,34 +0,0 @@
|
||||
# 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
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 />);
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@code/ui": "workspace:*",
|
||||
"@code/work-os": "workspace:*",
|
||||
"@flue/react": "1.0.0-beta.9",
|
||||
"@flue/sdk": "1.0.0-beta.9",
|
||||
"@react-router/fs-routes": "^8.1.0",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
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]}
|
||||
/>
|
||||
);
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,907 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { projectWorkNotices } from "@code/primitives/work";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
} from "@code/ui/components/ai-elements/conversation";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import { projectWorkNotices } from "@code/work-os";
|
||||
import {
|
||||
ChevronRight,
|
||||
FolderGit2,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,71 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,401 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,237 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkNotice } from "@code/work-os";
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkNotice } from "@code/work-os";
|
||||
import type { WorkNotice } from "@code/primitives/work";
|
||||
import type { FlueConversationMessage } from "@flue/react";
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,329 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||