Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6364c33499 | ||
|
|
db9afd7a24 | ||
|
|
ce16813fbc | ||
|
|
95c23db4fc | ||
|
|
08bc5ae259 | ||
|
|
4dc878b8cb | ||
|
|
3e3f06403f | ||
|
|
8bd3953440 | ||
|
|
bb4bd9e0d3 | ||
|
|
f208d4e4b8 | ||
|
|
fc1d0b6916 | ||
|
|
fc8cfabacb | ||
|
|
ce1f33aa69 | ||
|
|
c644ec8d01 | ||
|
|
88f53f550d | ||
|
|
54a5993274 | ||
|
|
def3fc949a | ||
|
|
9f7dd3c1a6 | ||
|
|
a8b2ff5e2e | ||
|
|
40e0f7e1eb | ||
|
|
bf2300a6be | ||
|
|
0657037c84 | ||
|
|
64a783b445 | ||
|
|
ed28943e7a | ||
|
|
9e148489f0 | ||
|
|
25f86d94cc | ||
|
|
7668fa69cc | ||
|
|
3ffa1cfc7c | ||
|
|
18eb150d7d | ||
|
|
e1b0b731e0 | ||
|
|
d428a2492b | ||
|
|
fe0fd9b16c | ||
|
|
fc1fcf5d44 | ||
|
|
3ae72864bd | ||
|
|
0d5d54caa8 | ||
|
|
830bcc4756 | ||
|
|
0e56a462cd | ||
|
|
9fb293a539 | ||
|
|
062c00f53c | ||
|
|
a7e70c9b2a | ||
|
|
526ed59776 | ||
|
|
fd3980c6bf | ||
|
|
d8a4bbe804 | ||
|
|
9eb6bcd25f | ||
|
|
a907539810 | ||
|
|
601aca73c2 | ||
|
|
ffecff3857 | ||
|
|
0d7162544b | ||
|
|
092a9793ea | ||
|
|
5ee0a8d50e | ||
|
|
420676f2d7 | ||
|
|
24d82e2a06 | ||
|
|
d47fa0e96a | ||
|
|
1e7c893985 | ||
|
|
f9ebcb4a01 | ||
|
|
dceaa2b417 | ||
|
|
a4f121e190 | ||
|
|
4edd456b5b | ||
|
|
4d9f6da41b | ||
|
|
a53029cf7a | ||
|
|
eede4c10ba | ||
|
|
35169672e1 | ||
|
|
359d9e2285 | ||
|
|
05a3baaac3 | ||
|
|
4cc40cb2e4 | ||
|
|
814df02be9 | ||
|
|
d7f6cbdcdc | ||
|
|
a8d946b6a9 | ||
|
|
0e32c35515 | ||
|
|
005b26fa32 | ||
|
|
cb7484912c | ||
|
|
cc47007fa9 | ||
|
|
302fd159df | ||
|
|
cfdb2efc70 | ||
|
|
6da82f0ed8 | ||
|
|
789d26bb8d | ||
|
|
4605dd7c92 | ||
|
|
d8383a788e | ||
|
|
58ff7942bd | ||
|
|
39b27a229f | ||
|
|
3bfa9ac65e | ||
|
|
2a0487aa6e | ||
|
|
48200a11df | ||
|
|
d4d5620c18 | ||
|
|
d4745591a9 | ||
|
|
a17aa5c283 | ||
|
|
19f771ea71 |
@@ -0,0 +1,35 @@
|
|||||||
|
# Repo-wide ignore for the agents/runner Docker builds.
|
||||||
|
# Keeps images small and prevents secrets from entering image layers.
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/.flue-vite
|
||||||
|
|
||||||
|
# Secrets — never bake into an image.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Local Convex / runtime state.
|
||||||
|
.convex
|
||||||
|
**/.convex
|
||||||
|
data
|
||||||
|
**/data
|
||||||
|
|
||||||
|
# VCS, caches, and editor cruft.
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
**/.DS_Store
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
|
||||||
|
# macOS AppleDouble sidecars are metadata, never TypeScript source.
|
||||||
|
**/._*
|
||||||
|
|
||||||
|
# Workspaces and runner artifacts created at runtime on the host.
|
||||||
|
workspaces
|
||||||
|
**/workspaces
|
||||||
|
*.log
|
||||||
+13
-7
@@ -6,10 +6,8 @@ SITE_URL=http://localhost:5173
|
|||||||
NATIVE_APP_URL=code://
|
NATIVE_APP_URL=code://
|
||||||
|
|
||||||
# Browser and native public endpoints
|
# Browser and native public endpoints
|
||||||
|
VITE_AUTH_URL=http://localhost:5173
|
||||||
VITE_CONVEX_URL=https://example.convex.cloud
|
VITE_CONVEX_URL=https://example.convex.cloud
|
||||||
VITE_CONVEX_SITE_URL=https://example.convex.site
|
|
||||||
# For phone testing, replace localhost with this machine's Tailscale IPv4 address.
|
|
||||||
VITE_FLUE_URL=http://localhost:3583
|
|
||||||
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
|
EXPO_PUBLIC_CONVEX_URL=https://example.convex.cloud
|
||||||
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
|
EXPO_PUBLIC_CONVEX_SITE_URL=https://example.convex.site
|
||||||
|
|
||||||
@@ -19,20 +17,28 @@ DAEMON_NAME=Local MacBook
|
|||||||
DAEMON_VERSION=0.0.0
|
DAEMON_VERSION=0.0.0
|
||||||
DAEMON_HEARTBEAT_MS=15000
|
DAEMON_HEARTBEAT_MS=15000
|
||||||
DAEMON_COMMAND_LEASE_MS=60000
|
DAEMON_COMMAND_LEASE_MS=60000
|
||||||
# RIVET_ENDPOINT=http://localhost:6420
|
RIVET_ENDPOINT=http://localhost:6420
|
||||||
|
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
|
||||||
|
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
|
||||||
|
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
|
||||||
|
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
|
||||||
|
|
||||||
# Flue persistence adapter
|
# Flue persistence adapter
|
||||||
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
FLUE_DB_TOKEN=replace-with-a-long-random-token
|
||||||
|
FLUE_URL=http://127.0.0.1:3585
|
||||||
|
|
||||||
# Agent model provider
|
# Agent model provider
|
||||||
AGENT_MODEL_PROVIDER=cheaptricks
|
AGENT_MODEL_PROVIDER=xiaomi
|
||||||
AGENT_MODEL_NAME=glm-5.2
|
AGENT_MODEL_NAME=mimo-v2.5
|
||||||
AGENT_MODEL_API=openai-completions
|
AGENT_MODEL_API=openai-completions
|
||||||
AGENT_MODEL_BASE_URL=https://ai.example.com/v1
|
AGENT_MODEL_BASE_URL=https://ai.example.com/v1
|
||||||
AGENT_MODEL_API_KEY=replace-with-provider-api-key
|
AGENT_MODEL_API_KEY=replace-with-provider-api-key
|
||||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
AGENT_MODEL_CONTEXT_WINDOW=1048576
|
||||||
AGENT_MODEL_MAX_TOKENS=131072
|
AGENT_MODEL_MAX_TOKENS=131072
|
||||||
|
|
||||||
# Self-hosted git (Gitea) — canonical repository host and API token
|
# Self-hosted git (Gitea) — canonical repository host and API token
|
||||||
GITEA_URL=https://git.openputer.com
|
GITEA_URL=https://git.openputer.com
|
||||||
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
|
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
|
||||||
|
|
||||||
|
# Zopu lean server (standalone chat + work actor)
|
||||||
|
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
||||||
|
|||||||
+22
@@ -54,3 +54,25 @@ coverage
|
|||||||
.cache
|
.cache
|
||||||
tmp
|
tmp
|
||||||
temp
|
temp
|
||||||
|
.env.*
|
||||||
|
.env*
|
||||||
|
infra/ansible/.gitignore
|
||||||
|
infra/ansible/ansible.cfg
|
||||||
|
infra/ansible/README.md
|
||||||
|
infra/ansible/requirements.yml
|
||||||
|
infra/ansible/group_vars/staging_vds.example/main.yml
|
||||||
|
infra/ansible/group_vars/staging_vds.example/vault.yml
|
||||||
|
infra/ansible/inventory/hosts.example.yml
|
||||||
|
infra/ansible/playbooks/converge.yml
|
||||||
|
infra/ansible/roles/backup/handlers/main.yml
|
||||||
|
infra/ansible/roles/backup/tasks/main.yml
|
||||||
|
infra/ansible/roles/backup/templates/zopu-backup.service.j2
|
||||||
|
infra/ansible/roles/backup/templates/zopu-backup.sh.j2
|
||||||
|
infra/ansible/roles/backup/templates/zopu-backup.timer.j2
|
||||||
|
infra/ansible/roles/common/tasks/main.yml
|
||||||
|
infra/ansible/roles/directories/tasks/main.yml
|
||||||
|
infra/ansible/roles/docker/handlers/main.yml
|
||||||
|
infra/ansible/roles/docker/tasks/main.yml
|
||||||
|
infra/ansible/roles/docker/vars/main.yml
|
||||||
|
infra/ansible/roles/firewall/handlers/main.yml
|
||||||
|
infra/ansible/roles/firewall/tasks/main.yml
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Archived implementations and agent reference material are not part of the web build.
|
||||||
|
repos/
|
||||||
|
.agents/
|
||||||
|
.codex/
|
||||||
|
|
||||||
|
# Private deployment operations are deployed to the VDS, not Vercel.
|
||||||
|
deploy/
|
||||||
|
infra/
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Local development state and tooling.
|
||||||
|
.vscode/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
packages/backend/.convex/
|
||||||
|
.vercel/output/
|
||||||
Vendored
-3
@@ -1,9 +1,6 @@
|
|||||||
{
|
{
|
||||||
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||||
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||||
"files.exclude": {
|
|
||||||
"repos/**": true
|
|
||||||
},
|
|
||||||
"files.watcherExclude": {
|
"files.watcherExclude": {
|
||||||
"repos/**": true
|
"repos/**": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/`, and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 1–2 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.
|
This Bun/TypeScript monorepo uses Bun/Vite+, Convex, Effect v4, and Flue; use `@code/*` exports, validate env in `packages/env`, never edit generated files or `repos/` (archived reference code — dormant apps, superseded web components, and prior implementations kept for reference), and keep UI presentational by moving UI state to reusable hooks and business/services/pure logic to dedicated modules. Treat `docs/PRODUCT.md`, `docs/DESIGN.md`, and `docs/TECH.md` as the canonical product, experience, and architecture specifications; read the relevant files before decisions or changes in those domains. Before Flue work, MUST read `.agents/skills/flue/SKILL.md` and use installed-version `flue docs`; after every change, MUST run `bunx ultracite check` on every changed source, script, and agent file (apply `bunx ultracite fix` first when needed), run the affected package's `bun run check-types`, exercise the changed behavior with its targeted runtime or smoke test, then run root `bun run check` and report unrelated pre-existing failures separately. Keep each individual instruction in any `AGENTS.md` to 1–2 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
FROM oven/bun:1.3.14 AS bun
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS build
|
||||||
|
|
||||||
|
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_AUTH_URL
|
||||||
|
ARG VITE_CONVEX_URL
|
||||||
|
ENV VITE_AUTH_URL=$VITE_AUTH_URL
|
||||||
|
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN bun run --filter web build
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim
|
||||||
|
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
WORKDIR /app/apps/web
|
||||||
|
|
||||||
|
COPY --from=build /app /app
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "node_modules/.bin/react-router-serve", "build/server/index.js"]
|
||||||
@@ -15,8 +15,6 @@
|
|||||||
"@code/env": "workspace:*",
|
"@code/env": "workspace:*",
|
||||||
"@code/primitives": "workspace:*",
|
"@code/primitives": "workspace:*",
|
||||||
"@code/ui": "workspace:*",
|
"@code/ui": "workspace:*",
|
||||||
"@flue/react": "1.0.0-beta.9",
|
|
||||||
"@flue/sdk": "1.0.0-beta.9",
|
|
||||||
"@react-router/fs-routes": "^8.1.0",
|
"@react-router/fs-routes": "^8.1.0",
|
||||||
"@react-router/node": "^8.1.0",
|
"@react-router/node": "^8.1.0",
|
||||||
"@react-router/serve": "^8.1.0",
|
"@react-router/serve": "^8.1.0",
|
||||||
@@ -24,19 +22,20 @@
|
|||||||
"isbot": "^5.1.44",
|
"isbot": "^5.1.44",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "catalog:",
|
"next-themes": "catalog:",
|
||||||
"react": "^19.2.7",
|
"react": "catalog:",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "catalog:",
|
||||||
"react-router": "^8.1.0",
|
"react-router": "^8.1.0",
|
||||||
"sonner": "catalog:",
|
"sonner": "catalog:",
|
||||||
"streamdown": "2.5.0"
|
"streamdown": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@code/config": "workspace:*",
|
"@code/config": "workspace:*",
|
||||||
"@react-router/dev": "^8.1.0",
|
"@react-router/dev": "^8.1.0",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
|
"@vercel/react-router": "1.3.1",
|
||||||
"react-router-devtools": "^6.2.1",
|
"react-router-devtools": "^6.2.1",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from "@react-router/dev/config";
|
||||||
|
import { vercelPreset } from "@vercel/react-router/vite";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Desktop addons package static web assets; SSR output cannot be bundled
|
|
||||||
ssr: false,
|
|
||||||
appDirectory: "src",
|
appDirectory: "src",
|
||||||
|
presets: [vercelPreset()],
|
||||||
|
ssr: true,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useConvexAccessToken } from "@code/auth/web";
|
||||||
|
import {
|
||||||
|
Attachment,
|
||||||
|
AttachmentAction,
|
||||||
|
AttachmentActions,
|
||||||
|
AttachmentContent,
|
||||||
|
AttachmentDescription,
|
||||||
|
AttachmentGroup,
|
||||||
|
AttachmentMedia,
|
||||||
|
AttachmentTitle,
|
||||||
|
} from "@code/ui/components/attachment";
|
||||||
|
import { ImageIcon, X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||||
|
import type { ConversationPart } from "@/lib/chat/types";
|
||||||
|
|
||||||
|
type FilePart = Extract<ConversationPart, { type: "file" }>;
|
||||||
|
|
||||||
|
const isDirectlyRenderableUrl = (url: string): boolean =>
|
||||||
|
url.startsWith("blob:") || url.startsWith("data:");
|
||||||
|
|
||||||
|
const useAuthenticatedImageSource = (url?: string): string | undefined => {
|
||||||
|
const resolveAccessToken = useConvexAccessToken();
|
||||||
|
const [loaded, setLoaded] = useState<{
|
||||||
|
readonly source: string;
|
||||||
|
readonly url: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || isDirectlyRenderableUrl(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
let objectUrl: string | undefined;
|
||||||
|
const load = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const accessToken = await resolveAccessToken();
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: accessToken
|
||||||
|
? { authorization: `Bearer ${accessToken}` }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
objectUrl = URL.createObjectURL(await response.blob());
|
||||||
|
if (active) {
|
||||||
|
setLoaded({ source: objectUrl, url });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The attachment remains represented by its filename and media type.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [resolveAccessToken, url]);
|
||||||
|
|
||||||
|
if (url && isDirectlyRenderableUrl(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return loaded && loaded.url === url ? loaded.source : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImagePreview = ({
|
||||||
|
alt,
|
||||||
|
mediaType,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
readonly alt: string;
|
||||||
|
readonly mediaType: string;
|
||||||
|
readonly url?: string;
|
||||||
|
}) => {
|
||||||
|
const source = useAuthenticatedImageSource(url);
|
||||||
|
return (
|
||||||
|
<AttachmentMedia
|
||||||
|
variant={mediaType.startsWith("image/") ? "image" : "icon"}
|
||||||
|
>
|
||||||
|
{source && mediaType.startsWith("image/") ? (
|
||||||
|
<img alt={alt} className="size-full object-cover" src={source} />
|
||||||
|
) : (
|
||||||
|
<ImageIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</AttachmentMedia>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PendingChatAttachments = ({
|
||||||
|
images,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
readonly images: readonly PendingChatImage[];
|
||||||
|
readonly onRemove: (id: string) => void;
|
||||||
|
}) => (
|
||||||
|
<AttachmentGroup className="max-w-full gap-2 pb-2">
|
||||||
|
{images.map((image) => (
|
||||||
|
<Attachment
|
||||||
|
className="w-24 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
|
||||||
|
key={image.id}
|
||||||
|
orientation="vertical"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<ImagePreview
|
||||||
|
alt={image.file.name}
|
||||||
|
mediaType={image.file.type}
|
||||||
|
url={image.previewUrl}
|
||||||
|
/>
|
||||||
|
<AttachmentContent>
|
||||||
|
<AttachmentTitle>{image.file.name}</AttachmentTitle>
|
||||||
|
</AttachmentContent>
|
||||||
|
<AttachmentActions>
|
||||||
|
<AttachmentAction
|
||||||
|
aria-label={`Remove ${image.file.name}`}
|
||||||
|
className="bg-white/90 hover:bg-white"
|
||||||
|
onClick={() => onRemove(image.id)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</AttachmentAction>
|
||||||
|
</AttachmentActions>
|
||||||
|
</Attachment>
|
||||||
|
))}
|
||||||
|
</AttachmentGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MessageAttachments = ({
|
||||||
|
parts,
|
||||||
|
}: {
|
||||||
|
readonly parts: readonly FilePart[];
|
||||||
|
}) => {
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AttachmentGroup className="mb-2 max-w-full gap-2">
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const title = part.filename ?? `Image ${index + 1}`;
|
||||||
|
return (
|
||||||
|
<Attachment
|
||||||
|
className="min-w-0 max-w-48 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
|
||||||
|
key={part.id ?? `${part.mediaType}:${index}`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<ImagePreview
|
||||||
|
alt={title}
|
||||||
|
mediaType={part.mediaType}
|
||||||
|
url={part.url}
|
||||||
|
/>
|
||||||
|
<AttachmentContent>
|
||||||
|
<AttachmentTitle>{title}</AttachmentTitle>
|
||||||
|
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
|
||||||
|
</AttachmentContent>
|
||||||
|
</Attachment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AttachmentGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,26 +7,60 @@ import {
|
|||||||
MobileChatBubble,
|
MobileChatBubble,
|
||||||
MobileChatMessage,
|
MobileChatMessage,
|
||||||
} from "@code/ui/components/mobile-chat";
|
} from "@code/ui/components/mobile-chat";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
|
import {
|
||||||
import type { ChatMessageProps } from "@/lib/chat/types";
|
getMessageText,
|
||||||
|
getReasoningText,
|
||||||
|
isReasoningStreaming,
|
||||||
|
isMessageStreaming,
|
||||||
|
} from "@/lib/chat/transforms";
|
||||||
|
import type {
|
||||||
|
AssistantResponseState,
|
||||||
|
ChatMessageProps,
|
||||||
|
} from "@/lib/chat/types";
|
||||||
|
|
||||||
import { AssistantIdentity } from "./assistant-identity";
|
import { AssistantIdentity } from "./assistant-identity";
|
||||||
import { ChatToolCall } from "./chat-tool-call";
|
import { MessageAttachments } from "./chat-attachments";
|
||||||
|
|
||||||
export const ChatMessage = ({ message }: ChatMessageProps) => {
|
const ReasoningTrace = ({
|
||||||
const isUser = message.role === "user";
|
isStreaming,
|
||||||
const isStreaming = isMessageStreaming(message);
|
text,
|
||||||
const text = getMessageText(message);
|
}: {
|
||||||
|
readonly isStreaming: boolean;
|
||||||
if (message.parts.length === 0 && !isStreaming) {
|
readonly text: string;
|
||||||
|
}) => {
|
||||||
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
className="mb-3 border-l-2 border-[#b9b5aa] pl-3 text-[#5f5d55]"
|
||||||
|
open={isStreaming ? true : undefined}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer select-none text-xs font-medium text-[#69675e]">
|
||||||
|
{isStreaming ? "Thinking live" : "Thinking trace"}
|
||||||
|
</summary>
|
||||||
|
<MessageResponse
|
||||||
|
className="chat-reasoning mt-2 min-w-0 text-xs leading-5"
|
||||||
|
isAnimating={isStreaming}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</MessageResponse>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
let content: ReactNode = text;
|
const AssistantText = ({
|
||||||
if (!isUser) {
|
hasReasoning,
|
||||||
content = text ? (
|
isStreaming,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
readonly hasReasoning: boolean;
|
||||||
|
readonly isStreaming: boolean;
|
||||||
|
readonly text: string;
|
||||||
|
}) => {
|
||||||
|
if (text) {
|
||||||
|
return (
|
||||||
<MessageResponse
|
<MessageResponse
|
||||||
caret={isStreaming ? "block" : undefined}
|
caret={isStreaming ? "block" : undefined}
|
||||||
className="chat-markdown min-w-0"
|
className="chat-markdown min-w-0"
|
||||||
@@ -34,33 +68,71 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
|
|||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
if (hasReasoning) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assistantState = (
|
||||||
|
isStreaming: boolean,
|
||||||
|
reasoningStreaming: boolean,
|
||||||
|
text: string
|
||||||
|
): AssistantResponseState | undefined => {
|
||||||
|
if (reasoningStreaming && !text) {
|
||||||
|
return "thinking";
|
||||||
|
}
|
||||||
|
return isStreaming ? "writing" : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||||
|
const isUser = message.role === "user";
|
||||||
|
const isStreaming = isMessageStreaming(message);
|
||||||
|
const text = getMessageText(message);
|
||||||
|
const reasoning = getReasoningText(message);
|
||||||
|
const reasoningStreaming = isReasoningStreaming(message);
|
||||||
|
const fileParts = message.parts.filter((part) => part.type === "file");
|
||||||
|
|
||||||
|
if (message.parts.length === 0 && !isStreaming) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sender = isUser ? "user" : "assistant";
|
const sender = isUser ? "user" : "assistant";
|
||||||
|
const content = isUser ? (
|
||||||
|
text
|
||||||
|
) : (
|
||||||
|
<AssistantText
|
||||||
|
hasReasoning={Boolean(reasoning)}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
text={text}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const identityState = assistantState(isStreaming, reasoningStreaming, text);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Message className="max-w-full gap-0" from={message.role}>
|
<Message className="max-w-full gap-0" from={message.role}>
|
||||||
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
|
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
|
||||||
<MobileChatMessage className="chat-message" sender={sender}>
|
<MobileChatMessage className="chat-message" sender={sender}>
|
||||||
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
|
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
|
||||||
{!isUser && (
|
{isUser ? null : <AssistantIdentity state={identityState} />}
|
||||||
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
|
|
||||||
)}
|
|
||||||
<MobileChatBubble
|
<MobileChatBubble
|
||||||
className={isUser ? "whitespace-pre-wrap" : undefined}
|
className={isUser ? "whitespace-pre-wrap" : undefined}
|
||||||
sender={sender}
|
sender={sender}
|
||||||
>
|
>
|
||||||
{message.parts.map((part) =>
|
<MessageAttachments parts={fileParts} />
|
||||||
part.type === "dynamic-tool" ? (
|
{isUser ? null : (
|
||||||
<ChatToolCall key={part.toolCallId} part={part} />
|
<ReasoningTrace
|
||||||
) : null
|
isStreaming={reasoningStreaming}
|
||||||
|
text={reasoning}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{content}
|
{content}
|
||||||
</MobileChatBubble>
|
</MobileChatBubble>
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import {
|
|
||||||
Tool,
|
|
||||||
ToolContent,
|
|
||||||
ToolInput,
|
|
||||||
ToolOutput,
|
|
||||||
} from "@code/ui/components/ai-elements/tool";
|
|
||||||
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
|
|
||||||
import { Search, SquareTerminal } from "lucide-react";
|
|
||||||
|
|
||||||
import type { ChatToolCallProps } from "@/lib/chat/types";
|
|
||||||
|
|
||||||
export const ChatToolCall = ({ part }: ChatToolCallProps) => {
|
|
||||||
let detail =
|
|
||||||
typeof part.input === "string"
|
|
||||||
? part.input
|
|
||||||
: JSON.stringify(part.input, null, 2);
|
|
||||||
let status = "running";
|
|
||||||
let tone: "error" | "neutral" | "success" = "neutral";
|
|
||||||
|
|
||||||
if (part.state === "output-available") {
|
|
||||||
detail =
|
|
||||||
typeof part.output === "string"
|
|
||||||
? part.output
|
|
||||||
: JSON.stringify(part.output, null, 2);
|
|
||||||
status = "done";
|
|
||||||
tone = "success";
|
|
||||||
} else if (part.state === "output-error") {
|
|
||||||
detail = part.errorText;
|
|
||||||
status = "failed";
|
|
||||||
tone = "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSearch = part.toolName.toLowerCase().includes("search");
|
|
||||||
const output = part.state === "output-available" ? part.output : undefined;
|
|
||||||
const errorText = part.state === "output-error" ? part.errorText : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tool className="mb-0 w-full border-0" defaultOpen>
|
|
||||||
<ToolContent className="space-y-0 p-0">
|
|
||||||
<MobileChatToolCall
|
|
||||||
detail={detail}
|
|
||||||
icon={
|
|
||||||
isSearch ? (
|
|
||||||
<Search className="size-3" strokeWidth={2.2} />
|
|
||||||
) : (
|
|
||||||
<SquareTerminal className="size-3" strokeWidth={2.2} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
status={status}
|
|
||||||
tone={tone}
|
|
||||||
toolName={part.toolName}
|
|
||||||
/>
|
|
||||||
<div className="sr-only">
|
|
||||||
<ToolInput input={part.input} />
|
|
||||||
<ToolOutput errorText={errorText} output={output} />
|
|
||||||
</div>
|
|
||||||
</ToolContent>
|
|
||||||
</Tool>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import {
|
|
||||||
MobileAssistantChatScreen,
|
|
||||||
MobileExpandedWorkScreen,
|
|
||||||
MobileHomeScreen,
|
|
||||||
MobileWorkListScreen,
|
|
||||||
MobileWorkUnitDetailScreen,
|
|
||||||
} from "@code/ui/components/mobile-product";
|
|
||||||
import { useNavigate, useParams } from "react-router";
|
|
||||||
|
|
||||||
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
|
|
||||||
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
|
|
||||||
|
|
||||||
export type MobileFlowScreen =
|
|
||||||
| "assistant-chat"
|
|
||||||
| "home"
|
|
||||||
| "work-list"
|
|
||||||
| "work-unit-detail";
|
|
||||||
|
|
||||||
interface MobileFlowPageProps {
|
|
||||||
screen: MobileFlowScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { workUnitId } = useParams();
|
|
||||||
const projectWorkspace = useMobileProjectWorkspace({
|
|
||||||
selectedWorkUnitId: workUnitId,
|
|
||||||
});
|
|
||||||
const workspace = useMobileWorkspace({
|
|
||||||
onCreateIssue: (title, body) =>
|
|
||||||
projectWorkspace.raiseIssue({ body, title }),
|
|
||||||
onSend: projectWorkspace.sendMessage,
|
|
||||||
});
|
|
||||||
const workPath = "/work";
|
|
||||||
const chatPath = "/chat";
|
|
||||||
|
|
||||||
const handleOpenUnit = (selectedWorkUnitId?: string) => {
|
|
||||||
const targetId =
|
|
||||||
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
|
|
||||||
if (!targetId) {
|
|
||||||
navigate("/dashboard");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (screen === "work-list" && !workspace.expanded) {
|
|
||||||
workspace.setExpanded(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/work/${targetId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const screenProps = {
|
|
||||||
composerValue: workspace.composerValue,
|
|
||||||
createIssueBody: workspace.issueBody,
|
|
||||||
createIssueTitle: workspace.issueTitle,
|
|
||||||
data: projectWorkspace.data,
|
|
||||||
onBack: () => navigate(workPath),
|
|
||||||
onComposerChange: workspace.handleComposerChange,
|
|
||||||
onComposerSubmit: workspace.handleComposerSubmit,
|
|
||||||
onCreateBodyChange: workspace.setIssueBody,
|
|
||||||
onCreateIssue: workspace.handleCreateIssueSubmit,
|
|
||||||
onCreateIssueFromSignal: projectWorkspace.data.latestSignal?.projectId
|
|
||||||
? () =>
|
|
||||||
void projectWorkspace.raiseIssueFromSignal(
|
|
||||||
projectWorkspace.data.latestSignal?.id ?? ""
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
onCreateTitleChange: workspace.setIssueTitle,
|
|
||||||
onManageProjects: () => navigate("/dashboard"),
|
|
||||||
onOpenAssistant: () => navigate(chatPath),
|
|
||||||
onOpenUnit: handleOpenUnit,
|
|
||||||
onProjectSelect: (projectId: string) =>
|
|
||||||
projectWorkspace.projectWorkspace.setSelectedProjectId(
|
|
||||||
projectId as Id<"projects">
|
|
||||||
),
|
|
||||||
onReviewUnit: (reviewUrl: string) => {
|
|
||||||
window.open(reviewUrl, "_blank", "noopener,noreferrer");
|
|
||||||
},
|
|
||||||
onStartUnit: (selectedIssueId: string) => {
|
|
||||||
void projectWorkspace.startWorkUnit(
|
|
||||||
selectedIssueId as Id<"projectIssues">
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onViewWork: () => navigate(workPath),
|
|
||||||
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
|
|
||||||
statusMessage: workspace.statusMessage ?? projectWorkspace.error?.message,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (screen === "assistant-chat") {
|
|
||||||
return <MobileAssistantChatScreen {...screenProps} />;
|
|
||||||
}
|
|
||||||
if (screen === "home") {
|
|
||||||
return <MobileHomeScreen {...screenProps} />;
|
|
||||||
}
|
|
||||||
if (screen === "work-unit-detail") {
|
|
||||||
return <MobileWorkUnitDetailScreen {...screenProps} />;
|
|
||||||
}
|
|
||||||
if (workspace.expanded) {
|
|
||||||
return <MobileExpandedWorkScreen {...screenProps} />;
|
|
||||||
}
|
|
||||||
return <MobileWorkListScreen {...screenProps} />;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { ProviderChips } from "./provider-chips";
|
||||||
|
import type { GitProviderAccountOption } from "./provider-chips";
|
||||||
|
import { RepositorySelector } from "./repository-selector";
|
||||||
|
|
||||||
|
export const AddProjectPanel = ({
|
||||||
|
accounts,
|
||||||
|
existingSourceUrls,
|
||||||
|
onClose,
|
||||||
|
onProjectCreated,
|
||||||
|
}: {
|
||||||
|
readonly accounts: readonly GitProviderAccountOption[] | undefined;
|
||||||
|
readonly existingSourceUrls: readonly string[];
|
||||||
|
readonly onClose: () => void;
|
||||||
|
readonly onProjectCreated: (projectId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
const githubConnectionId = accounts?.find(
|
||||||
|
(account) => account.provider === "github" && account.status === "active"
|
||||||
|
)?.id as Id<"gitConnections"> | undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
dialog?.showModal();
|
||||||
|
|
||||||
|
return () => dialog?.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<dialog
|
||||||
|
aria-labelledby="add-project-heading"
|
||||||
|
className="fixed inset-0 z-50 m-0 flex h-full w-full max-w-none items-center justify-center bg-[#20201d]/45 p-2 backdrop-blur-[2px] sm:p-6"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
ref={dialogRef}
|
||||||
|
>
|
||||||
|
<section className="relative flex h-[calc(100svh-1rem)] w-full max-w-2xl flex-col overflow-hidden rounded bg-[#faf9f4] text-[#20201d] shadow-2xl sm:h-[min(46rem,calc(100svh-3rem))]">
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-5 border-b border-[#d7d3c7] px-5 py-4">
|
||||||
|
<h2
|
||||||
|
className="text-base font-semibold tracking-tight text-[#20201d]"
|
||||||
|
id="add-project-heading"
|
||||||
|
>
|
||||||
|
Add project
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
aria-label="Close add project"
|
||||||
|
className="grid size-8 shrink-0 place-items-center rounded text-[#747168] transition-colors hover:bg-[#f2f0e7] hover:text-[#20201d]"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden p-5">
|
||||||
|
<ProviderChips accounts={accounts} />
|
||||||
|
|
||||||
|
<RepositorySelector
|
||||||
|
existingSourceUrls={existingSourceUrls}
|
||||||
|
githubConnectionId={githubConnectionId}
|
||||||
|
onProjectCreated={onProjectCreated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</dialog>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const ContextEditor = ({
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
readonly projectId: Id<"projects">;
|
||||||
|
}) => {
|
||||||
|
const instructions = useQuery(api.projects.getInstructions, {
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
const updateInstructions = useMutation(api.projects.updateInstructions);
|
||||||
|
const [draft, setDraft] = useState<string>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const textareaId = `context-textarea-${projectId}`;
|
||||||
|
|
||||||
|
const currentDraft = draft ?? instructions ?? "";
|
||||||
|
const dirty = draft !== undefined && draft !== (instructions ?? "");
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!dirty || saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateInstructions({
|
||||||
|
instructions: draft ?? "",
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
setDraft(undefined);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label
|
||||||
|
className="text-xs font-medium text-[#20201d]"
|
||||||
|
htmlFor={textareaId}
|
||||||
|
>
|
||||||
|
Context
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
|
||||||
|
id={textareaId}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
placeholder="Project-specific instructions for agents..."
|
||||||
|
value={currentDraft}
|
||||||
|
/>
|
||||||
|
{dirty ? (
|
||||||
|
<Button
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={save}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
|
||||||
|
{saving ? "Saving" : "Save context"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { authClient } from "@code/auth/web";
|
||||||
|
import { LoaderCircle, GitBranch } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const GitHubConnectButton = () => {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const linkGithub = async () => {
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await authClient.linkSocial({
|
||||||
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
|
provider: "github",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
onClick={linkGithub}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<GitBranch className="size-5 text-[#20201d]" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
|
||||||
|
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
|
||||||
|
</div>
|
||||||
|
{pending ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { authClient } from "@code/auth/web";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { LoaderCircle, RefreshCw, ShieldCheck, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/** Expanded scope set for repository + organization visibility. */
|
||||||
|
const GITHUB_EXPANDED_SCOPES = [
|
||||||
|
"repo",
|
||||||
|
"read:org",
|
||||||
|
"read:user",
|
||||||
|
"user:email",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface GithubConnectionSettingsProps {
|
||||||
|
readonly connectionId: string;
|
||||||
|
readonly externalUsername: string | undefined;
|
||||||
|
readonly grantedScopesJson: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GithubConnectionSettings = ({
|
||||||
|
connectionId,
|
||||||
|
externalUsername,
|
||||||
|
grantedScopesJson,
|
||||||
|
}: GithubConnectionSettingsProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reauthorizing, setReauthorizing] = useState(false);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close on outside click.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
popoverRef.current &&
|
||||||
|
!popoverRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Parse granted scopes from stored JSON.
|
||||||
|
const grantedScopes: readonly string[] = (() => {
|
||||||
|
if (!grantedScopesJson) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(grantedScopesJson) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((s): s is string => typeof s === "string");
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const missingScopes = GITHUB_EXPANDED_SCOPES.filter(
|
||||||
|
(scope) => !grantedScopes.includes(scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReauthorize = async () => {
|
||||||
|
setReauthorizing(true);
|
||||||
|
try {
|
||||||
|
await authClient.linkSocial({
|
||||||
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
|
provider: "github",
|
||||||
|
// Pass the full desired scope set so GitHub grants everything.
|
||||||
|
scopes: [...GITHUB_EXPANDED_SCOPES],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setReauthorizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Silence unused-vars: connectionId identifies which row is being managed.
|
||||||
|
void connectionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={popoverRef}>
|
||||||
|
<Button
|
||||||
|
aria-label="GitHub connection settings"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-8 gap-1 rounded border-[#d7d3c7] bg-[#fffefa] px-0 text-[#747168] hover:bg-[#f5f3eb]"
|
||||||
|
disabled={reauthorizing}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{open ? (
|
||||||
|
<dialog
|
||||||
|
aria-modal="false"
|
||||||
|
className="absolute top-9 right-0 z-50 w-72 rounded-lg border border-[#d7d3c7] bg-[#fffefa] p-4 shadow-xl"
|
||||||
|
open
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ShieldCheck className="size-4 text-emerald-500" />
|
||||||
|
<h4 className="text-sm font-semibold text-[#20201d]">
|
||||||
|
GitHub Access
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Close settings"
|
||||||
|
className="text-[#858277] hover:text-[#20201d]"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{externalUsername ? (
|
||||||
|
<p className="mt-2 text-xs text-[#68665e]">
|
||||||
|
Connected as{" "}
|
||||||
|
<span className="font-medium text-[#20201d]">
|
||||||
|
{externalUsername}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{grantedScopes.length > 0 ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wide text-[#858277]">
|
||||||
|
Granted scopes
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
|
{grantedScopes.map((scope) => (
|
||||||
|
<span
|
||||||
|
className="rounded bg-[#f5f3eb] px-1.5 py-0.5 text-[10px] font-medium text-[#68665e]"
|
||||||
|
key={scope}
|
||||||
|
>
|
||||||
|
{scope}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-[#858277]">
|
||||||
|
Scope details will appear after reauthorization.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs leading-5 text-[#68665e]">
|
||||||
|
Reauthorization refreshes GitHub scopes and re-syncs your accessible
|
||||||
|
repositories, including organization repos GitHub returns for your
|
||||||
|
account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-2 text-[11px] leading-4 text-[#858277]">
|
||||||
|
Organization repository visibility depends on your GitHub org
|
||||||
|
membership and SSO authorization — not all org repos may be
|
||||||
|
accessible via OAuth alone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-3 w-full gap-1.5 rounded border-[#55564e] bg-[#20201d] text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||||
|
disabled={reauthorizing}
|
||||||
|
onClick={() => void handleReauthorize()}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{reauthorizing ? (
|
||||||
|
<LoaderCircle className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="size-3" />
|
||||||
|
)}
|
||||||
|
{reauthorizing ? "Redirecting…" : "Reauthorize access"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{missingScopes.length > 0 ? (
|
||||||
|
<p className="mt-2 text-[10px] leading-4 text-amber-600">
|
||||||
|
Not yet granted: {missingScopes.join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</dialog>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export const LoadingState = () => (
|
||||||
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||||
|
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import type { ProjectView } from "@code/primitives/project";
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
BookOpen,
|
||||||
|
ChevronDown,
|
||||||
|
FolderGit2,
|
||||||
|
GitBranch,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { ContextEditor } from "./context-editor";
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ProjectCard = ({ project }: { readonly project: ProjectView }) => {
|
||||||
|
const [source] = project.sources;
|
||||||
|
const contextDocuments = project.contextDocuments.filter(
|
||||||
|
(document) => document.content.trim().length > 0
|
||||||
|
);
|
||||||
|
const repositoryLabel =
|
||||||
|
source?.repositoryPath ?? source?.host ?? "Repository";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="group flex min-w-0 flex-col border border-[#d7d3c7] bg-[#fffefa] transition-colors hover:border-[#aaa69a]">
|
||||||
|
<Link
|
||||||
|
className="flex min-w-0 flex-1 flex-col p-5 outline-none focus-visible:ring-2 focus-visible:ring-[#55564e] focus-visible:ring-inset"
|
||||||
|
to={`/?project=${project.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<span className="grid size-10 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
|
||||||
|
<FolderGit2 className="size-4" />
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight className="mt-1 size-4 shrink-0 text-[#858277] transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-[#20201d]" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 min-w-0">
|
||||||
|
<h3 className="truncate text-base font-semibold tracking-tight text-[#20201d]">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 truncate text-sm text-[#68665e]">
|
||||||
|
{repositoryLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-x-4 gap-y-2 border-t border-[#e4e0d4] pt-4 text-xs text-[#747168]">
|
||||||
|
{source?.defaultBranch ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<GitBranch className="size-3.5" />
|
||||||
|
{source.defaultBranch}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<BookOpen className="size-3.5" />
|
||||||
|
{contextDocuments.length} context{" "}
|
||||||
|
{contextDocuments.length === 1 ? "source" : "sources"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-[#858277]">
|
||||||
|
Updated {dateFormatter.format(project.updatedAt)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<details className="border-t border-[#e4e0d4]">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between px-5 py-3 text-xs font-medium text-[#68665e] marker:content-none hover:text-[#20201d] [&::-webkit-details-marker]:hidden">
|
||||||
|
Project context
|
||||||
|
<ChevronDown className="size-3.5 transition-transform [[open]_&]:rotate-180" />
|
||||||
|
</summary>
|
||||||
|
<div className="border-t border-[#e4e0d4] px-5 pb-5">
|
||||||
|
<ContextEditor projectId={project.id as unknown as Id<"projects">} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { ProjectView } from "@code/primitives/project";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { ProjectCard } from "./project-card";
|
||||||
|
|
||||||
|
export const ProjectsGrid = ({
|
||||||
|
onSearchChange,
|
||||||
|
projects,
|
||||||
|
search,
|
||||||
|
totalProjects,
|
||||||
|
}: {
|
||||||
|
readonly onSearchChange: (value: string) => void;
|
||||||
|
readonly projects: readonly ProjectView[];
|
||||||
|
readonly search: string;
|
||||||
|
readonly totalProjects: number;
|
||||||
|
}) => (
|
||||||
|
<section aria-labelledby="projects-heading" className="mt-10">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-semibold tracking-tight text-[#20201d]"
|
||||||
|
id="projects-heading"
|
||||||
|
>
|
||||||
|
Your projects
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs tabular-nums text-[#858277]">
|
||||||
|
{totalProjects}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-[#68665e]">
|
||||||
|
Choose a repository to continue its project conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative block w-full sm:w-64">
|
||||||
|
<span className="sr-only">Search projects</span>
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
|
||||||
|
<input
|
||||||
|
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none placeholder:text-[#858277] focus:border-[#55564e]"
|
||||||
|
onChange={(event) => onSearchChange(event.target.value)}
|
||||||
|
placeholder="Search projects"
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 border border-dashed border-[#c9c5b9] bg-[#fffefa]/60 p-8 text-center">
|
||||||
|
<p className="text-sm font-medium text-[#20201d]">
|
||||||
|
No matching projects
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#747168]">
|
||||||
|
Try a different project name or repository.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { ArrowLeft, FolderGit2, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
export const ProjectsHeader = ({
|
||||||
|
hasProjects,
|
||||||
|
onGoHome,
|
||||||
|
onOpenAddProject,
|
||||||
|
}: {
|
||||||
|
readonly hasProjects: boolean;
|
||||||
|
readonly onGoHome: () => void;
|
||||||
|
readonly onOpenAddProject: () => void;
|
||||||
|
}) => (
|
||||||
|
<header className="flex flex-col gap-5 border-b border-[#d7d3c7] pb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="grid size-11 shrink-0 place-items-center bg-[#20201d] text-[#fffefa]">
|
||||||
|
<FolderGit2 className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||||
|
Zopu workspace
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-0.5 text-2xl font-semibold tracking-tight text-[#20201d]">
|
||||||
|
Projects
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasProjects ? (
|
||||||
|
<Button
|
||||||
|
className="border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
|
||||||
|
onClick={onGoHome}
|
||||||
|
size="lg"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-3.5" />
|
||||||
|
Workspace
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
className="bg-[#20201d] text-[#fffefa] hover:bg-[#3a3933]"
|
||||||
|
onClick={onOpenAddProject}
|
||||||
|
size="lg"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { authClient } from "@code/auth/web";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
GitBranch,
|
||||||
|
LoaderCircle,
|
||||||
|
Server,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { GithubConnectionSettings } from "./github-connection-settings";
|
||||||
|
import { PuterConnectForm } from "./puter-connect-form";
|
||||||
|
|
||||||
|
export interface GitProviderAccountOption {
|
||||||
|
readonly externalUsername: string;
|
||||||
|
readonly grantedScopesJson?: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly provider: "github" | "gitea";
|
||||||
|
readonly serverUrl: string;
|
||||||
|
readonly status:
|
||||||
|
| "pending-auth"
|
||||||
|
| "active"
|
||||||
|
| "reauth-required"
|
||||||
|
| "revoked"
|
||||||
|
| "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderChipsProps {
|
||||||
|
readonly accounts: readonly GitProviderAccountOption[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderChipProps {
|
||||||
|
readonly account: GitProviderAccountOption | undefined;
|
||||||
|
readonly icon: React.ElementType;
|
||||||
|
readonly label: string;
|
||||||
|
readonly loading: boolean;
|
||||||
|
readonly onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHealthy = (status: GitProviderAccountOption["status"]) =>
|
||||||
|
status === "active";
|
||||||
|
|
||||||
|
const ProviderStatusIcon = ({ connected }: { readonly connected: boolean }) =>
|
||||||
|
connected ? (
|
||||||
|
<CheckCircle2 className="size-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-3 text-amber-500" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const ProviderChip = ({
|
||||||
|
account,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
loading,
|
||||||
|
onClick,
|
||||||
|
}: ProviderChipProps) => {
|
||||||
|
const connected = account !== undefined && isHealthy(account.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-pressed={connected}
|
||||||
|
className={
|
||||||
|
connected
|
||||||
|
? "h-8 gap-1.5 rounded border-[#d7d3c7] bg-[#f5f3eb] px-3 text-[#20201d] hover:bg-[#f5f3eb]"
|
||||||
|
: "h-8 gap-1.5 rounded border-[#c9c5b9] bg-[#fffefa] px-3 text-[#68665e] hover:bg-[#f5f3eb]"
|
||||||
|
}
|
||||||
|
disabled={loading || connected}
|
||||||
|
onClick={onClick}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderCircle className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon className="size-3.5 text-[#747168]" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{connected ? account.externalUsername : `Connect ${label}`}
|
||||||
|
</span>
|
||||||
|
{account === undefined ? null : (
|
||||||
|
<ProviderStatusIcon connected={connected} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
|
||||||
|
const [pendingProvider, setPendingProvider] = useState<
|
||||||
|
"github" | "gitea" | undefined
|
||||||
|
>();
|
||||||
|
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||||
|
|
||||||
|
const githubAccount = accounts?.find(
|
||||||
|
(account) => account.provider === "github"
|
||||||
|
);
|
||||||
|
const puterAccount = accounts?.find(
|
||||||
|
(account) => account.provider === "gitea"
|
||||||
|
);
|
||||||
|
|
||||||
|
const linkGithub = async () => {
|
||||||
|
if (pendingProvider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingProvider("github");
|
||||||
|
try {
|
||||||
|
await authClient.linkSocial({
|
||||||
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
|
provider: "github",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPendingProvider(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePuterChipClick = () => {
|
||||||
|
if (puterAccount && isHealthy(puterAccount.status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowPuterForm((value) => !value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePuterConnected = () => {
|
||||||
|
setShowPuterForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const accountsLoading = accounts === undefined;
|
||||||
|
const githubConnected =
|
||||||
|
githubAccount !== undefined && isHealthy(githubAccount.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ProviderChip
|
||||||
|
account={githubAccount}
|
||||||
|
icon={GitBranch}
|
||||||
|
label="GitHub"
|
||||||
|
loading={accountsLoading || pendingProvider === "github"}
|
||||||
|
onClick={linkGithub}
|
||||||
|
/>
|
||||||
|
{githubConnected ? (
|
||||||
|
<GithubConnectionSettings
|
||||||
|
connectionId={githubAccount.id}
|
||||||
|
externalUsername={githubAccount.externalUsername}
|
||||||
|
grantedScopesJson={githubAccount.grantedScopesJson}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ProviderChip
|
||||||
|
account={puterAccount}
|
||||||
|
icon={Server}
|
||||||
|
label="Puter Git"
|
||||||
|
loading={accountsLoading || pendingProvider === "gitea"}
|
||||||
|
onClick={handlePuterChipClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showPuterForm ? (
|
||||||
|
<div className="rounded border border-[#d7d3c7] bg-[#fffefa] p-3 shadow-sm">
|
||||||
|
<PuterConnectForm onConnected={handlePuterConnected} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Server } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { GitHubConnectButton } from "./github-connect-button";
|
||||||
|
import { PuterConnectForm } from "./puter-connect-form";
|
||||||
|
|
||||||
|
export const ProviderSection = () => {
|
||||||
|
const [showPuterForm, setShowPuterForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="provider-connections-heading">
|
||||||
|
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||||
|
Need a repository?
|
||||||
|
</p>
|
||||||
|
<h3
|
||||||
|
className="mt-1 text-base font-semibold text-[#20201d]"
|
||||||
|
id="provider-connections-heading"
|
||||||
|
>
|
||||||
|
Connect a Git provider
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#68665e]">
|
||||||
|
Link GitHub for OAuth access or add a Puter Git personal access token.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<GitHubConnectButton />
|
||||||
|
<button
|
||||||
|
aria-expanded={showPuterForm}
|
||||||
|
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
onClick={() => setShowPuterForm((value) => !value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Server className="size-4 text-[#20201d]" />
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
|
||||||
|
<p className="text-xs text-[#747168]">
|
||||||
|
Connect with a personal access token
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showPuterForm ? (
|
||||||
|
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { useAction } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ConnectGiteaResult {
|
||||||
|
connectionId: Id<"gitConnections">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectGiteaRef = makeFunctionReference<
|
||||||
|
"action",
|
||||||
|
{ token: string; username?: string },
|
||||||
|
ConnectGiteaResult
|
||||||
|
>("gitConnections:connectGitea");
|
||||||
|
|
||||||
|
export const PuterConnectForm = ({
|
||||||
|
onConnected,
|
||||||
|
}: {
|
||||||
|
readonly onConnected: () => void;
|
||||||
|
}) => {
|
||||||
|
const connectGitea = useAction(connectGiteaRef);
|
||||||
|
const [token, setToken] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!token.trim() || pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await connectGitea({
|
||||||
|
token: token.trim(),
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
});
|
||||||
|
setToken("");
|
||||||
|
setUsername("");
|
||||||
|
onConnected();
|
||||||
|
} catch (caughtError) {
|
||||||
|
setError(
|
||||||
|
caughtError instanceof Error ? caughtError.message : String(caughtError)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="mt-4 space-y-3" onSubmit={submit}>
|
||||||
|
<p className="text-xs text-[#68665e]">
|
||||||
|
Connect your Puter Git personal access token. The Puter Git instance is
|
||||||
|
at <code className="text-[#20201d]">git.openputer.com</code>.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Puter username (optional)"
|
||||||
|
value={username}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||||
|
onChange={(event) => setToken(event.target.value)}
|
||||||
|
placeholder="Personal access token"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
/>
|
||||||
|
{error ? <p className="text-xs text-red-700">{error}</p> : null}
|
||||||
|
<Button className="h-10 w-full" disabled={pending} type="submit">
|
||||||
|
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
|
||||||
|
{pending ? "Connecting" : "Connect Puter Git"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
LoaderCircle,
|
||||||
|
FolderGit2,
|
||||||
|
GitBranch,
|
||||||
|
Globe2,
|
||||||
|
Lock,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { useGithubRepoSearch } from "@/hooks/use-github-repo-search";
|
||||||
|
import type { GithubRepositorySearchResult } from "@/hooks/use-github-repo-search";
|
||||||
|
import { useRepositoryPicker } from "@/hooks/use-repository-picker";
|
||||||
|
import type { GitRepositoryOption } from "@/hooks/use-repository-picker";
|
||||||
|
|
||||||
|
const listRepositoriesRef = makeFunctionReference<
|
||||||
|
"query",
|
||||||
|
Record<string, never>,
|
||||||
|
readonly GitRepositoryOption[]
|
||||||
|
>("gitProvisioning:listRepositories");
|
||||||
|
|
||||||
|
interface CreateProjectResult {
|
||||||
|
projectId: Id<"projects">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProjectFromRepositoryRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ gitRepositoryId: Id<"gitRepositories">; name: string },
|
||||||
|
CreateProjectResult
|
||||||
|
>("gitProvisioning:createProjectFromRepository");
|
||||||
|
|
||||||
|
const providerLabels = {
|
||||||
|
all: "All",
|
||||||
|
gitea: "Puter Git",
|
||||||
|
github: "GitHub",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const RepositorySelector = ({
|
||||||
|
existingSourceUrls,
|
||||||
|
githubConnectionId,
|
||||||
|
onProjectCreated,
|
||||||
|
}: {
|
||||||
|
readonly existingSourceUrls: readonly string[];
|
||||||
|
readonly githubConnectionId: Id<"gitConnections"> | undefined;
|
||||||
|
readonly onProjectCreated: (projectId: string) => void;
|
||||||
|
}) => {
|
||||||
|
const repositories = useQuery(listRepositoriesRef, {});
|
||||||
|
const createProject = useMutation(createProjectFromRepositoryRef);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [pending, setPending] = useState<string>();
|
||||||
|
const {
|
||||||
|
availableRepositories,
|
||||||
|
privacyFilter,
|
||||||
|
providerFilter,
|
||||||
|
search,
|
||||||
|
setPrivacyFilter,
|
||||||
|
setProviderFilter,
|
||||||
|
setSearch,
|
||||||
|
} = useRepositoryPicker({
|
||||||
|
existingSourceUrls,
|
||||||
|
repositories,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live GitHub search: active when GitHub provider is selectable (filter is
|
||||||
|
// "all" or "github") and a GitHub connection exists. The search action uses
|
||||||
|
// the server-side encrypted credential — the token never reaches the browser.
|
||||||
|
const githubSearchEnabled =
|
||||||
|
githubConnectionId !== undefined &&
|
||||||
|
(providerFilter === "all" || providerFilter === "github");
|
||||||
|
const githubSearch = useGithubRepoSearch({
|
||||||
|
connectionId: githubConnectionId,
|
||||||
|
enabled: githubSearchEnabled,
|
||||||
|
query: search,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge live search results with the synced snapshot. Live results take
|
||||||
|
// priority by webUrl to avoid duplicate rows.
|
||||||
|
const liveResults = useMemo<readonly GithubRepositorySearchResult[]>(() => {
|
||||||
|
if (githubSearch.kind !== "results") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return githubSearch.results;
|
||||||
|
}, [githubSearch]);
|
||||||
|
|
||||||
|
const displayRepositories = useMemo<readonly GitRepositoryOption[]>(() => {
|
||||||
|
const base = availableRepositories ?? [];
|
||||||
|
if (liveResults.length === 0) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const existingUrls = new Set(base.map((repo) => repo.webUrl));
|
||||||
|
const merged: GitRepositoryOption[] = [...base];
|
||||||
|
for (const result of liveResults) {
|
||||||
|
if (!result.gitRepositoryId || existingUrls.has(result.webUrl)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existingUrls.add(result.webUrl);
|
||||||
|
merged.push({
|
||||||
|
cloneUrl: result.cloneUrl,
|
||||||
|
defaultBranch: result.defaultBranch,
|
||||||
|
fullName: result.fullName,
|
||||||
|
id: result.gitRepositoryId,
|
||||||
|
name: result.name,
|
||||||
|
owner: result.owner,
|
||||||
|
privacy: result.privacy,
|
||||||
|
provider: result.provider,
|
||||||
|
webUrl: result.webUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged.toSorted((left, right) =>
|
||||||
|
left.fullName.localeCompare(right.fullName)
|
||||||
|
);
|
||||||
|
}, [availableRepositories, liveResults]);
|
||||||
|
|
||||||
|
const create = async (repository: GitRepositoryOption) => {
|
||||||
|
if (pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
setPending(repository.id);
|
||||||
|
try {
|
||||||
|
const result = await createProject({
|
||||||
|
gitRepositoryId: repository.id as Id<"gitRepositories">,
|
||||||
|
name: repository.name,
|
||||||
|
});
|
||||||
|
onProjectCreated(String(result.projectId));
|
||||||
|
} catch (caughtError) {
|
||||||
|
setError(
|
||||||
|
caughtError instanceof Error
|
||||||
|
? caughtError.message
|
||||||
|
: "Could not create this project"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (repositories === undefined || availableRepositories === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-48 flex-1 place-items-center border border-[#d7d3c7] bg-[#fffefa]">
|
||||||
|
<LoaderCircle className="size-5 animate-spin text-[#747168]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFiltered =
|
||||||
|
Boolean(search) || providerFilter !== "all" || privacyFilter !== "all";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="repository-picker-heading"
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
className="text-base font-semibold text-[#20201d]"
|
||||||
|
id="repository-picker-heading"
|
||||||
|
>
|
||||||
|
Imports
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-[#747168]">
|
||||||
|
Repositories already added as projects are hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||||
|
<Link
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-[#20201d] underline decoration-[#858277] underline-offset-4 hover:decoration-[#20201d]"
|
||||||
|
to="/?import=public"
|
||||||
|
>
|
||||||
|
Import public Git URL
|
||||||
|
<ArrowUpRight className="size-3" />
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs tabular-nums text-[#858277]">
|
||||||
|
{availableRepositories.length} available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid shrink-0 gap-3 sm:flex sm:flex-wrap sm:items-center sm:justify-between">
|
||||||
|
<label className="relative min-w-0 sm:max-w-[16rem]">
|
||||||
|
<span className="sr-only">Search repositories</span>
|
||||||
|
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[#858277]" />
|
||||||
|
<input
|
||||||
|
className="h-9 w-full rounded border border-[#d7d3c7] bg-[#fffefa] pr-3 pl-9 text-sm text-[#20201d] outline-none ring-[#d7d3c7] placeholder:text-[#858277] focus:border-[#858277] focus:ring-1"
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Search repositories"
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<fieldset className="flex flex-wrap gap-1">
|
||||||
|
<legend className="sr-only">Repository provider</legend>
|
||||||
|
{(
|
||||||
|
Object.keys(providerLabels) as (keyof typeof providerLabels)[]
|
||||||
|
).map((provider) => (
|
||||||
|
<Button
|
||||||
|
aria-pressed={providerFilter === provider}
|
||||||
|
className={
|
||||||
|
providerFilter === provider
|
||||||
|
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||||
|
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
|
||||||
|
}
|
||||||
|
key={provider}
|
||||||
|
onClick={() => setProviderFilter(provider)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{providerLabels[provider]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="flex flex-wrap gap-1">
|
||||||
|
<legend className="sr-only">Repository visibility</legend>
|
||||||
|
{(["all", "private", "public"] as const).map((privacy) => (
|
||||||
|
<Button
|
||||||
|
aria-pressed={privacyFilter === privacy}
|
||||||
|
className={
|
||||||
|
privacyFilter === privacy
|
||||||
|
? "h-7 rounded border-transparent bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||||
|
: "h-7 rounded border-transparent bg-[#e8e6dc] px-2.5 text-[11px] text-[#68665e] hover:bg-[#dedcd2]"
|
||||||
|
}
|
||||||
|
key={privacy}
|
||||||
|
onClick={() => setPrivacyFilter(privacy)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{privacy === "all" ? "All" : privacy}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="mt-3 border border-red-300 bg-red-50 p-2.5 text-xs leading-5 text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{githubSearch.kind === "loading" ? (
|
||||||
|
<p className="mt-3 flex items-center gap-1.5 text-xs text-[#747168]">
|
||||||
|
<LoaderCircle className="size-3 animate-spin" />
|
||||||
|
Searching GitHub…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{githubSearch.kind === "error" ? (
|
||||||
|
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
|
||||||
|
{githubSearch.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{githubSearch.kind === "reauth" ? (
|
||||||
|
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
|
||||||
|
GitHub access needs reauthorization. Use the settings control on the
|
||||||
|
GitHub chip to reconnect.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{displayRepositories.length > 0 ? (
|
||||||
|
<div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]">
|
||||||
|
{displayRepositories.map((repository) => {
|
||||||
|
const ProviderIcon =
|
||||||
|
repository.provider === "github" ? GitBranch : Server;
|
||||||
|
const VisibilityIcon =
|
||||||
|
repository.privacy === "private" ? Lock : Globe2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between gap-3 p-3"
|
||||||
|
key={repository.id}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-start gap-2.5">
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center rounded border border-[#d7d3c7] bg-[#f5f3eb] text-[#68665e]">
|
||||||
|
<ProviderIcon className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-[#20201d]">
|
||||||
|
{repository.fullName}
|
||||||
|
</p>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] text-[#747168]">
|
||||||
|
<span>{repository.defaultBranch}</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<VisibilityIcon className="size-3" />
|
||||||
|
{repository.privacy}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="h-7 shrink-0 rounded border-[#55564e] bg-[#20201d] px-2.5 text-[11px] text-[#fffefa] hover:bg-[#3a3933]"
|
||||||
|
disabled={pending !== undefined}
|
||||||
|
onClick={() => void create(repository)}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{pending === repository.id ? (
|
||||||
|
<LoaderCircle className="size-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FolderGit2 className="size-3" />
|
||||||
|
)}
|
||||||
|
{pending === repository.id ? "Creating" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded border border-dashed border-[#c9c5b9] bg-[#fffefa] p-6 text-center">
|
||||||
|
<FolderGit2 className="mx-auto size-5 text-[#858277]" />
|
||||||
|
<p className="mt-3 text-sm font-medium text-[#20201d]">
|
||||||
|
{isFiltered
|
||||||
|
? "No repositories match these filters"
|
||||||
|
: "No repositories available"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-[#68665e]">
|
||||||
|
{isFiltered
|
||||||
|
? "Change your search or filters to see other repositories."
|
||||||
|
: "Connect a Git provider or import a public repository to continue."}
|
||||||
|
</p>
|
||||||
|
{isFiltered ? (
|
||||||
|
<Button
|
||||||
|
className="mt-4 border-[#c9c5b9] bg-[#fffefa] text-[#20201d] hover:bg-[#f5f3eb]"
|
||||||
|
onClick={() => {
|
||||||
|
setPrivacyFilter("all");
|
||||||
|
setProviderFilter("all");
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export function ThemeProvider({
|
export const ThemeProvider = ({
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
}: React.ComponentProps<typeof NextThemesProvider>) => (
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
<NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
}
|
);
|
||||||
|
|
||||||
export { useTheme } from "next-themes";
|
export { useTheme } from "next-themes";
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { ImagePlus, LoaderCircle, Send } from "lucide-react";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
|
||||||
|
import { useChatImages } from "@/hooks/chat/use-chat-images";
|
||||||
|
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
export const ConversationComposer = ({
|
||||||
|
draft,
|
||||||
|
onDraftChange,
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
readonly draft: string;
|
||||||
|
readonly onDraftChange: (draft: string) => void;
|
||||||
|
readonly workspace: WorkspaceState;
|
||||||
|
}) => {
|
||||||
|
const imageInput = useRef<HTMLInputElement>(null);
|
||||||
|
const attachments = useChatImages();
|
||||||
|
const busy =
|
||||||
|
workspace.agent.status === "submitted" ||
|
||||||
|
workspace.agent.status === "streaming";
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const message = draft.trim();
|
||||||
|
if (!message || busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await workspace.agent.sendMessage(message, {
|
||||||
|
images: attachments.images.map((image) => image.file),
|
||||||
|
});
|
||||||
|
onDraftChange("");
|
||||||
|
attachments.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||||
|
{attachments.images.length > 0 ? (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<PendingChatAttachments
|
||||||
|
images={attachments.images}
|
||||||
|
onRemove={attachments.handleRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{workspace.agent.error ? (
|
||||||
|
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||||
|
{workspace.agent.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{attachments.error ? (
|
||||||
|
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
|
||||||
|
{attachments.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mx-auto flex max-w-2xl items-end gap-2">
|
||||||
|
<input
|
||||||
|
accept="image/*"
|
||||||
|
aria-label="Attach images"
|
||||||
|
className="sr-only"
|
||||||
|
multiple
|
||||||
|
onChange={(event) => {
|
||||||
|
attachments.addFiles(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
ref={imageInput}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
aria-label="Attach images"
|
||||||
|
className="size-11 shrink-0"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => imageInput.current?.click()}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<ImagePlus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<textarea
|
||||||
|
aria-label="Message Zopu"
|
||||||
|
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base outline-none focus:border-[#55564e]"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => onDraftChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Describe an outcome or problem…"
|
||||||
|
rows={1}
|
||||||
|
value={draft}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
aria-label="Send message"
|
||||||
|
className="size-11 shrink-0"
|
||||||
|
disabled={!draft.trim() || busy}
|
||||||
|
onClick={() => void send()}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { projectWorkNotices } from "@code/primitives/work";
|
||||||
|
import {
|
||||||
|
Conversation,
|
||||||
|
ConversationContent,
|
||||||
|
} from "@code/ui/components/ai-elements/conversation";
|
||||||
|
import { MessageSquareText } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { ChatMessage } from "@/components/chat/chat-message";
|
||||||
|
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
|
||||||
|
import { buildWorkspaceTimeline } from "@/lib/workspace/presentation";
|
||||||
|
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
import { WorkCard } from "./work-card";
|
||||||
|
|
||||||
|
const ConversationLoading = () => (
|
||||||
|
<output
|
||||||
|
aria-label="Loading conversation"
|
||||||
|
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
|
||||||
|
>
|
||||||
|
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||||
|
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
|
||||||
|
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
|
||||||
|
<span className="sr-only">Loading conversation…</span>
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ConversationEmptyState = () => (
|
||||||
|
<div className="grid min-h-[55vh] place-items-center text-center">
|
||||||
|
<div>
|
||||||
|
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
|
||||||
|
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
|
||||||
|
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
|
||||||
|
Describe an outcome or problem. Casual conversation stays conversation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ConversationFeed = ({
|
||||||
|
highlightedMessageId,
|
||||||
|
onSourceSelect,
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
readonly highlightedMessageId?: string;
|
||||||
|
readonly onSourceSelect: (rawText: string) => void;
|
||||||
|
readonly workspace: WorkspaceState;
|
||||||
|
}) => {
|
||||||
|
const works = workspace.works ?? [];
|
||||||
|
const workById = new Map(
|
||||||
|
works.map((work) => [String(work._id), work] as const)
|
||||||
|
);
|
||||||
|
const notices = projectWorkNotices(works);
|
||||||
|
const timeline = useMemo(
|
||||||
|
() => buildWorkspaceTimeline(workspace.agent.messages, notices),
|
||||||
|
[notices, workspace.agent.messages]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Conversation className="min-h-0 flex-1">
|
||||||
|
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
|
||||||
|
{!workspace.agent.historyReady && timeline.length === 0 ? (
|
||||||
|
<ConversationLoading />
|
||||||
|
) : null}
|
||||||
|
{workspace.agent.historyReady && timeline.length === 0 ? (
|
||||||
|
<ConversationEmptyState />
|
||||||
|
) : null}
|
||||||
|
{timeline.map((item) => {
|
||||||
|
if (item.kind === "work") {
|
||||||
|
const work = workById.get(item.notice.workId) as
|
||||||
|
| WorkRecord
|
||||||
|
| undefined;
|
||||||
|
return work ? (
|
||||||
|
<div
|
||||||
|
className="chat-message ml-7"
|
||||||
|
key={`notice-${item.notice.eventId}`}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
|
||||||
|
Work proposed from this conversation
|
||||||
|
</p>
|
||||||
|
<div className="rounded-sm">
|
||||||
|
<span className="sr-only">Proposed Work</span>
|
||||||
|
<WorkCard
|
||||||
|
onSourceSelect={onSourceSelect}
|
||||||
|
work={work}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-sm transition-colors duration-300 ${highlightedMessageId === item.message.id ? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]" : ""}`}
|
||||||
|
id={`workspace-message-${item.message.id}`}
|
||||||
|
key={item.message.id}
|
||||||
|
>
|
||||||
|
<ChatMessage message={item.message} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{workspace.agent.status === "submitted" ? (
|
||||||
|
<ChatThinkingResponse />
|
||||||
|
) : null}
|
||||||
|
</ConversationContent>
|
||||||
|
</Conversation>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import { FolderGit2, LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
export const ProjectConnectForm = ({
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
readonly workspace: WorkspaceState;
|
||||||
|
}) => (
|
||||||
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void workspace.connectRepository();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
|
||||||
|
<FolderGit2 className="size-5" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-6 text-2xl font-semibold">Connect a project</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#68665e]">
|
||||||
|
Turn actionable project conversation into proposed Work with exact
|
||||||
|
provenance.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
aria-label="Public Git repository URL"
|
||||||
|
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
|
||||||
|
onChange={(event) => workspace.setRepository(event.target.value)}
|
||||||
|
placeholder="https://github.com/owner/repository"
|
||||||
|
required
|
||||||
|
value={workspace.repository}
|
||||||
|
/>
|
||||||
|
{workspace.error ? (
|
||||||
|
<p className="mt-2 text-xs text-red-700">{workspace.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
className="mt-3 h-12 w-full"
|
||||||
|
disabled={workspace.pending}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{workspace.pending ? (
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{workspace.pending ? "Connecting" : "Connect project"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { FolderGit2, Menu } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import type { WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
export const ProjectHeader = ({
|
||||||
|
onOpenDrawer,
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
readonly onOpenDrawer: () => void;
|
||||||
|
readonly workspace: WorkspaceState;
|
||||||
|
}) => {
|
||||||
|
const works = workspace.works ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
|
||||||
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
|
<select
|
||||||
|
aria-label="Current project"
|
||||||
|
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
|
||||||
|
onChange={(event) => workspace.selectProject(event.target.value)}
|
||||||
|
value={workspace.selectedProject?.id ?? ""}
|
||||||
|
>
|
||||||
|
{(workspace.projects ?? []).map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-[10px] uppercase text-[#858277]">
|
||||||
|
Conversation to proposed Work
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
aria-label="Projects"
|
||||||
|
className="mr-2 grid size-9 place-items-center border border-[#c9c5b9] bg-[#fffefa] text-[#20201d] transition-colors hover:bg-[#f5f3eb]"
|
||||||
|
to="/projects"
|
||||||
|
>
|
||||||
|
<FolderGit2 className="size-4" />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
|
||||||
|
onClick={onOpenDrawer}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Menu className="size-4" /> Work{" "}
|
||||||
|
{workspace.works === undefined ? "…" : works.length}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useProjectWorkspace } from "@/hooks/workspace/use-project-workspace";
|
||||||
|
import { useVisualViewportStyle } from "@/hooks/workspace/use-visual-viewport";
|
||||||
|
import { findSourceMessageTarget } from "@/lib/workspace/presentation";
|
||||||
|
|
||||||
|
import { ConversationComposer } from "./conversation-composer";
|
||||||
|
import { ConversationFeed } from "./conversation-feed";
|
||||||
|
import { ProjectConnectForm } from "./project-connect-form";
|
||||||
|
import { ProjectHeader } from "./project-header";
|
||||||
|
import { WorkFeed } from "./work-feed";
|
||||||
|
|
||||||
|
const ProjectLoading = () => (
|
||||||
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
|
||||||
|
<span className="size-5 animate-spin rounded-full border-2 border-[#20201d] border-t-transparent" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ProjectWorkspacePage = () => {
|
||||||
|
const workspace = useProjectWorkspace();
|
||||||
|
const viewportStyle = useVisualViewportStyle();
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
|
||||||
|
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
if (workspace.projects === undefined) {
|
||||||
|
return <ProjectLoading />;
|
||||||
|
}
|
||||||
|
if (!workspace.selectedProject) {
|
||||||
|
return <ProjectConnectForm workspace={workspace} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const works = workspace.works ?? [];
|
||||||
|
const revealSourceMessage = (rawText: string) => {
|
||||||
|
const messageId = findSourceMessageTarget(
|
||||||
|
workspace.agent.messages,
|
||||||
|
rawText
|
||||||
|
);
|
||||||
|
if (!messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setHighlightedMessageId(messageId);
|
||||||
|
if (highlightTimer.current) {
|
||||||
|
clearTimeout(highlightTimer.current);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
document
|
||||||
|
.querySelector(`#workspace-message-${CSS.escape(messageId)}`)
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||||
|
);
|
||||||
|
highlightTimer.current = setTimeout(
|
||||||
|
() => setHighlightedMessageId(undefined),
|
||||||
|
1800
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="workspace-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
|
||||||
|
style={viewportStyle}
|
||||||
|
>
|
||||||
|
<section className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<ProjectHeader
|
||||||
|
onOpenDrawer={() => setDrawerOpen(true)}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
<ConversationFeed
|
||||||
|
highlightedMessageId={highlightedMessageId}
|
||||||
|
onSourceSelect={revealSourceMessage}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
<ConversationComposer
|
||||||
|
draft={draft}
|
||||||
|
onDraftChange={setDraft}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
|
||||||
|
<h2 className="text-sm font-semibold">Proposed Work</h2>
|
||||||
|
<p className="mb-4 text-xs text-[#747168]">
|
||||||
|
{works.length} durable outcomes
|
||||||
|
</p>
|
||||||
|
<WorkFeed
|
||||||
|
onSourceSelect={revealSourceMessage}
|
||||||
|
works={works}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
{drawerOpen ? (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
|
||||||
|
<button
|
||||||
|
aria-label="Close Work drawer"
|
||||||
|
className="absolute inset-0"
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
|
||||||
|
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
|
||||||
|
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
|
||||||
|
<button
|
||||||
|
aria-label="Close Work drawer"
|
||||||
|
className="grid size-9 place-items-center"
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<WorkFeed
|
||||||
|
onSourceSelect={revealSourceMessage}
|
||||||
|
works={works}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { Button } from "@code/ui/components/button";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronRight,
|
||||||
|
FileCode2,
|
||||||
|
Hammer,
|
||||||
|
Play,
|
||||||
|
RotateCcw,
|
||||||
|
ScrollText,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { changedFilesFor } from "@/lib/workspace/types";
|
||||||
|
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
interface WorkCardProps {
|
||||||
|
readonly onSourceSelect: (rawText: string) => void;
|
||||||
|
readonly work: WorkRecord;
|
||||||
|
readonly workspace: WorkspaceState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const starterDefinition = (work: WorkRecord) => ({
|
||||||
|
acceptanceCriteria: ["The requested outcome is observable and documented"],
|
||||||
|
affectedUsers: ["Project users"],
|
||||||
|
assumptions: [],
|
||||||
|
constraints: [],
|
||||||
|
desiredOutcome: work.objective,
|
||||||
|
inScope: [work.objective],
|
||||||
|
outOfScope: ["Unrelated product changes"],
|
||||||
|
problem: work.objective,
|
||||||
|
questions: [],
|
||||||
|
requiredArtifacts: ["Implementation diff and terminal outcome"],
|
||||||
|
risk: "medium",
|
||||||
|
});
|
||||||
|
|
||||||
|
const starterDesign = (work: WorkRecord) => ({
|
||||||
|
architectureSummary: "Implement the requested outcome in focused changes.",
|
||||||
|
callFlowDelta: ["Work -> Run -> Attempt -> AgentOS -> candidate revision"],
|
||||||
|
concerns: [],
|
||||||
|
evidenceRequirements: ["Terminal Run classification"],
|
||||||
|
fileTreeDelta: [],
|
||||||
|
impactMap: {
|
||||||
|
files: [],
|
||||||
|
modules: [],
|
||||||
|
risks: [],
|
||||||
|
summary: "Focused implementation",
|
||||||
|
},
|
||||||
|
invariants: [
|
||||||
|
"A successful AgentOS response alone never marks Work complete",
|
||||||
|
"Every Attempt reaches a terminal classification",
|
||||||
|
],
|
||||||
|
keyInterfaces: ["WorkExecution", "AttemptResult"],
|
||||||
|
slices: [
|
||||||
|
{
|
||||||
|
codeBoundaries: ["workExecution"],
|
||||||
|
dependsOn: [],
|
||||||
|
evidenceRequirements: ["Normalized activity events"],
|
||||||
|
id: "implementation",
|
||||||
|
objective: work.objective,
|
||||||
|
observableBehavior: "Implementation changes are visible in the diff",
|
||||||
|
reviewRequired: false,
|
||||||
|
title: "Implementation",
|
||||||
|
verification: ["Run completes with a terminal classification"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tradeoffs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// oxlint-disable-next-line complexity -- this card coordinates planning and run evidence.
|
||||||
|
export const WorkCard = ({
|
||||||
|
onSourceSelect,
|
||||||
|
work,
|
||||||
|
workspace,
|
||||||
|
}: WorkCardProps) => {
|
||||||
|
const [sourcesOpen, setSourcesOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
const sources = work.signals.flatMap((signal) => signal.sources);
|
||||||
|
const [latestRun] = work.runs;
|
||||||
|
const openQuestions =
|
||||||
|
work.definition?.questions?.filter((question) => question.status === "open")
|
||||||
|
.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
|
||||||
|
<Sparkles className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
|
||||||
|
{work.status === "proposed" ? "Proposed Work" : "Work"}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-[15px] font-semibold leading-5">
|
||||||
|
{work.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
|
||||||
|
{work.objective}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
|
||||||
|
onClick={() => setSourcesOpen((open) => !open)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{sources.length} exact source{" "}
|
||||||
|
{sources.length === 1 ? "message" : "messages"}
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
|
||||||
|
onClick={() => setExpanded((open) => !open)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{sourcesOpen ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
|
||||||
|
key={source.messageId}
|
||||||
|
onClick={() => onSourceSelect(source.rawText)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">{source.rawText}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
|
||||||
|
<section>
|
||||||
|
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||||
|
Outcome
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
|
||||||
|
<p className="mt-2 text-[#747168]">Status: {work.status}</p>
|
||||||
|
<p className="mt-1 text-[#747168]">
|
||||||
|
Risk: {work.definition?.risk ?? "not defined"}
|
||||||
|
</p>
|
||||||
|
{openQuestions > 0 ? (
|
||||||
|
<p className="mt-1 text-amber-800">
|
||||||
|
{openQuestions} open question(s)
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{work.status === "proposed" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void workspace.requestDefinition(work._id)}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3.5" /> Define
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{work.status === "defining" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
void workspace.saveDefinition(
|
||||||
|
work._id,
|
||||||
|
starterDefinition(work)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Hammer className="size-3.5" /> Save definition
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||||
|
Design
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 leading-5 text-[#626057]">
|
||||||
|
{work.design?.architectureSummary ?? "No Design Packet yet."}
|
||||||
|
</p>
|
||||||
|
{work.design?.slices?.map((item) => (
|
||||||
|
<p className="mt-1 text-[#747168]" key={item.id}>
|
||||||
|
{item.title}: {item.observableBehavior}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{work.status === "designing" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
void workspace.saveDesign(work._id, starterDesign(work))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Hammer className="size-3.5" /> Save design
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
|
||||||
|
Build
|
||||||
|
</p>
|
||||||
|
{workspace.operationError ? (
|
||||||
|
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
|
||||||
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 leading-5">
|
||||||
|
{workspace.operationError.message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => workspace.clearOperationError()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{latestRun ? (
|
||||||
|
<div className="mt-1 space-y-2 text-[#626057]">
|
||||||
|
<p className="leading-5">
|
||||||
|
Run {latestRun.status}:{" "}
|
||||||
|
{latestRun.terminalSummary ??
|
||||||
|
latestRun.terminalClassification ??
|
||||||
|
"activity is still arriving"}
|
||||||
|
</p>
|
||||||
|
{latestRun.baseRevision ? (
|
||||||
|
<p className="font-mono text-[10px] text-[#747168]">
|
||||||
|
{latestRun.baseRevision.slice(0, 8)} →{" "}
|
||||||
|
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{latestRun.artifacts?.map((artifact) => (
|
||||||
|
<div key={artifact._id} className="space-y-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{artifact.uri ? (
|
||||||
|
<a
|
||||||
|
className="underline"
|
||||||
|
href={artifact.uri}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{artifact.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
artifact.title
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{changedFilesFor(artifact).length > 0 ? (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{changedFilesFor(artifact).map((file) => (
|
||||||
|
<li
|
||||||
|
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
|
||||||
|
key={file}
|
||||||
|
>
|
||||||
|
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
|
||||||
|
<span className="min-w-0 break-all">{file}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{latestRun.attemptEvents &&
|
||||||
|
latestRun.attemptEvents.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(logsOpen
|
||||||
|
? latestRun.attemptEvents
|
||||||
|
: latestRun.attemptEvents.slice(-3)
|
||||||
|
).map((item) => (
|
||||||
|
<p
|
||||||
|
className="border-l-2 border-[#b8c760] pl-2 leading-5"
|
||||||
|
key={item._id}
|
||||||
|
>
|
||||||
|
{item.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{latestRun.attemptEvents.length > 3 ? (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
|
||||||
|
onClick={() => setLogsOpen((open) => !open)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ScrollText className="size-3.5" />
|
||||||
|
{logsOpen
|
||||||
|
? "Show recent activity"
|
||||||
|
: `Show full activity log (${latestRun.attemptEvents.length})`}
|
||||||
|
<ChevronRight
|
||||||
|
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{work.status === "ready" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!workspace.projectGitConnection}
|
||||||
|
onClick={() => void workspace.startExecution(work._id)}
|
||||||
|
>
|
||||||
|
<Play className="size-3.5" /> Run
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{latestRun?.status === "running" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void workspace.cancelExecution(latestRun._id)}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" /> Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{latestRun?.status === "terminal" &&
|
||||||
|
latestRun.terminalClassification === "RetryableFailure" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void workspace.retryExecution(latestRun._id)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" /> Retry
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
import { WorkCard } from "./work-card";
|
||||||
|
|
||||||
|
export const WorkFeed = ({
|
||||||
|
onSourceSelect,
|
||||||
|
works,
|
||||||
|
workspace,
|
||||||
|
}: {
|
||||||
|
readonly onSourceSelect: (rawText: string) => void;
|
||||||
|
readonly works: readonly WorkRecord[];
|
||||||
|
readonly workspace: WorkspaceState;
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{works.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm text-[#747168]">
|
||||||
|
Actionable messages will appear here.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{works.map((work) => (
|
||||||
|
<WorkCard
|
||||||
|
key={work._id}
|
||||||
|
onSourceSelect={onSourceSelect}
|
||||||
|
work={work}
|
||||||
|
workspace={workspace}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import { useChatAgent } from "./use-chat-agent";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
agent: {
|
|
||||||
error: undefined,
|
|
||||||
historyReady: true,
|
|
||||||
messages: [],
|
|
||||||
sendMessage: vi.fn(() => Promise.resolve()),
|
|
||||||
status: "idle" as const,
|
|
||||||
},
|
|
||||||
organization: {} as { error?: Error; organizationId?: string },
|
|
||||||
useFlueAgent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@flue/react", () => ({
|
|
||||||
useFlueAgent: mocks.useFlueAgent,
|
|
||||||
}));
|
|
||||||
vi.mock("@/hooks/use-personal-organization", () => ({
|
|
||||||
usePersonalOrganization: () => mocks.organization,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("useChatAgent", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mocks.organization = {};
|
|
||||||
mocks.agent.sendMessage.mockClear();
|
|
||||||
mocks.useFlueAgent.mockReset();
|
|
||||||
mocks.useFlueAgent.mockReturnValue(mocks.agent);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not use the agent before organization bootstrap completes", async () => {
|
|
||||||
const chat = useChatAgent();
|
|
||||||
|
|
||||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
|
||||||
id: undefined,
|
|
||||||
live: "sse",
|
|
||||||
name: "zopu",
|
|
||||||
});
|
|
||||||
expect(chat.status).toBe("connecting");
|
|
||||||
await expect(chat.sendMessage("too early")).rejects.toThrow(
|
|
||||||
"Personal organization is still being prepared"
|
|
||||||
);
|
|
||||||
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the ensured organization id for the agent session", async () => {
|
|
||||||
mocks.organization = { organizationId: "org-a" };
|
|
||||||
|
|
||||||
const chat = useChatAgent();
|
|
||||||
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
|
|
||||||
id: "org-a",
|
|
||||||
live: "sse",
|
|
||||||
name: "zopu",
|
|
||||||
});
|
|
||||||
|
|
||||||
await chat.sendMessage("ready");
|
|
||||||
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +1,111 @@
|
|||||||
import { useFlueAgent } from "@flue/react";
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
|
||||||
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
import type { PersonalOrganizationState } from "@/hooks/use-personal-organization";
|
||||||
import { CHAT_AGENT } from "@/lib/chat/constants";
|
import { projectConversation } from "@/lib/chat/conversation";
|
||||||
import type { ChatAgentState } from "@/lib/chat/types";
|
import type { AgentStatus, ChatAgentState } from "@/lib/chat/types";
|
||||||
|
|
||||||
|
const requestId = (): string =>
|
||||||
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
|
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
const uploadImage = async (
|
||||||
|
file: File,
|
||||||
|
generateUploadUrl: () => Promise<string>
|
||||||
|
) => {
|
||||||
|
const response = await fetch(await generateUploadUrl(), {
|
||||||
|
body: file,
|
||||||
|
headers: { "content-type": file.type },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Image upload failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof payload !== "object" ||
|
||||||
|
payload === null ||
|
||||||
|
!("storageId" in payload) ||
|
||||||
|
typeof payload.storageId !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("Image upload returned an invalid response");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filename: file.name || undefined,
|
||||||
|
mimeType: file.type,
|
||||||
|
storageId: payload.storageId as Id<"_storage">,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const useOrganizationChatAgent = (
|
export const useOrganizationChatAgent = (
|
||||||
organization: PersonalOrganizationState
|
organization: PersonalOrganizationState
|
||||||
): ChatAgentState => {
|
): ChatAgentState => {
|
||||||
const agent = useFlueAgent({
|
const { organizationId } = organization;
|
||||||
...CHAT_AGENT,
|
const rows = useQuery(
|
||||||
id: organization.organizationId,
|
api.conversationMessages.listForCurrentOrganization,
|
||||||
});
|
organizationId ? { organizationId } : "skip"
|
||||||
|
);
|
||||||
|
const send = useMutation(api.conversationMessages.send);
|
||||||
|
const generateUploadUrl = useMutation(
|
||||||
|
api.conversationMessages.generateUploadUrl
|
||||||
|
);
|
||||||
|
const [sendError, setSendError] = useState<Error>();
|
||||||
|
|
||||||
const sendMessage = async (message: string): Promise<void> => {
|
const projected = projectConversation(rows ?? []);
|
||||||
if (!organization.organizationId) {
|
|
||||||
|
let status: AgentStatus = "idle";
|
||||||
|
if (projected.streaming) {
|
||||||
|
status = "streaming";
|
||||||
|
} else if (projected.pending) {
|
||||||
|
status = "submitted";
|
||||||
|
}
|
||||||
|
if (!organizationId || rows === undefined) {
|
||||||
|
status = organization.error ? "error" : "connecting";
|
||||||
|
} else if (projected.failedError || sendError) {
|
||||||
|
status = "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = async (
|
||||||
|
message: string,
|
||||||
|
options?: { readonly images?: readonly File[] }
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!organizationId) {
|
||||||
throw (
|
throw (
|
||||||
organization.error ??
|
organization.error ??
|
||||||
new Error("Personal organization is still being prepared")
|
new Error("Personal organization is still being prepared")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await agent.sendMessage(message);
|
setSendError(undefined);
|
||||||
|
try {
|
||||||
|
const images = await Promise.all(
|
||||||
|
(options?.images ?? []).map((file) =>
|
||||||
|
uploadImage(file, generateUploadUrl)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await send({
|
||||||
|
clientRequestId: requestId(),
|
||||||
|
images,
|
||||||
|
organizationId,
|
||||||
|
rawText: message,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const normalized =
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
setSendError(normalized);
|
||||||
|
throw normalized;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let { status } = agent;
|
|
||||||
if (!organization.organizationId) {
|
|
||||||
status = organization.error ? "error" : "connecting";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: organization.error ?? agent.error,
|
error:
|
||||||
historyReady: agent.historyReady,
|
organization.error ??
|
||||||
messages: agent.messages,
|
sendError ??
|
||||||
|
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||||
|
historyReady: rows !== undefined,
|
||||||
|
messages: projected.messages,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
status,
|
status,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useChatAgent = (): ChatAgentState =>
|
|
||||||
useOrganizationChatAgent(usePersonalOrganization());
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
|
||||||
|
import type { PendingChatImage } from "@/lib/chat/attachments";
|
||||||
|
|
||||||
|
const generateImageId = (): string =>
|
||||||
|
globalThis.crypto?.randomUUID?.() ??
|
||||||
|
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
|
||||||
|
export const useChatImages = () => {
|
||||||
|
const [images, setImages] = useState<PendingChatImage[]>([]);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const previewUrls = useRef(new Set<string>());
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
for (const previewUrl of previewUrls.current) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addFiles = (files: FileList | null) => {
|
||||||
|
if (!files) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(undefined);
|
||||||
|
setImages((current) => {
|
||||||
|
const remaining = MAX_CHAT_IMAGES - current.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = [...current];
|
||||||
|
for (const file of [...files].slice(0, remaining)) {
|
||||||
|
const validation = validateChatImage(file);
|
||||||
|
if (!validation.accepted) {
|
||||||
|
setError(validation.message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previewUrl = URL.createObjectURL(file);
|
||||||
|
previewUrls.current.add(previewUrl);
|
||||||
|
next.push({
|
||||||
|
file,
|
||||||
|
id: generateImageId(),
|
||||||
|
previewUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (files.length > remaining) {
|
||||||
|
setError(`Attach up to ${MAX_CHAT_IMAGES} images`);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
setImages((current) => {
|
||||||
|
const removed = current.find((image) => image.id === id);
|
||||||
|
if (removed) {
|
||||||
|
URL.revokeObjectURL(removed.previewUrl);
|
||||||
|
previewUrls.current.delete(removed.previewUrl);
|
||||||
|
}
|
||||||
|
return current.filter((image) => image.id !== id);
|
||||||
|
});
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
for (const previewUrl of previewUrls.current) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
previewUrls.current.clear();
|
||||||
|
setImages([]);
|
||||||
|
setError(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { addFiles, clear, error, handleRemove: remove, images } as const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ProjectView } from "@code/primitives/project";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export const useFilteredProjects = ({
|
||||||
|
projects,
|
||||||
|
search,
|
||||||
|
}: {
|
||||||
|
readonly projects: readonly ProjectView[];
|
||||||
|
readonly search: string;
|
||||||
|
}) =>
|
||||||
|
useMemo(() => {
|
||||||
|
const searchTerm = search.trim().toLowerCase();
|
||||||
|
if (!searchTerm) {
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.filter((project) => {
|
||||||
|
const [source] = project.sources;
|
||||||
|
return (
|
||||||
|
project.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
source?.repositoryPath.toLowerCase().includes(searchTerm) ||
|
||||||
|
source?.host.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [projects, search]);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
|
||||||
|
import { useAction } from "convex/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
|
||||||
|
|
||||||
|
export type GithubSearchState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "results"; results: readonly GithubRepositorySearchResult[] }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
| { kind: "reauth" };
|
||||||
|
|
||||||
|
const MIN_QUERY_LENGTH = 2;
|
||||||
|
const DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced live GitHub repository search. Calls the
|
||||||
|
* `gitConnections:searchGithubRepositories` action with the user's stored
|
||||||
|
* encrypted credential (the token never reaches the browser). Returns a
|
||||||
|
* stale-safe, cancellable search state.
|
||||||
|
*
|
||||||
|
* Only searches when the GitHub provider filter is active (or "all") and a
|
||||||
|
* query is at least `MIN_QUERY_LENGTH` characters.
|
||||||
|
*/
|
||||||
|
export const useGithubRepoSearch = ({
|
||||||
|
connectionId,
|
||||||
|
enabled,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
readonly connectionId: Id<"gitConnections"> | undefined;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly query: string;
|
||||||
|
}): GithubSearchState => {
|
||||||
|
const searchAction = useAction(api.gitConnections.searchGithubRepositories);
|
||||||
|
const [state, setState] = useState<GithubSearchState>({ kind: "idle" });
|
||||||
|
// Track the latest request so stale responses are discarded.
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const shouldBeSearching =
|
||||||
|
enabled && connectionId !== undefined && trimmed.length >= MIN_QUERY_LENGTH;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldBeSearching || !connectionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequestId = requestIdRef.current + 1;
|
||||||
|
requestIdRef.current = currentRequestId;
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler -- loading state must be set synchronously before the debounced fetch
|
||||||
|
setState({ kind: "loading" });
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const results = await searchAction({
|
||||||
|
connectionId,
|
||||||
|
query: trimmed,
|
||||||
|
});
|
||||||
|
// Discard if a newer request superseded this one.
|
||||||
|
if (requestIdRef.current !== currentRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({ kind: "results", results });
|
||||||
|
} catch (caughtError) {
|
||||||
|
if (requestIdRef.current !== currentRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
caughtError instanceof Error
|
||||||
|
? caughtError.message
|
||||||
|
: "GitHub search failed";
|
||||||
|
if (
|
||||||
|
message.toLowerCase().includes("reauth") ||
|
||||||
|
message.toLowerCase().includes("rejected") ||
|
||||||
|
message.toLowerCase().includes("reconnect")
|
||||||
|
) {
|
||||||
|
setState({ kind: "reauth" });
|
||||||
|
} else {
|
||||||
|
setState({ kind: "error", message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [connectionId, shouldBeSearching, searchAction, trimmed]);
|
||||||
|
// Reset to idle when search is no longer active.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldBeSearching && state.kind !== "idle") {
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler -- reset state when search is no longer active
|
||||||
|
setState({ kind: "idle" });
|
||||||
|
}
|
||||||
|
}, [shouldBeSearching, state.kind]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { api } from "@code/backend/convex/_generated/api";
|
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import type {
|
|
||||||
MobileAssistantMessageView,
|
|
||||||
MobileAssistantView,
|
|
||||||
} from "@code/ui/components/mobile-product";
|
|
||||||
import { useQuery } from "convex/react";
|
|
||||||
|
|
||||||
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
|
||||||
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
|
||||||
import { useProjectWorkspace } from "@/hooks/use-project-workspace";
|
|
||||||
import { STATUS_COPY } from "@/lib/chat/constants";
|
|
||||||
import { getMessageText } from "@/lib/chat/transforms";
|
|
||||||
import { buildMobileWorkspaceView } from "@/lib/mobile-workspace/build-mobile-workspace-view";
|
|
||||||
|
|
||||||
const toAssistantMessages = (
|
|
||||||
messages: ReturnType<typeof useOrganizationChatAgent>["messages"]
|
|
||||||
): MobileAssistantMessageView[] =>
|
|
||||||
messages.flatMap((message) => {
|
|
||||||
if (message.role !== "assistant" && message.role !== "user") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const text = getMessageText(message);
|
|
||||||
return text
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: message.id,
|
|
||||||
role: message.role,
|
|
||||||
text,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const getStatusTone = (
|
|
||||||
status: ReturnType<typeof useOrganizationChatAgent>["status"]
|
|
||||||
): MobileAssistantView["statusTone"] => {
|
|
||||||
if (status === "error") {
|
|
||||||
return "red";
|
|
||||||
}
|
|
||||||
return status === "connecting" ? "amber" : "green";
|
|
||||||
};
|
|
||||||
|
|
||||||
interface UseMobileProjectWorkspaceOptions {
|
|
||||||
readonly selectedWorkUnitId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMobileProjectWorkspace = ({
|
|
||||||
selectedWorkUnitId,
|
|
||||||
}: UseMobileProjectWorkspaceOptions) => {
|
|
||||||
const organization = usePersonalOrganization();
|
|
||||||
const projectWorkspace = useProjectWorkspace();
|
|
||||||
const chatAgent = useOrganizationChatAgent(organization);
|
|
||||||
const signals = useQuery(
|
|
||||||
api.signals.list,
|
|
||||||
organization.organizationId
|
|
||||||
? { organizationId: organization.organizationId }
|
|
||||||
: "skip"
|
|
||||||
);
|
|
||||||
const assistant: MobileAssistantView = {
|
|
||||||
isBusy:
|
|
||||||
chatAgent.status === "connecting" ||
|
|
||||||
chatAgent.status === "streaming" ||
|
|
||||||
chatAgent.status === "submitted",
|
|
||||||
messages: toAssistantMessages(chatAgent.messages),
|
|
||||||
statusLabel: STATUS_COPY[chatAgent.status],
|
|
||||||
statusTone: getStatusTone(chatAgent.status),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: buildMobileWorkspaceView({
|
|
||||||
artifacts: projectWorkspace.artifacts,
|
|
||||||
assistant,
|
|
||||||
events: projectWorkspace.events,
|
|
||||||
issues: projectWorkspace.issues,
|
|
||||||
organizationLabel: organization.organizationName,
|
|
||||||
projects: projectWorkspace.projects,
|
|
||||||
selectedProject: projectWorkspace.selectedProject,
|
|
||||||
selectedWorkUnitId,
|
|
||||||
signals,
|
|
||||||
}),
|
|
||||||
error: organization.error ?? chatAgent.error,
|
|
||||||
organization,
|
|
||||||
projectWorkspace,
|
|
||||||
raiseIssue: projectWorkspace.raiseIssue,
|
|
||||||
raiseIssueFromSignal: (signalId: string) =>
|
|
||||||
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
|
|
||||||
sendMessage: chatAgent.sendMessage,
|
|
||||||
startWorkUnit: projectWorkspace.startWorkUnit,
|
|
||||||
} as const;
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import type { FormEvent } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface UseMobileWorkspaceOptions {
|
|
||||||
readonly initialExpanded?: boolean;
|
|
||||||
readonly onCreateIssue: (title: string, body: string) => Promise<void>;
|
|
||||||
readonly onSend: (message: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = (error: unknown) =>
|
|
||||||
error instanceof Error ? error.message : "Message could not be sent";
|
|
||||||
|
|
||||||
export const useMobileWorkspace = ({
|
|
||||||
initialExpanded = false,
|
|
||||||
onCreateIssue,
|
|
||||||
onSend,
|
|
||||||
}: UseMobileWorkspaceOptions) => {
|
|
||||||
const [composerValue, setComposerValue] = useState("");
|
|
||||||
const [issueBody, setIssueBody] = useState("");
|
|
||||||
const [issueTitle, setIssueTitle] = useState("");
|
|
||||||
const [expanded, setExpanded] = useState(initialExpanded);
|
|
||||||
const [statusMessage, setStatusMessage] = useState<string>();
|
|
||||||
|
|
||||||
const handleComposerChange = (value: string) => {
|
|
||||||
setComposerValue(value);
|
|
||||||
setStatusMessage(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const message = composerValue.trim();
|
|
||||||
if (!message) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatusMessage("Sending to Zopu…");
|
|
||||||
const sendMessage = async () => {
|
|
||||||
try {
|
|
||||||
await onSend(message);
|
|
||||||
setComposerValue("");
|
|
||||||
setStatusMessage("Sent to Zopu");
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(errorMessage(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void sendMessage();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateIssueSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const title = issueTitle.trim();
|
|
||||||
const body = issueBody.trim();
|
|
||||||
if (!title || !body) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatusMessage("Creating project work…");
|
|
||||||
const createIssue = async () => {
|
|
||||||
try {
|
|
||||||
await onCreateIssue(title, body);
|
|
||||||
setIssueTitle("");
|
|
||||||
setIssueBody("");
|
|
||||||
setStatusMessage("Project work created");
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(errorMessage(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void createIssue();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
composerValue,
|
|
||||||
expanded,
|
|
||||||
handleComposerChange,
|
|
||||||
handleComposerSubmit,
|
|
||||||
handleCreateIssueSubmit,
|
|
||||||
issueBody,
|
|
||||||
issueTitle,
|
|
||||||
setExpanded,
|
|
||||||
setIssueBody,
|
|
||||||
setIssueTitle,
|
|
||||||
statusMessage,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useWebAuth } from "@code/auth/web";
|
import { useWebAuth } from "@code/auth/web";
|
||||||
import { api } from "@code/backend/convex/_generated/api";
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
import { useMutation } from "convex/react";
|
import { useConvexAuth, useMutation } from "convex/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface PersonalOrganizationState {
|
export interface PersonalOrganizationState {
|
||||||
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
|
|||||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||||
const auth = useWebAuth();
|
const auth = useWebAuth();
|
||||||
|
const { isAuthenticated, isRefreshing } = useConvexAuth();
|
||||||
|
const convexReady = isAuthenticated && !isRefreshing;
|
||||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||||
const ensurePersonalOrganization = useMutation(
|
const ensurePersonalOrganization = useMutation(
|
||||||
api.organizations.ensurePersonalOrganization
|
api.organizations.ensurePersonalOrganization
|
||||||
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) {
|
if (!convexReady || !userId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [ensurePersonalOrganization, userId]);
|
}, [convexReady, ensurePersonalOrganization, userId]);
|
||||||
|
|
||||||
if (!userId || state?.userId !== userId) {
|
if (!convexReady || !userId || state?.userId !== userId) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
import { api } from "@code/backend/convex/_generated/api";
|
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import { useFlueClient } from "@flue/react";
|
|
||||||
import { useAction, useMutation, useQuery } from "convex/react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildProjectLoopView,
|
|
||||||
summarizeProjectIssues,
|
|
||||||
} from "@/lib/projects/project-evidence";
|
|
||||||
|
|
||||||
const errorMessage = (error: unknown) =>
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
export const useProjectWorkspace = () => {
|
|
||||||
const flueClient = useFlueClient();
|
|
||||||
const projects = useQuery(api.projects.list);
|
|
||||||
const [selectedProjectId, setSelectedProjectId] =
|
|
||||||
useState<Id<"projects"> | null>(null);
|
|
||||||
const [repository, setRepository] = useState("");
|
|
||||||
const [issueTitle, setIssueTitle] = useState("");
|
|
||||||
const [issueBody, setIssueBody] = useState("");
|
|
||||||
const [selectedIssueId, setSelectedIssueId] =
|
|
||||||
useState<Id<"projectIssues"> | null>(null);
|
|
||||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const importPublicGit = useAction(api.projects.importPublicGit);
|
|
||||||
const createIssue = useMutation(api.projectIssues.create);
|
|
||||||
const createIssueFromSignal = useMutation(api.projectIssues.createFromSignal);
|
|
||||||
const beginIssue = useMutation(api.projectIssues.begin);
|
|
||||||
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
|
|
||||||
const activeProjectId =
|
|
||||||
selectedProjectId ??
|
|
||||||
(projects?.[0]?.id as unknown as Id<"projects">) ??
|
|
||||||
null;
|
|
||||||
const artifacts = useQuery(
|
|
||||||
api.projectArtifacts.list,
|
|
||||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
|
||||||
);
|
|
||||||
const issues = useQuery(
|
|
||||||
api.projectIssues.list,
|
|
||||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
|
||||||
);
|
|
||||||
const events = useQuery(
|
|
||||||
api.projectIssues.events,
|
|
||||||
activeProjectId ? { projectId: activeProjectId } : "skip"
|
|
||||||
);
|
|
||||||
const pullRequest = events
|
|
||||||
?.map((event) => event.data)
|
|
||||||
.find(
|
|
||||||
(
|
|
||||||
data
|
|
||||||
): data is {
|
|
||||||
pullRequest: {
|
|
||||||
baseBranch: string;
|
|
||||||
branch: string;
|
|
||||||
number: number;
|
|
||||||
status: "open" | "closed" | "merged";
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
} =>
|
|
||||||
typeof data === "object" &&
|
|
||||||
data !== null &&
|
|
||||||
"pullRequest" in data &&
|
|
||||||
typeof data.pullRequest === "object" &&
|
|
||||||
data.pullRequest !== null
|
|
||||||
)?.pullRequest;
|
|
||||||
|
|
||||||
const connectRepository = async () => {
|
|
||||||
setPendingAction("connect");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const outcome = await importPublicGit({ repositoryUrl: repository });
|
|
||||||
setSelectedProjectId(outcome.id as unknown as Id<"projects">);
|
|
||||||
setRepository("");
|
|
||||||
} catch (caughtError) {
|
|
||||||
setError(errorMessage(caughtError));
|
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const raiseIssue = async (input?: {
|
|
||||||
readonly body?: string;
|
|
||||||
readonly title?: string;
|
|
||||||
}) => {
|
|
||||||
if (!activeProjectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextTitle = input?.title ?? issueTitle;
|
|
||||||
const nextBody = input?.body ?? issueBody;
|
|
||||||
setPendingAction("issue");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const issueId = await createIssue({
|
|
||||||
body: nextBody,
|
|
||||||
projectId: activeProjectId,
|
|
||||||
title: nextTitle,
|
|
||||||
});
|
|
||||||
setSelectedIssueId(issueId);
|
|
||||||
setIssueTitle("");
|
|
||||||
setIssueBody("");
|
|
||||||
} catch (caughtError) {
|
|
||||||
setError(errorMessage(caughtError));
|
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const raiseIssueFromSignal = async (signalId: Id<"signals">) => {
|
|
||||||
setPendingAction(`signal:${signalId}`);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const outcome = await createIssueFromSignal({ signalId });
|
|
||||||
setSelectedIssueId(outcome.issueId);
|
|
||||||
} catch (caughtError) {
|
|
||||||
setError(errorMessage(caughtError));
|
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startIssue = async (
|
|
||||||
issueId: Id<"projectIssues">,
|
|
||||||
issueNumber: number,
|
|
||||||
title: string
|
|
||||||
) => {
|
|
||||||
const actionKey = `issue:${issueId}`;
|
|
||||||
setPendingAction(actionKey);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await beginIssue({ issueId });
|
|
||||||
await flueClient.agents.send("project-manager", String(issueId), {
|
|
||||||
message: `Start project issue ${issueNumber}: ${title}. Read the bound context and complete the workflow.`,
|
|
||||||
});
|
|
||||||
} catch (caughtError) {
|
|
||||||
const message = errorMessage(caughtError);
|
|
||||||
await markDispatchFailed({ error: message, issueId });
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setPendingAction(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startWorkUnit = async (issueId: Id<"projectIssues">) => {
|
|
||||||
const issue = issues?.find((candidate) => candidate._id === issueId);
|
|
||||||
if (!issue) {
|
|
||||||
setError("That work unit is no longer available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await startIssue(issue._id, issue.number, issue.title);
|
|
||||||
};
|
|
||||||
const selectedProject =
|
|
||||||
projects?.find(
|
|
||||||
(project) => project.id === (activeProjectId as unknown as string)
|
|
||||||
) ?? null;
|
|
||||||
const selectedIssue =
|
|
||||||
issues?.find((issue) => issue._id === selectedIssueId) ?? issues?.[0];
|
|
||||||
const projectLoop = buildProjectLoopView({
|
|
||||||
artifacts,
|
|
||||||
issue: selectedIssue,
|
|
||||||
source: selectedProject?.sources[0],
|
|
||||||
});
|
|
||||||
const issueSummary = summarizeProjectIssues(issues);
|
|
||||||
|
|
||||||
return {
|
|
||||||
artifacts,
|
|
||||||
connectRepository,
|
|
||||||
error,
|
|
||||||
events,
|
|
||||||
issueBody,
|
|
||||||
issueSummary,
|
|
||||||
issueTitle,
|
|
||||||
issues,
|
|
||||||
pendingAction,
|
|
||||||
projectLoop,
|
|
||||||
projects,
|
|
||||||
pullRequest,
|
|
||||||
raiseIssue,
|
|
||||||
raiseIssueFromSignal,
|
|
||||||
repository,
|
|
||||||
selectedIssue,
|
|
||||||
selectedIssueId,
|
|
||||||
selectedProject,
|
|
||||||
selectedProjectId: activeProjectId,
|
|
||||||
setIssueBody,
|
|
||||||
setIssueTitle,
|
|
||||||
setRepository,
|
|
||||||
setSelectedIssueId,
|
|
||||||
setSelectedProjectId,
|
|
||||||
startIssue,
|
|
||||||
startWorkUnit,
|
|
||||||
} as const;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export const useProjectsPageState = ({
|
||||||
|
hasProjects,
|
||||||
|
}: {
|
||||||
|
readonly hasProjects: boolean | undefined;
|
||||||
|
}) => {
|
||||||
|
const [addProjectOpen, setAddProjectOpen] = useState<boolean>();
|
||||||
|
const [projectSearch, setProjectSearch] = useState("");
|
||||||
|
const isAddProjectOpen = addProjectOpen ?? hasProjects === false;
|
||||||
|
|
||||||
|
const closeAddProject = useCallback(() => setAddProjectOpen(false), []);
|
||||||
|
const openAddProject = useCallback(() => setAddProjectOpen(true), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeAddProject,
|
||||||
|
isAddProjectOpen,
|
||||||
|
openAddProject,
|
||||||
|
projectSearch,
|
||||||
|
setProjectSearch,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export interface GitRepositoryOption {
|
||||||
|
readonly cloneUrl: string;
|
||||||
|
readonly defaultBranch: string;
|
||||||
|
readonly fullName: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly owner: string;
|
||||||
|
readonly privacy: "public" | "private";
|
||||||
|
readonly provider: "github" | "gitea";
|
||||||
|
readonly webUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderFilter = "all" | GitRepositoryOption["provider"];
|
||||||
|
type PrivacyFilter = "all" | GitRepositoryOption["privacy"];
|
||||||
|
|
||||||
|
export const useRepositoryPicker = ({
|
||||||
|
existingSourceUrls,
|
||||||
|
repositories,
|
||||||
|
}: {
|
||||||
|
readonly existingSourceUrls: readonly string[];
|
||||||
|
readonly repositories: readonly GitRepositoryOption[] | undefined;
|
||||||
|
}) => {
|
||||||
|
const [privacyFilter, setPrivacyFilter] = useState<PrivacyFilter>("all");
|
||||||
|
const [providerFilter, setProviderFilter] = useState<ProviderFilter>("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const availableRepositories = useMemo(() => {
|
||||||
|
if (!repositories) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingUrls = new Set(existingSourceUrls);
|
||||||
|
const searchTerm = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return repositories
|
||||||
|
.filter((repository) => !existingUrls.has(repository.webUrl))
|
||||||
|
.filter(
|
||||||
|
(repository) =>
|
||||||
|
providerFilter === "all" || repository.provider === providerFilter
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(repository) =>
|
||||||
|
privacyFilter === "all" || repository.privacy === privacyFilter
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(repository) =>
|
||||||
|
!searchTerm ||
|
||||||
|
repository.fullName.toLowerCase().includes(searchTerm) ||
|
||||||
|
repository.owner.toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
.toSorted((left, right) => left.fullName.localeCompare(right.fullName));
|
||||||
|
}, [existingSourceUrls, privacyFilter, providerFilter, repositories, search]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableRepositories,
|
||||||
|
privacyFilter,
|
||||||
|
providerFilter,
|
||||||
|
search,
|
||||||
|
setPrivacyFilter,
|
||||||
|
setProviderFilter,
|
||||||
|
setSearch,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { authClient } from "@code/auth/web";
|
||||||
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { useAction, useMutation, useQuery } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
|
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
|
||||||
|
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||||
|
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
|
||||||
|
|
||||||
|
const toError = (error: unknown) =>
|
||||||
|
error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
const workListRef = makeFunctionReference<
|
||||||
|
"query",
|
||||||
|
{ projectId: Id<"projects"> },
|
||||||
|
WorkRecord[]
|
||||||
|
>("workPlanning:listForProject");
|
||||||
|
const requestDefinitionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ workId: Id<"works"> },
|
||||||
|
unknown
|
||||||
|
>("workPlanning:requestDefinition");
|
||||||
|
const saveDefinitionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ workId: Id<"works">; payloadJson: string },
|
||||||
|
unknown
|
||||||
|
>("workPlanning:saveDefinitionProposal");
|
||||||
|
const saveDesignRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ workId: Id<"works">; payloadJson: string },
|
||||||
|
unknown
|
||||||
|
>("workPlanning:saveDesignProposal");
|
||||||
|
const startExecutionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ sliceId?: string; workId: Id<"works"> },
|
||||||
|
unknown
|
||||||
|
>("workExecution:start");
|
||||||
|
const cancelExecutionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ runId: Id<"workRuns"> },
|
||||||
|
unknown
|
||||||
|
>("workExecution:cancel");
|
||||||
|
const retryExecutionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ runId: Id<"workRuns"> },
|
||||||
|
unknown
|
||||||
|
>("workExecution:retry");
|
||||||
|
const listGitConnectionsRef = makeFunctionReference<
|
||||||
|
"query",
|
||||||
|
Record<string, never>,
|
||||||
|
readonly {
|
||||||
|
id: string;
|
||||||
|
provider: "github" | "gitea";
|
||||||
|
serverUrl: string;
|
||||||
|
username?: string;
|
||||||
|
}[]
|
||||||
|
>("gitConnectionData:list");
|
||||||
|
const projectGitConnectionRef = makeFunctionReference<
|
||||||
|
"query",
|
||||||
|
{ projectId: Id<"projects"> },
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
provider: "github" | "gitea";
|
||||||
|
serverUrl: string;
|
||||||
|
username?: string;
|
||||||
|
} | null
|
||||||
|
>("gitConnectionData:getForProject");
|
||||||
|
const connectGiteaRef = makeFunctionReference<
|
||||||
|
"action",
|
||||||
|
{ token: string; username?: string },
|
||||||
|
{ connectionId: Id<"gitConnections"> }
|
||||||
|
>("gitConnections:connectGitea");
|
||||||
|
const connectGithubRef = makeFunctionReference<
|
||||||
|
"action",
|
||||||
|
Record<string, never>,
|
||||||
|
{ connectionId: Id<"gitConnections"> }
|
||||||
|
>("gitConnections:connectGithub");
|
||||||
|
const attachGitConnectionRef = makeFunctionReference<
|
||||||
|
"mutation",
|
||||||
|
{ connectionId: Id<"gitConnections">; projectId: Id<"projects"> },
|
||||||
|
unknown
|
||||||
|
>("gitConnectionData:attachToProject");
|
||||||
|
|
||||||
|
export const useProjectWorkspace = (): WorkspaceState => {
|
||||||
|
const organization = usePersonalOrganization();
|
||||||
|
const projects = useQuery(
|
||||||
|
api.projects.list,
|
||||||
|
organization.organizationId ? {} : "skip"
|
||||||
|
);
|
||||||
|
const importPublicGit = useAction(api.projects.importPublicGit);
|
||||||
|
const agent = useOrganizationChatAgent(organization);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [selectedProjectId, setSelectedProjectId] =
|
||||||
|
useState<Id<"projects"> | null>(null);
|
||||||
|
const [repository, setRepository] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<Error>();
|
||||||
|
const [operationError, setOperationError] = useState<Error>();
|
||||||
|
|
||||||
|
const projectParam = searchParams.get("project");
|
||||||
|
const paramProject = projectParam
|
||||||
|
? (projectParam as unknown as Id<"projects">)
|
||||||
|
: null;
|
||||||
|
const selectedProjectStillExists = projects?.some(
|
||||||
|
(project) =>
|
||||||
|
project.id === (selectedProjectId as unknown as string) ||
|
||||||
|
project.id === (paramProject as unknown as string)
|
||||||
|
);
|
||||||
|
const activeProjectId = selectedProjectStillExists
|
||||||
|
? (selectedProjectId ?? paramProject)
|
||||||
|
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
|
||||||
|
const works = useQuery(
|
||||||
|
workListRef,
|
||||||
|
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||||
|
);
|
||||||
|
const gitConnections = useQuery(
|
||||||
|
listGitConnectionsRef,
|
||||||
|
organization.organizationId ? {} : "skip"
|
||||||
|
);
|
||||||
|
const projectGitConnection = useQuery(
|
||||||
|
projectGitConnectionRef,
|
||||||
|
activeProjectId ? { projectId: activeProjectId } : "skip"
|
||||||
|
);
|
||||||
|
const requestDefinitionMutation = useMutation(requestDefinitionRef);
|
||||||
|
const saveDefinitionMutation = useMutation(saveDefinitionRef);
|
||||||
|
const saveDesignMutation = useMutation(saveDesignRef);
|
||||||
|
const startExecutionMutation = useMutation(startExecutionRef);
|
||||||
|
const cancelExecutionMutation = useMutation(cancelExecutionRef);
|
||||||
|
const retryExecutionMutation = useMutation(retryExecutionRef);
|
||||||
|
const attachGitConnectionMutation = useMutation(attachGitConnectionRef);
|
||||||
|
const connectGiteaAction = useAction(connectGiteaRef);
|
||||||
|
const connectGithubAction = useAction(connectGithubRef);
|
||||||
|
const selectedProject = useMemo(
|
||||||
|
() =>
|
||||||
|
projects?.find(
|
||||||
|
(project) => project.id === (activeProjectId as unknown as string)
|
||||||
|
) ?? null,
|
||||||
|
[activeProjectId, projects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||||
|
setOperationError(undefined);
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (caughtError) {
|
||||||
|
setOperationError(toError(caughtError));
|
||||||
|
throw caughtError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectRepository = async () => {
|
||||||
|
const repositoryUrl = repository.trim();
|
||||||
|
if (!repositoryUrl || pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const project = await importPublicGit({ repositoryUrl });
|
||||||
|
setSelectedProjectId(project.id as unknown as Id<"projects">);
|
||||||
|
setRepository("");
|
||||||
|
} catch (caughtError) {
|
||||||
|
setError(toError(caughtError));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachConnection = async (connectionId: Id<"gitConnections">) => {
|
||||||
|
if (!activeProjectId) {
|
||||||
|
throw new Error("Select a project first");
|
||||||
|
}
|
||||||
|
await attachGitConnectionMutation({
|
||||||
|
connectionId,
|
||||||
|
projectId: activeProjectId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectGitea = (input: { token: string; username?: string }) =>
|
||||||
|
runOperation(async () => {
|
||||||
|
const result = await connectGiteaAction(input);
|
||||||
|
await attachConnection(result.connectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectLinkedGithub = () =>
|
||||||
|
runOperation(async () => {
|
||||||
|
const result = await connectGithubAction({});
|
||||||
|
await attachConnection(result.connectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
authorizeGithub: () =>
|
||||||
|
authClient.linkSocial({
|
||||||
|
callbackURL: `${window.location.origin}/projects?resume=github`,
|
||||||
|
provider: "github",
|
||||||
|
}),
|
||||||
|
cancelExecution: (runId: Id<"workRuns">) =>
|
||||||
|
runOperation(() => cancelExecutionMutation({ runId })),
|
||||||
|
clearOperationError: () => setOperationError(undefined),
|
||||||
|
connectGitea,
|
||||||
|
connectLinkedGithub,
|
||||||
|
connectRepository,
|
||||||
|
error,
|
||||||
|
gitConnections,
|
||||||
|
operationError,
|
||||||
|
pending,
|
||||||
|
projectGitConnection,
|
||||||
|
projects,
|
||||||
|
repository,
|
||||||
|
requestDefinition: (workId: Id<"works">) =>
|
||||||
|
requestDefinitionMutation({ workId }),
|
||||||
|
retryExecution: (runId: Id<"workRuns">) =>
|
||||||
|
runOperation(() => retryExecutionMutation({ runId })),
|
||||||
|
saveDefinition: (workId: Id<"works">, payload: unknown) =>
|
||||||
|
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||||
|
saveDesign: (workId: Id<"works">, payload: unknown) =>
|
||||||
|
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }),
|
||||||
|
selectProject: (projectId: string) =>
|
||||||
|
setSelectedProjectId(projectId as unknown as Id<"projects">),
|
||||||
|
selectedProject: selectedProject
|
||||||
|
? { id: selectedProject.id, name: selectedProject.name }
|
||||||
|
: null,
|
||||||
|
setRepository,
|
||||||
|
startExecution: (workId: Id<"works">, sliceId?: string) =>
|
||||||
|
runOperation(() => startExecutionMutation({ sliceId, workId })),
|
||||||
|
works,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { visualViewportStyle } from "./use-visual-viewport";
|
||||||
|
|
||||||
|
describe("Workspace visual viewport", () => {
|
||||||
|
test("shrinks the application surface to the keyboard-visible height", () => {
|
||||||
|
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
|
||||||
|
height: "500px",
|
||||||
|
transform: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tracks a panned visual viewport without scrolling the document", () => {
|
||||||
|
expect(visualViewportStyle({ height: 500, offsetTop: 44 })).toEqual({
|
||||||
|
height: "500px",
|
||||||
|
transform: "translateY(44px)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a dynamic viewport fallback before browser measurement", () => {
|
||||||
|
expect(visualViewportStyle()).toEqual({
|
||||||
|
height: "100dvh",
|
||||||
|
transform: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface VisualViewportFrame {
|
||||||
|
readonly height: number;
|
||||||
|
readonly offsetTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readVisualViewport = (): VisualViewportFrame => ({
|
||||||
|
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
|
||||||
|
offsetTop: Math.round(window.visualViewport?.offsetTop ?? 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const visualViewportStyle = (
|
||||||
|
frame?: VisualViewportFrame
|
||||||
|
): CSSProperties => ({
|
||||||
|
height: frame ? `${frame.height}px` : "100dvh",
|
||||||
|
transform: frame?.offsetTop ? `translateY(${frame.offsetTop}px)` : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useVisualViewportStyle = (): CSSProperties => {
|
||||||
|
const [frame, setFrame] = useState<VisualViewportFrame>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
let animationFrame = 0;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
animationFrame = requestAnimationFrame(() => {
|
||||||
|
setFrame(readVisualViewport());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
root.classList.add("workspace-viewport-lock");
|
||||||
|
update();
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
viewport?.addEventListener("resize", update);
|
||||||
|
viewport?.addEventListener("scroll", update);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
root.classList.remove("workspace-viewport-lock");
|
||||||
|
window.removeEventListener("resize", update);
|
||||||
|
viewport?.removeEventListener("resize", update);
|
||||||
|
viewport?.removeEventListener("scroll", update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return visualViewportStyle(frame);
|
||||||
|
};
|
||||||
@@ -6,6 +6,13 @@ body {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.workspace-viewport-lock,
|
||||||
|
html.workspace-viewport-lock body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-conversation-mobile > div {
|
.ai-conversation-mobile > div {
|
||||||
scrollbar-gutter: auto !important;
|
scrollbar-gutter: auto !important;
|
||||||
}
|
}
|
||||||
@@ -68,6 +75,19 @@ body {
|
|||||||
min-height: 1.75rem;
|
min-height: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-progress-bar {
|
||||||
|
animation: route-progress 900ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes route-progress {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes response-dot {
|
@keyframes response-dot {
|
||||||
0%,
|
0%,
|
||||||
60%,
|
60%,
|
||||||
@@ -96,6 +116,45 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The chat always renders on the light workspace surface
|
||||||
|
* (`bg-[#f2f0e7]`, near-black text), but the app forces the dark theme
|
||||||
|
* app-wide (`forcedTheme="dark"`). Streamdown's code/table/mermaid chrome and
|
||||||
|
* the rules below read theme tokens, which under `.dark` resolve to near-black
|
||||||
|
* values — producing unreadable dark-on-dark blocks. Re-declare the relevant
|
||||||
|
* tokens to warm-light values inside the markdown context so every token-driven
|
||||||
|
* chrome (headers, copy/download/fullscreen controls, table borders/cells,
|
||||||
|
* inline code) reads correctly on the light surface without touching the
|
||||||
|
* global theme or unrelated UI.
|
||||||
|
*/
|
||||||
|
.workspace-surface .chat-markdown {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #232321;
|
||||||
|
--muted: #f4f4f2;
|
||||||
|
--muted-foreground: #69675f;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #232321;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #232321;
|
||||||
|
--secondary: #f4f4f2;
|
||||||
|
--secondary-foreground: #232321;
|
||||||
|
--accent: #efece4;
|
||||||
|
--accent-foreground: #232321;
|
||||||
|
--sidebar: #ffffff;
|
||||||
|
--sidebar-foreground: #232321;
|
||||||
|
--sidebar-accent: #f4f4f2;
|
||||||
|
--sidebar-accent-foreground: #232321;
|
||||||
|
--border: #e2e0d6;
|
||||||
|
--input: #e2e0d6;
|
||||||
|
--ring: #b6b7b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-surface .chat-markdown,
|
||||||
|
.workspace-surface .chat-reasoning,
|
||||||
|
.workspace-surface .thinking-line {
|
||||||
|
color: #232321;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-markdown > :first-child {
|
.chat-markdown > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
@@ -197,6 +256,16 @@ body {
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Readable header band + zebra rows so tables stand out on the surface. */
|
||||||
|
.chat-markdown thead th {
|
||||||
|
background: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-markdown tbody tr:nth-child(even) {
|
||||||
|
background: color-mix(in oklch, var(--muted) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.chat-markdown *,
|
.chat-markdown *,
|
||||||
.chat-message,
|
.chat-message,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
|
interface AuthLoaderData {
|
||||||
|
readonly token: string | null;
|
||||||
|
}
|
||||||
|
export const loadAuthToken = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const cookie = request.headers.get("cookie");
|
||||||
|
if (!cookie) {
|
||||||
|
return { token: null };
|
||||||
|
}
|
||||||
|
const tokenUrl = new URL(
|
||||||
|
"/api/auth/convex/token",
|
||||||
|
new URL(request.url).origin
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = new Headers({ cookie });
|
||||||
|
const response = await fetch(tokenUrl, { headers });
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { token: null };
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Response("Authentication service unavailable", { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
typeof payload !== "object" ||
|
||||||
|
payload === null ||
|
||||||
|
!("token" in payload) ||
|
||||||
|
typeof payload.token !== "string"
|
||||||
|
) {
|
||||||
|
throw new Response("Authentication service returned an invalid response", {
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { token: payload.token };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAuthToken = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const auth = await loadAuthToken(request);
|
||||||
|
if (!auth.token) {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
|
||||||
|
throw redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redirectAuthenticated = async (
|
||||||
|
request: Request
|
||||||
|
): Promise<AuthLoaderData> => {
|
||||||
|
const auth = await loadAuthToken(request);
|
||||||
|
if (auth.token) {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const returnTo = requestUrl.searchParams.get("returnTo");
|
||||||
|
throw redirect(returnTo?.startsWith("/") ? returnTo : "/");
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
|
||||||
|
|
||||||
|
describe("chat image attachments", () => {
|
||||||
|
test("accepts supported image payloads within the upload limit", () => {
|
||||||
|
expect(validateChatImage({ size: 1024, type: "image/png" })).toEqual({
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-images and oversized images before transport", () => {
|
||||||
|
expect(validateChatImage({ size: 1024, type: "text/plain" }).accepted).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateChatImage({
|
||||||
|
size: MAX_CHAT_IMAGE_BYTES + 1,
|
||||||
|
type: "image/jpeg",
|
||||||
|
}).accepted
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const MAX_CHAT_IMAGES = 4;
|
||||||
|
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface PendingChatImage {
|
||||||
|
readonly file: File;
|
||||||
|
readonly id: string;
|
||||||
|
readonly previewUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageValidationResult {
|
||||||
|
readonly accepted: boolean;
|
||||||
|
readonly message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateChatImage = (
|
||||||
|
file: Pick<File, "size" | "type">
|
||||||
|
): ImageValidationResult => {
|
||||||
|
if (!file.type.startsWith("image/")) {
|
||||||
|
return { accepted: false, message: "Only image files can be attached" };
|
||||||
|
}
|
||||||
|
if (file.size > MAX_CHAT_IMAGE_BYTES) {
|
||||||
|
return {
|
||||||
|
accepted: false,
|
||||||
|
message: "Images must be 10 MB or smaller",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { accepted: true };
|
||||||
|
};
|
||||||
@@ -1,22 +1,12 @@
|
|||||||
import type { AgentStatus } from "@flue/react";
|
import type { AgentStatus } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The global Zopu agent. The instance `id` is the current organization id
|
* The global Zopu agent. The instance `id` is the current organization id
|
||||||
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
||||||
* instance. Organization scoping is the hard tenancy boundary.
|
* instance. Organization scoping is the hard tenancy boundary.
|
||||||
*/
|
*/
|
||||||
export const CHAT_AGENT = {
|
export const MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
|
||||||
live: "sse",
|
export const MODEL_LABEL = "GPT-OSS 20B Free";
|
||||||
name: "zopu",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const MODEL_LABEL = "GLM-5.2";
|
|
||||||
|
|
||||||
export const SUGGESTIONS = [
|
|
||||||
["Think it through", "Help me make a difficult decision"],
|
|
||||||
["Make a plan", "Turn an idea into clear next steps"],
|
|
||||||
["Explain simply", "Break down something complex"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const STATUS_COPY: Record<AgentStatus, string> = {
|
export const STATUS_COPY: Record<AgentStatus, string> = {
|
||||||
connecting: "Connecting",
|
connecting: "Connecting",
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import type { ConversationRow } from "./conversation";
|
||||||
|
import { projectConversation } from "./conversation";
|
||||||
|
|
||||||
|
const row = (
|
||||||
|
overrides: Partial<ConversationRow> &
|
||||||
|
Pick<ConversationRow, "messageId" | "role" | "status">
|
||||||
|
): ConversationRow => ({
|
||||||
|
attachments: [],
|
||||||
|
error: null,
|
||||||
|
rawText: "",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("projectConversation", () => {
|
||||||
|
test("keeps queued assistant rows as status instead of duplicate messages", () => {
|
||||||
|
const state = projectConversation([
|
||||||
|
row({
|
||||||
|
messageId: "user-1",
|
||||||
|
rawText: "Build it",
|
||||||
|
role: "user",
|
||||||
|
status: "dispatching",
|
||||||
|
}),
|
||||||
|
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
|
||||||
|
]);
|
||||||
|
expect(state.pending).toBe(true);
|
||||||
|
expect(state.messages).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("projects partial assistant text as streaming", () => {
|
||||||
|
const state = projectConversation([
|
||||||
|
row({
|
||||||
|
messageId: "assistant-streaming",
|
||||||
|
rawText: "Working",
|
||||||
|
role: "assistant",
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(state.streaming).toBe(true);
|
||||||
|
expect(state.messages[0]?.parts).toEqual([
|
||||||
|
{ state: "streaming", text: "Working", type: "text" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("projects completed Convex rows into renderable messages", () => {
|
||||||
|
const state = projectConversation([
|
||||||
|
row({
|
||||||
|
messageId: "assistant-1",
|
||||||
|
rawText: "Captured the request.",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(state.messages[0]?.parts).toEqual([
|
||||||
|
{ state: "done", text: "Captured the request.", type: "text" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears a historical failure after a newer turn succeeds", () => {
|
||||||
|
const state = projectConversation([
|
||||||
|
row({
|
||||||
|
error: "Old admission failure",
|
||||||
|
messageId: "assistant-failed",
|
||||||
|
role: "assistant",
|
||||||
|
status: "failed",
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
messageId: "assistant-completed",
|
||||||
|
rawText: "Recovered",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(state.failedError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears historical pending state after a newer turn completes", () => {
|
||||||
|
const state = projectConversation([
|
||||||
|
row({
|
||||||
|
messageId: "assistant-stale",
|
||||||
|
role: "assistant",
|
||||||
|
status: "running",
|
||||||
|
}),
|
||||||
|
row({
|
||||||
|
messageId: "assistant-completed",
|
||||||
|
rawText: "Recovered",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(state.pending).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ConversationMessage } from "./types";
|
||||||
|
|
||||||
|
export interface ConversationRow {
|
||||||
|
readonly attachments: readonly {
|
||||||
|
readonly filename: string | null;
|
||||||
|
readonly id: string;
|
||||||
|
readonly mediaType: string;
|
||||||
|
readonly url: string | null;
|
||||||
|
}[];
|
||||||
|
readonly error: string | null;
|
||||||
|
readonly messageId: string;
|
||||||
|
readonly rawText: string;
|
||||||
|
readonly role: "assistant" | "user";
|
||||||
|
readonly status:
|
||||||
|
| "aborted"
|
||||||
|
| "completed"
|
||||||
|
| "dispatching"
|
||||||
|
| "failed"
|
||||||
|
| "queued"
|
||||||
|
| "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAssistant = (
|
||||||
|
rows: readonly ConversationRow[]
|
||||||
|
): ConversationRow | undefined =>
|
||||||
|
rows.findLast((row) => row.role === "assistant");
|
||||||
|
|
||||||
|
const latestTerminalError = (row: ConversationRow | undefined) =>
|
||||||
|
row?.status === "failed"
|
||||||
|
? (row.error ?? "Conversation turn failed")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const projectConversation = (rows: readonly ConversationRow[]) => {
|
||||||
|
const activeAssistant = latestAssistant(rows);
|
||||||
|
const streaming = rows.some(
|
||||||
|
(row) =>
|
||||||
|
row.role === "assistant" &&
|
||||||
|
row.status === "running" &&
|
||||||
|
row.rawText.length > 0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
failedError: latestTerminalError(activeAssistant),
|
||||||
|
messages: rows
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.role === "user" ||
|
||||||
|
row.status === "completed" ||
|
||||||
|
(row.role === "assistant" &&
|
||||||
|
row.status === "running" &&
|
||||||
|
row.rawText.length > 0)
|
||||||
|
)
|
||||||
|
.map<ConversationMessage>((row) => ({
|
||||||
|
id: row.messageId,
|
||||||
|
parts: [
|
||||||
|
...row.attachments.map((attachment) => ({
|
||||||
|
filename: attachment.filename ?? undefined,
|
||||||
|
id: attachment.id,
|
||||||
|
mediaType: attachment.mediaType,
|
||||||
|
type: "file" as const,
|
||||||
|
url: attachment.url ?? undefined,
|
||||||
|
})),
|
||||||
|
...(row.rawText
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
state:
|
||||||
|
row.status === "running"
|
||||||
|
? ("streaming" as const)
|
||||||
|
: ("done" as const),
|
||||||
|
text: row.rawText,
|
||||||
|
type: "text" as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
role: row.role,
|
||||||
|
})),
|
||||||
|
pending:
|
||||||
|
activeAssistant?.status === "queued" ||
|
||||||
|
activeAssistant?.status === "dispatching" ||
|
||||||
|
activeAssistant?.status === "running",
|
||||||
|
streaming,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractThinkingMarkup,
|
||||||
|
getReasoningText,
|
||||||
|
isReasoningStreaming,
|
||||||
|
} from "./transforms";
|
||||||
|
import type { ConversationMessage } from "./types";
|
||||||
|
|
||||||
|
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
|
||||||
|
id: "message-1",
|
||||||
|
parts,
|
||||||
|
role: "assistant",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reasoning traces", () => {
|
||||||
|
test("extracts completed and streaming MiniMax think blocks", () => {
|
||||||
|
expect(
|
||||||
|
extractThinkingMarkup(
|
||||||
|
"<think>First thought</think><think>Current thought"
|
||||||
|
)
|
||||||
|
).toBe("First thought\n\nCurrent thought");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("combines native reasoning with inline thinking", () => {
|
||||||
|
expect(
|
||||||
|
getReasoningText(
|
||||||
|
message([
|
||||||
|
{ state: "streaming", text: "Native thought", type: "reasoning" },
|
||||||
|
{ state: "streaming", text: "<think>Inline thought", type: "text" },
|
||||||
|
])
|
||||||
|
)
|
||||||
|
).toBe("Native thought\n\nInline thought");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes an open streamed think block as live reasoning", () => {
|
||||||
|
expect(
|
||||||
|
isReasoningStreaming(
|
||||||
|
message([{ state: "streaming", text: "<think>Working", type: "text" }])
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
|
import type { AgentStatus, ConversationMessage } from "./types";
|
||||||
|
|
||||||
export const stripThinkingMarkup = (text: string): string => {
|
export const stripThinkingMarkup = (text: string): string => {
|
||||||
const withoutCompletedBlocks = text.replaceAll(
|
const withoutCompletedBlocks = text.replaceAll(
|
||||||
@@ -15,24 +15,61 @@ export const stripThinkingMarkup = (text: string): string => {
|
|||||||
return visibleText.replaceAll("</think>", "").trimStart();
|
return visibleText.replaceAll("</think>", "").trimStart();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMessageText = (message: FlueConversationMessage): string =>
|
export const extractThinkingMarkup = (text: string): string => {
|
||||||
stripThinkingMarkup(
|
const completed = [
|
||||||
|
...text.matchAll(/<think>(?<content>[\s\S]*?)<\/think>/giu),
|
||||||
|
]
|
||||||
|
.map((match) => match.groups?.content?.trim())
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
const openTagIndex = lowerText.lastIndexOf("<think>");
|
||||||
|
const closeTagIndex = lowerText.lastIndexOf("</think>");
|
||||||
|
if (openTagIndex > closeTagIndex) {
|
||||||
|
const active = text.slice(openTagIndex + "<think>".length).trim();
|
||||||
|
if (active) {
|
||||||
|
completed.push(active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completed.join("\n\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRawMessageText = (message: ConversationMessage): string =>
|
||||||
message.parts
|
message.parts
|
||||||
.filter((part) => part.type === "text")
|
.filter((part) => part.type === "text")
|
||||||
.map((part) => part.text)
|
.map((part) => part.text)
|
||||||
.join("\n")
|
.join("\n");
|
||||||
);
|
|
||||||
|
|
||||||
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
|
export const getMessageText = (message: ConversationMessage): string =>
|
||||||
message.parts.some((part) => {
|
stripThinkingMarkup(getRawMessageText(message));
|
||||||
if (part.type === "dynamic-tool") {
|
|
||||||
return part.state === "input-available";
|
export const getReasoningText = (message: ConversationMessage): string => {
|
||||||
}
|
const nativeReasoning = message.parts
|
||||||
return (
|
.filter((part) => part.type === "reasoning")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n");
|
||||||
|
const inlineReasoning = extractThinkingMarkup(getRawMessageText(message));
|
||||||
|
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
|
||||||
|
const nativeReasoningStreaming = message.parts.some(
|
||||||
|
(part) => part.type === "reasoning" && part.state === "streaming"
|
||||||
|
);
|
||||||
|
const rawText = getRawMessageText(message).toLowerCase();
|
||||||
|
const inlineReasoningStreaming =
|
||||||
|
rawText.lastIndexOf("<think>") > rawText.lastIndexOf("</think>") &&
|
||||||
|
message.parts.some(
|
||||||
|
(part) => part.type === "text" && part.state === "streaming"
|
||||||
|
);
|
||||||
|
return nativeReasoningStreaming || inlineReasoningStreaming;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isMessageStreaming = (message: ConversationMessage): boolean =>
|
||||||
|
message.parts.some(
|
||||||
|
(part) =>
|
||||||
(part.type === "text" || part.type === "reasoning") &&
|
(part.type === "text" || part.type === "reasoning") &&
|
||||||
part.state === "streaming"
|
part.state === "streaming"
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
export const getStatusDotClass = (status: AgentStatus): string => {
|
export const getStatusDotClass = (status: AgentStatus): string => {
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
import type {
|
export type AgentStatus =
|
||||||
AgentStatus,
|
| "connecting"
|
||||||
FlueConversationMessage,
|
| "error"
|
||||||
FlueConversationPart,
|
| "idle"
|
||||||
} from "@flue/react";
|
| "streaming"
|
||||||
|
| "submitted";
|
||||||
|
|
||||||
|
export type ConversationPart =
|
||||||
|
| {
|
||||||
|
readonly state: "done" | "streaming";
|
||||||
|
readonly text: string;
|
||||||
|
readonly type: "text";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly state: "done" | "streaming";
|
||||||
|
readonly text: string;
|
||||||
|
readonly type: "reasoning";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly filename?: string;
|
||||||
|
readonly id?: string;
|
||||||
|
readonly mediaType: string;
|
||||||
|
readonly type: "file";
|
||||||
|
readonly url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ConversationMessage {
|
||||||
|
readonly id: string;
|
||||||
|
readonly parts: ConversationPart[];
|
||||||
|
readonly role: "assistant" | "user";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatAgentState {
|
export interface ChatAgentState {
|
||||||
error?: Error;
|
error?: Error;
|
||||||
historyReady: boolean;
|
historyReady: boolean;
|
||||||
messages: FlueConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
sendMessage: (message: string) => Promise<void>;
|
sendMessage: (
|
||||||
|
message: string,
|
||||||
|
options?: { readonly images?: readonly File[] }
|
||||||
|
) => Promise<void>;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +49,7 @@ export interface ChatComposerProps {
|
|||||||
|
|
||||||
export interface ChatConversationProps {
|
export interface ChatConversationProps {
|
||||||
historyReady: boolean;
|
historyReady: boolean;
|
||||||
messages: FlueConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
onSuggestion: (suggestion: string) => void;
|
onSuggestion: (suggestion: string) => void;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
}
|
}
|
||||||
@@ -30,11 +59,7 @@ export interface ChatHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageProps {
|
export interface ChatMessageProps {
|
||||||
message: FlueConversationMessage;
|
message: ConversationMessage;
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatToolCallProps {
|
|
||||||
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AssistantResponseState = "thinking" | "writing";
|
export type AssistantResponseState = "thinking" | "writing";
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { createFlueClient } from "@flue/sdk";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { createFlueFetch } from "./flue-transport";
|
|
||||||
|
|
||||||
const BASE_URL = new URL("https://flue.example/api/");
|
|
||||||
|
|
||||||
describe("createFlueFetch", () => {
|
|
||||||
test("overlapping SDK agent sends keep distinct request IDs", async () => {
|
|
||||||
const firstResponse = Promise.withResolvers<Response>();
|
|
||||||
const secondResponse = Promise.withResolvers<Response>();
|
|
||||||
const bothStarted = Promise.withResolvers<boolean>();
|
|
||||||
const capturedHeaders: Headers[] = [];
|
|
||||||
let requestCount = 0;
|
|
||||||
let requestId = 0;
|
|
||||||
|
|
||||||
const fetchImpl: typeof fetch = (input, init) => {
|
|
||||||
capturedHeaders.push(new Headers(init?.headers));
|
|
||||||
requestCount += 1;
|
|
||||||
if (requestCount === 2) {
|
|
||||||
bothStarted.resolve(true);
|
|
||||||
}
|
|
||||||
if (requestCount === 1) {
|
|
||||||
return firstResponse.promise;
|
|
||||||
}
|
|
||||||
if (requestCount === 2) {
|
|
||||||
return secondResponse.promise;
|
|
||||||
}
|
|
||||||
throw new Error(`Unexpected request: ${String(input)}`);
|
|
||||||
};
|
|
||||||
const client = createFlueClient({
|
|
||||||
baseUrl: BASE_URL.toString(),
|
|
||||||
fetch: createFlueFetch({
|
|
||||||
baseUrl: BASE_URL,
|
|
||||||
fetchImpl,
|
|
||||||
generateRequestId: () => `request-${(requestId += 1)}`,
|
|
||||||
}),
|
|
||||||
headers: { authorization: "Bearer current-jwt" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstSend = client.agents.send("zopu", "org-a", {
|
|
||||||
message: "first",
|
|
||||||
});
|
|
||||||
const secondSend = client.agents.send("zopu", "org-a", {
|
|
||||||
message: "second",
|
|
||||||
});
|
|
||||||
await bothStarted.promise;
|
|
||||||
|
|
||||||
expect(capturedHeaders).toHaveLength(2);
|
|
||||||
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
|
|
||||||
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
|
|
||||||
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
|
|
||||||
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
|
|
||||||
|
|
||||||
firstResponse.resolve(
|
|
||||||
Response.json(
|
|
||||||
{
|
|
||||||
offset: "1",
|
|
||||||
streamUrl: "http://internal/streams/first",
|
|
||||||
submissionId: "submission-1",
|
|
||||||
},
|
|
||||||
{ status: 202 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
secondResponse.resolve(
|
|
||||||
Response.json(
|
|
||||||
{
|
|
||||||
offset: "2",
|
|
||||||
streamUrl: "http://internal/streams/second",
|
|
||||||
submissionId: "submission-2",
|
|
||||||
},
|
|
||||||
{ status: 202 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
|
|
||||||
{
|
|
||||||
offset: "1",
|
|
||||||
streamUrl: "https://flue.example/streams/first",
|
|
||||||
submissionId: "submission-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: "2",
|
|
||||||
streamUrl: "https://flue.example/streams/second",
|
|
||||||
submissionId: "submission-2",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("history and stream observation requests remain untagged", async () => {
|
|
||||||
const capturedHeaders: Headers[] = [];
|
|
||||||
const fetchImpl: typeof fetch = (_input, init) => {
|
|
||||||
capturedHeaders.push(new Headers(init?.headers));
|
|
||||||
return Promise.resolve(new Response(null, { status: 204 }));
|
|
||||||
};
|
|
||||||
const flueFetch = createFlueFetch({
|
|
||||||
baseUrl: BASE_URL,
|
|
||||||
fetchImpl,
|
|
||||||
generateRequestId: () => "must-not-be-used",
|
|
||||||
});
|
|
||||||
|
|
||||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
|
|
||||||
headers: { authorization: "Bearer current-jwt" },
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
|
|
||||||
headers: { authorization: "Bearer current-jwt" },
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(capturedHeaders).toHaveLength(2);
|
|
||||||
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
|
|
||||||
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
interface FlueFetchOptions {
|
|
||||||
readonly baseUrl: URL;
|
|
||||||
readonly fetchImpl?: typeof fetch;
|
|
||||||
readonly generateRequestId?: () => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Flue transport used by the browser client.
|
|
||||||
*
|
|
||||||
* The installed Flue SDK does not expose per-call headers on `agents.send`.
|
|
||||||
* Its custom `fetch` seam does receive the resolved URL and method, so request
|
|
||||||
* IDs are attached here to each agent admission POST. History and Durable
|
|
||||||
* Streams observation requests are GETs and intentionally remain untagged.
|
|
||||||
*/
|
|
||||||
export const createFlueFetch = ({
|
|
||||||
baseUrl,
|
|
||||||
fetchImpl = fetch,
|
|
||||||
generateRequestId = crypto.randomUUID,
|
|
||||||
}: FlueFetchOptions): typeof fetch => {
|
|
||||||
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
|
|
||||||
|
|
||||||
return async (input, init) => {
|
|
||||||
const inputUrl =
|
|
||||||
typeof input === "string" || input instanceof URL ? input : input.url;
|
|
||||||
const requestUrl = new URL(inputUrl, baseUrl);
|
|
||||||
const method = (
|
|
||||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
|
||||||
).toUpperCase();
|
|
||||||
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
|
|
||||||
? requestUrl.pathname.slice(basePath.length)
|
|
||||||
: requestUrl.pathname;
|
|
||||||
const pathSegments = relativePath.split("/").filter(Boolean);
|
|
||||||
const isAgentAdmission =
|
|
||||||
method === "POST" &&
|
|
||||||
pathSegments.length === 3 &&
|
|
||||||
pathSegments[0] === "agents";
|
|
||||||
|
|
||||||
let requestInit = init;
|
|
||||||
if (isAgentAdmission) {
|
|
||||||
const headers = new Headers(
|
|
||||||
input instanceof Request ? input.headers : undefined
|
|
||||||
);
|
|
||||||
for (const [key, value] of new Headers(init?.headers).entries()) {
|
|
||||||
headers.set(key, value);
|
|
||||||
}
|
|
||||||
headers.set("x-zopu-request-id", generateRequestId());
|
|
||||||
requestInit = { ...init, headers };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetchImpl(input, requestInit);
|
|
||||||
if (
|
|
||||||
!response.ok ||
|
|
||||||
!response.headers.get("content-type")?.includes("application/json")
|
|
||||||
) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.clone().json()) as unknown;
|
|
||||||
if (
|
|
||||||
typeof body !== "object" ||
|
|
||||||
body === null ||
|
|
||||||
!("streamUrl" in body) ||
|
|
||||||
typeof body.streamUrl !== "string"
|
|
||||||
) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamUrl = new URL(body.streamUrl);
|
|
||||||
streamUrl.protocol = baseUrl.protocol;
|
|
||||||
streamUrl.host = baseUrl.host;
|
|
||||||
const headers = new Headers(response.headers);
|
|
||||||
headers.set("location", streamUrl.toString());
|
|
||||||
return Response.json(
|
|
||||||
{ ...body, streamUrl: streamUrl.toString() },
|
|
||||||
{
|
|
||||||
headers,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import type { api } from "@code/backend/convex/_generated/api";
|
|
||||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import {
|
|
||||||
getProjectWorkNextAction,
|
|
||||||
getProjectWorkProgress,
|
|
||||||
getProjectWorkStatus,
|
|
||||||
} from "@code/primitives/project-work";
|
|
||||||
import type {
|
|
||||||
MobileAssistantView,
|
|
||||||
MobileProjectView,
|
|
||||||
MobileSignalView,
|
|
||||||
MobileWorkspaceView,
|
|
||||||
MobileWorkUnitTone,
|
|
||||||
MobileWorkUnitView,
|
|
||||||
} from "@code/ui/components/mobile-product";
|
|
||||||
import type { FunctionReturnType } from "convex/server";
|
|
||||||
|
|
||||||
type SignalList = FunctionReturnType<typeof api.signals.list>;
|
|
||||||
type ProjectEventList = FunctionReturnType<typeof api.projectIssues.events>;
|
|
||||||
type ProjectView = FunctionReturnType<typeof api.projects.list>[number];
|
|
||||||
|
|
||||||
interface BuildMobileWorkspaceViewInput {
|
|
||||||
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
|
|
||||||
readonly assistant: MobileAssistantView;
|
|
||||||
readonly events: ProjectEventList | undefined;
|
|
||||||
readonly issues: readonly Doc<"projectIssues">[] | undefined;
|
|
||||||
readonly organizationLabel?: string;
|
|
||||||
readonly projects: readonly ProjectView[] | undefined;
|
|
||||||
readonly selectedProject: ProjectView | null;
|
|
||||||
readonly selectedWorkUnitId?: string;
|
|
||||||
readonly signals: SignalList | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedDateFormatter = new Intl.DateTimeFormat("en", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusTone = (
|
|
||||||
status: ReturnType<typeof getProjectWorkStatus>
|
|
||||||
): MobileWorkUnitTone => {
|
|
||||||
if (status.phase === "completed") {
|
|
||||||
return "green";
|
|
||||||
}
|
|
||||||
if (status.phase === "failed" || status.phase === "waiting") {
|
|
||||||
return "orange";
|
|
||||||
}
|
|
||||||
if (status.phase === "captured") {
|
|
||||||
return "purple";
|
|
||||||
}
|
|
||||||
return "blue";
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPullRequestData = (
|
|
||||||
value: unknown
|
|
||||||
): value is {
|
|
||||||
pullRequest: {
|
|
||||||
number: number;
|
|
||||||
status: "open" | "closed" | "merged";
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
} =>
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"pullRequest" in value &&
|
|
||||||
typeof value.pullRequest === "object" &&
|
|
||||||
value.pullRequest !== null &&
|
|
||||||
"number" in value.pullRequest &&
|
|
||||||
typeof value.pullRequest.number === "number" &&
|
|
||||||
"url" in value.pullRequest &&
|
|
||||||
typeof value.pullRequest.url === "string";
|
|
||||||
|
|
||||||
const toWorkUnit = (
|
|
||||||
issue: Doc<"projectIssues">,
|
|
||||||
artifactCount: number,
|
|
||||||
events: ProjectEventList | undefined
|
|
||||||
): MobileWorkUnitView => {
|
|
||||||
const status = getProjectWorkStatus(issue.status);
|
|
||||||
const pullRequest = events
|
|
||||||
?.filter((event) => event.issueId === issue._id)
|
|
||||||
.map((event) => event.data)
|
|
||||||
.find(isPullRequestData)?.pullRequest;
|
|
||||||
return {
|
|
||||||
artifactCount,
|
|
||||||
canRetry: status.phase === "failed" || status.phase === "waiting",
|
|
||||||
canStart: status.phase === "captured",
|
|
||||||
code: `#${issue.number}`,
|
|
||||||
id: issue._id,
|
|
||||||
nextAction: getProjectWorkNextAction(issue.status),
|
|
||||||
progress: getProjectWorkProgress(issue.status),
|
|
||||||
pullRequestNumber: pullRequest?.number,
|
|
||||||
reviewUrl: pullRequest?.url,
|
|
||||||
statusLabel: status.label,
|
|
||||||
summary: issue.body,
|
|
||||||
title: issue.title,
|
|
||||||
tone: statusTone(status),
|
|
||||||
updatedLabel: updatedDateFormatter.format(issue.updatedAt),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toLatestSignalView = (
|
|
||||||
signal: SignalList[number]["signal"] | undefined
|
|
||||||
): MobileSignalView | undefined => {
|
|
||||||
if (!signal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
desiredOutcome: signal.problemStatement.desiredOutcome,
|
|
||||||
id: signal._id,
|
|
||||||
projectId: signal.projectId ?? undefined,
|
|
||||||
summary: signal.problemStatement.summary,
|
|
||||||
title: signal.problemStatement.title,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const relevantSignalsForProject = (
|
|
||||||
signals: SignalList | undefined,
|
|
||||||
projectId: ProjectView["id"] | undefined
|
|
||||||
) =>
|
|
||||||
signals?.filter(
|
|
||||||
({ signal }) =>
|
|
||||||
!signal.projectId || String(signal.projectId) === String(projectId)
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const makeProjectViews = (
|
|
||||||
projects: readonly ProjectView[] | undefined
|
|
||||||
): readonly MobileProjectView[] =>
|
|
||||||
projects?.map((project) => ({
|
|
||||||
connected: project.sources.length > 0,
|
|
||||||
id: project.id,
|
|
||||||
name: project.name,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
export const buildMobileWorkspaceView = ({
|
|
||||||
artifacts,
|
|
||||||
assistant,
|
|
||||||
events,
|
|
||||||
issues,
|
|
||||||
organizationLabel = "Personal workspace",
|
|
||||||
projects,
|
|
||||||
selectedProject,
|
|
||||||
selectedWorkUnitId,
|
|
||||||
signals,
|
|
||||||
}: BuildMobileWorkspaceViewInput): MobileWorkspaceView => {
|
|
||||||
const artifactCount = artifacts?.length ?? 0;
|
|
||||||
const workUnits =
|
|
||||||
issues?.map((issue) => toWorkUnit(issue, artifactCount, events)) ?? [];
|
|
||||||
const selectedWorkUnit =
|
|
||||||
workUnits.find((workUnit) => workUnit.id === selectedWorkUnitId) ??
|
|
||||||
workUnits.find((workUnit) => workUnit.tone === "blue") ??
|
|
||||||
workUnits[0];
|
|
||||||
const selectedProjectId = selectedProject?.id;
|
|
||||||
const relevantSignals = relevantSignalsForProject(signals, selectedProjectId);
|
|
||||||
const latestSignal = relevantSignals[0]?.signal;
|
|
||||||
const activeCount = workUnits.filter(
|
|
||||||
(workUnit) => workUnit.progress < 100
|
|
||||||
).length;
|
|
||||||
const needsAttentionCount = issues?.filter(
|
|
||||||
(issue) => issue.status === "failed" || issue.status === "needs-input"
|
|
||||||
).length;
|
|
||||||
const shippedCount = issues?.filter(
|
|
||||||
(issue) => issue.status === "completed"
|
|
||||||
).length;
|
|
||||||
const hasSelectedProject = Boolean(selectedProject);
|
|
||||||
const projectViews = makeProjectViews(projects);
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeCount,
|
|
||||||
activityCount: workUnits.length + relevantSignals.length,
|
|
||||||
artifactCount,
|
|
||||||
assistant,
|
|
||||||
isLoading:
|
|
||||||
projects === undefined ||
|
|
||||||
(hasSelectedProject && (artifacts === undefined || issues === undefined)),
|
|
||||||
latestSignal: toLatestSignalView(latestSignal),
|
|
||||||
needsAttentionCount: needsAttentionCount ?? 0,
|
|
||||||
organizationLabel,
|
|
||||||
projectName: selectedProject?.name,
|
|
||||||
projects: projectViews,
|
|
||||||
selectedProjectId: selectedProject?.id,
|
|
||||||
selectedWorkUnit,
|
|
||||||
shippedCount: shippedCount ?? 0,
|
|
||||||
totalCount: workUnits.length,
|
|
||||||
workUnits,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildProjectLoopView,
|
|
||||||
summarizeProjectIssues,
|
|
||||||
} from "./project-evidence";
|
|
||||||
|
|
||||||
const makeIssue = (
|
|
||||||
status: Doc<"projectIssues">["status"],
|
|
||||||
issueId: Id<"projectIssues"> = "issue-1" as Id<"projectIssues">
|
|
||||||
): Doc<"projectIssues"> =>
|
|
||||||
({
|
|
||||||
_creationTime: 1,
|
|
||||||
_id: issueId,
|
|
||||||
body: "Make the project loop visible in the web app.",
|
|
||||||
createdAt: 1000,
|
|
||||||
number: 8,
|
|
||||||
projectId: "project-1",
|
|
||||||
status,
|
|
||||||
title: "Build project loop UI",
|
|
||||||
updatedAt: 2000,
|
|
||||||
}) as Doc<"projectIssues">;
|
|
||||||
|
|
||||||
const makeArtifact = (content: string): Doc<"projectArtifacts"> =>
|
|
||||||
({
|
|
||||||
_creationTime: 1,
|
|
||||||
_id: "artifact-1",
|
|
||||||
content,
|
|
||||||
createdAt: 1000,
|
|
||||||
path: "artifacts.md",
|
|
||||||
projectId: "project-1",
|
|
||||||
revision: 2,
|
|
||||||
updatedAt: 2000,
|
|
||||||
}) as Doc<"projectArtifacts">;
|
|
||||||
|
|
||||||
describe("project evidence", () => {
|
|
||||||
test("turns published PR evidence into a review action", () => {
|
|
||||||
const view = buildProjectLoopView({
|
|
||||||
artifacts: [
|
|
||||||
makeArtifact(
|
|
||||||
"Verification: 14 unit tests passed.\nPull request: PR #42"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
issue: makeIssue("completed"),
|
|
||||||
source: {
|
|
||||||
host: "git.example.com",
|
|
||||||
repositoryPath: "puter/zopu",
|
|
||||||
url: "https://git.example.com/puter/zopu",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(view?.progress).toBe(100);
|
|
||||||
expect(view?.verification).toBe("passed");
|
|
||||||
expect(view?.pullRequest.reviewUrl).toBe(
|
|
||||||
"https://git.example.com/puter/zopu/pulls/42"
|
|
||||||
);
|
|
||||||
expect(view?.verificationNote).toContain(
|
|
||||||
"Verification: 14 unit tests passed."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps the review slot pending when no PR is published", () => {
|
|
||||||
const view = buildProjectLoopView({
|
|
||||||
artifacts: [
|
|
||||||
makeArtifact("The run is recording implementation evidence."),
|
|
||||||
],
|
|
||||||
issue: makeIssue("working"),
|
|
||||||
source: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(view?.verification).toBe("running");
|
|
||||||
expect(view?.pullRequest.state).toBe("pending");
|
|
||||||
expect(view?.pullRequest.reviewUrl).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("summarizes the project queue without inventing work", () => {
|
|
||||||
const summary = summarizeProjectIssues([
|
|
||||||
makeIssue("working"),
|
|
||||||
makeIssue("needs-input", "issue-2" as Id<"projectIssues">),
|
|
||||||
makeIssue("completed", "issue-3" as Id<"projectIssues">),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(summary).toEqual({
|
|
||||||
active: 2,
|
|
||||||
completed: 1,
|
|
||||||
needsInput: 1,
|
|
||||||
total: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import type { Doc } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import type { ProjectIssueStatus } from "@code/primitives/project-issue";
|
|
||||||
import {
|
|
||||||
getProjectWorkNextAction,
|
|
||||||
getProjectWorkProgress,
|
|
||||||
getProjectWorkStatus,
|
|
||||||
} from "@code/primitives/project-work";
|
|
||||||
import type { ProjectVerificationStatus } from "@code/primitives/project-work";
|
|
||||||
|
|
||||||
type ProjectIssue = Doc<"projectIssues">;
|
|
||||||
type ProjectArtifact = Doc<"projectArtifacts">;
|
|
||||||
|
|
||||||
export interface ProjectSourceSummary {
|
|
||||||
readonly host: string;
|
|
||||||
readonly repositoryPath: string;
|
|
||||||
readonly url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectIssueSummary {
|
|
||||||
readonly active: number;
|
|
||||||
readonly completed: number;
|
|
||||||
readonly needsInput: number;
|
|
||||||
readonly total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectVerificationCheck {
|
|
||||||
readonly detail: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly state: "attention" | "complete" | "current" | "upcoming";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectActivityItem {
|
|
||||||
readonly detail: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly time: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectPullRequest {
|
|
||||||
readonly number: number | undefined;
|
|
||||||
readonly reviewUrl: string | undefined;
|
|
||||||
readonly state: "available" | "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectLoopView {
|
|
||||||
readonly activity: readonly ProjectActivityItem[];
|
|
||||||
readonly currentState: string;
|
|
||||||
readonly nextAction: string;
|
|
||||||
readonly progress: number;
|
|
||||||
readonly pullRequest: ProjectPullRequest;
|
|
||||||
readonly status: ReturnType<typeof getProjectWorkStatus>;
|
|
||||||
readonly verification: ProjectVerificationStatus;
|
|
||||||
readonly verificationChecks: readonly ProjectVerificationCheck[];
|
|
||||||
readonly verificationNote: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_TEXT: Readonly<Record<ProjectIssueStatus, string>> = {
|
|
||||||
completed:
|
|
||||||
"Implementation and verification are complete. Review the resulting change.",
|
|
||||||
failed:
|
|
||||||
"The run stopped with its workspace preserved. Review the failure and retry when ready.",
|
|
||||||
"needs-input":
|
|
||||||
"The run is paused until a project decision or missing piece is resolved.",
|
|
||||||
open: "The issue is ready to start. Zopu will keep the project context attached to the run.",
|
|
||||||
queued: "The issue is queued for the project manager to pick up.",
|
|
||||||
working:
|
|
||||||
"The project manager is working through the issue and recording durable evidence.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (timestamp: number): string =>
|
|
||||||
new Intl.DateTimeFormat(undefined, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(timestamp);
|
|
||||||
|
|
||||||
const relativeTime = (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`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const allArtifactText = (artifacts: readonly ProjectArtifact[]): string =>
|
|
||||||
artifacts.map((artifact) => artifact.content).join("\n");
|
|
||||||
|
|
||||||
const extractPullRequest = (
|
|
||||||
artifacts: readonly ProjectArtifact[],
|
|
||||||
source: ProjectSourceSummary | undefined
|
|
||||||
): ProjectPullRequest => {
|
|
||||||
const text = allArtifactText(artifacts);
|
|
||||||
const urlMatch = text.match(/https?:\/\/[^\s)]+\/pulls\/\d+/iu)?.[0];
|
|
||||||
const numberMatch = text.match(/\bPR\s*#(?<number>\d+)\b/iu);
|
|
||||||
const numberValue = numberMatch?.groups?.number;
|
|
||||||
const number = numberValue ? Number(numberValue) : undefined;
|
|
||||||
let reviewUrl = urlMatch;
|
|
||||||
if (!reviewUrl && number && source) {
|
|
||||||
reviewUrl = `${source.url.replace(/\/$/u, "")}/pulls/${number}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
number,
|
|
||||||
reviewUrl,
|
|
||||||
state: reviewUrl ? "available" : "pending",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractVerificationNote = (
|
|
||||||
artifacts: readonly ProjectArtifact[],
|
|
||||||
status: ProjectIssueStatus
|
|
||||||
): string => {
|
|
||||||
const evidenceLine = allArtifactText(artifacts)
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find((line) => /verification|tests?|browser|passed|failed/iu.test(line));
|
|
||||||
|
|
||||||
if (evidenceLine) {
|
|
||||||
return evidenceLine.replace(/^[-*#\s]+/u, "").slice(0, 140);
|
|
||||||
}
|
|
||||||
if (status === "completed") {
|
|
||||||
return "The agent reported completion after its verification step.";
|
|
||||||
}
|
|
||||||
if (status === "failed") {
|
|
||||||
return "Verification did not complete successfully.";
|
|
||||||
}
|
|
||||||
if (status === "working") {
|
|
||||||
return "Verification will appear here as the run records evidence.";
|
|
||||||
}
|
|
||||||
return "No verification evidence has been recorded yet.";
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeRunCheck = (status: ProjectIssueStatus): ProjectVerificationCheck => {
|
|
||||||
if (status === "queued") {
|
|
||||||
return {
|
|
||||||
detail: "Agent admission is queued",
|
|
||||||
label: "Agent run",
|
|
||||||
state: "current",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (status === "open") {
|
|
||||||
return {
|
|
||||||
detail: "Start the issue to create a run",
|
|
||||||
label: "Agent run",
|
|
||||||
state: "upcoming",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
detail: "Project manager run is attached",
|
|
||||||
label: "Agent run",
|
|
||||||
state: "complete",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeVerificationCheck = (
|
|
||||||
verification: ProjectVerificationStatus
|
|
||||||
): ProjectVerificationCheck => {
|
|
||||||
if (verification === "passed") {
|
|
||||||
return {
|
|
||||||
detail: "Verification passed",
|
|
||||||
label: "Verification",
|
|
||||||
state: "complete",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (verification === "failed") {
|
|
||||||
return {
|
|
||||||
detail: "Review the preserved failure",
|
|
||||||
label: "Verification",
|
|
||||||
state: "attention",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (verification === "blocked") {
|
|
||||||
return {
|
|
||||||
detail: "Waiting on a project decision",
|
|
||||||
label: "Verification",
|
|
||||||
state: "attention",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (verification === "running") {
|
|
||||||
return {
|
|
||||||
detail: "Evidence is being recorded",
|
|
||||||
label: "Verification",
|
|
||||||
state: "current",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
detail: "No verification result yet",
|
|
||||||
label: "Verification",
|
|
||||||
state: "upcoming",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeChecks = (
|
|
||||||
status: ProjectIssueStatus,
|
|
||||||
verification: ProjectVerificationStatus,
|
|
||||||
pullRequest: ProjectPullRequest
|
|
||||||
): readonly ProjectVerificationCheck[] => {
|
|
||||||
const issueCheck: ProjectVerificationCheck =
|
|
||||||
status === "open"
|
|
||||||
? {
|
|
||||||
detail: "Waiting to be started",
|
|
||||||
label: "Issue accepted",
|
|
||||||
state: "upcoming",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
detail: "Issue context is attached",
|
|
||||||
label: "Issue accepted",
|
|
||||||
state: "complete",
|
|
||||||
};
|
|
||||||
const pullRequestCheck: ProjectVerificationCheck =
|
|
||||||
pullRequest.state === "available"
|
|
||||||
? {
|
|
||||||
detail: "Ready for human review",
|
|
||||||
label: "Gitea pull request",
|
|
||||||
state: "complete",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
detail: "Appears after a verified run publishes one",
|
|
||||||
label: "Gitea pull request",
|
|
||||||
state: "upcoming",
|
|
||||||
};
|
|
||||||
return [
|
|
||||||
issueCheck,
|
|
||||||
makeRunCheck(status),
|
|
||||||
makeVerificationCheck(verification),
|
|
||||||
pullRequestCheck,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildActivity = (
|
|
||||||
issue: ProjectIssue,
|
|
||||||
artifacts: readonly ProjectArtifact[],
|
|
||||||
statusLabel: string
|
|
||||||
): readonly ProjectActivityItem[] => {
|
|
||||||
const artifact = [...artifacts]
|
|
||||||
.toSorted((left, right) => right.updatedAt - left.updatedAt)
|
|
||||||
.find((candidate) => candidate.path !== "work.md");
|
|
||||||
const items: ProjectActivityItem[] = [
|
|
||||||
{
|
|
||||||
detail: `Issue #${issue.number} entered the project loop`,
|
|
||||||
label: "Issue created",
|
|
||||||
time: formatTime(issue.createdAt),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (issue.updatedAt !== issue.createdAt) {
|
|
||||||
items.unshift({
|
|
||||||
detail: `Status is now ${statusLabel.toLowerCase()}`,
|
|
||||||
label: "Work state updated",
|
|
||||||
time: relativeTime(issue.updatedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artifact) {
|
|
||||||
items.unshift({
|
|
||||||
detail: `Durable project evidence changed in ${artifact.path}`,
|
|
||||||
label: "Evidence recorded",
|
|
||||||
time: relativeTime(artifact.updatedAt),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, 3);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const summarizeProjectIssues = (
|
|
||||||
issues: readonly ProjectIssue[] | undefined
|
|
||||||
): ProjectIssueSummary => {
|
|
||||||
if (!issues) {
|
|
||||||
return { active: 0, completed: 0, needsInput: 0, total: 0 };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
active: issues.filter(
|
|
||||||
(issue) => !["completed", "failed"].includes(issue.status)
|
|
||||||
).length,
|
|
||||||
completed: issues.filter((issue) => issue.status === "completed").length,
|
|
||||||
needsInput: issues.filter((issue) => issue.status === "needs-input").length,
|
|
||||||
total: issues.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildProjectLoopView = ({
|
|
||||||
artifacts,
|
|
||||||
issue,
|
|
||||||
source,
|
|
||||||
}: {
|
|
||||||
readonly artifacts: readonly ProjectArtifact[] | undefined;
|
|
||||||
readonly issue: ProjectIssue | undefined;
|
|
||||||
readonly source: ProjectSourceSummary | undefined;
|
|
||||||
}): ProjectLoopView | null => {
|
|
||||||
if (!issue) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = getProjectWorkStatus(issue.status);
|
|
||||||
const { verification } = status;
|
|
||||||
const resolvedArtifacts = artifacts ?? [];
|
|
||||||
const pullRequest = extractPullRequest(resolvedArtifacts, source);
|
|
||||||
|
|
||||||
return {
|
|
||||||
activity: buildActivity(issue, resolvedArtifacts, status.label),
|
|
||||||
currentState: STATUS_TEXT[issue.status],
|
|
||||||
nextAction: getProjectWorkNextAction(issue.status),
|
|
||||||
progress: getProjectWorkProgress(issue.status),
|
|
||||||
pullRequest,
|
|
||||||
status,
|
|
||||||
verification,
|
|
||||||
verificationChecks: makeChecks(issue.status, verification, pullRequest),
|
|
||||||
verificationNote: extractVerificationNote(resolvedArtifacts, issue.status),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { getUserInitials } from "./get-user-initials";
|
||||||
|
|
||||||
|
describe("getUserInitials", () => {
|
||||||
|
it("uses the first two name parts", () => {
|
||||||
|
expect(getUserInitials("Sai Karthik")).toBe("SK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a neutral fallback while auth loads", () => {
|
||||||
|
expect(getUserInitials()).toBe("U");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const getUserInitials = (name?: string): string => {
|
||||||
|
const parts = name?.trim().split(/\s+/u).filter(Boolean) ?? [];
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
const source = (relativePath: string) =>
|
||||||
|
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
|
||||||
|
|
||||||
|
describe("Workspace frontend regression contracts", () => {
|
||||||
|
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
|
||||||
|
const page = source(
|
||||||
|
"../../components/workspace/project-workspace-page.tsx"
|
||||||
|
);
|
||||||
|
const viewport = source("../../hooks/workspace/use-visual-viewport.ts");
|
||||||
|
const root = source("../../root.tsx");
|
||||||
|
const styles = source("../../index.css");
|
||||||
|
|
||||||
|
expect(root).toContain("interactive-widget=resizes-content");
|
||||||
|
expect(page).toContain("style={viewportStyle}");
|
||||||
|
expect(page).toContain("fixed inset-x-0 top-0");
|
||||||
|
expect(viewport).toContain("workspace-viewport-lock");
|
||||||
|
expect(styles).toContain("html.workspace-viewport-lock body");
|
||||||
|
expect(styles).toContain("overflow: hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the responsive shell shrinkable with a pinned composer", () => {
|
||||||
|
const page = source(
|
||||||
|
"../../components/workspace/project-workspace-page.tsx"
|
||||||
|
);
|
||||||
|
const feed = source("../../components/workspace/conversation-feed.tsx");
|
||||||
|
const composer = source(
|
||||||
|
"../../components/workspace/conversation-composer.tsx"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
|
||||||
|
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
|
||||||
|
expect(composer).toContain('className="shrink-0 border-t');
|
||||||
|
expect(composer).toContain(
|
||||||
|
'className="mx-auto flex max-w-2xl items-end gap-2"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps attachment strips contained and markdown capabilities enabled", () => {
|
||||||
|
const attachment = source("../../components/chat/chat-attachments.tsx");
|
||||||
|
const attachmentPrimitive = source(
|
||||||
|
"../../../../../packages/ui/src/components/attachment.tsx"
|
||||||
|
);
|
||||||
|
const chatMessage = source("../../components/chat/chat-message.tsx");
|
||||||
|
const messageRenderer = source(
|
||||||
|
"../../../../../packages/ui/src/components/ai-elements/message.tsx"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(attachment).toContain('className="max-w-full');
|
||||||
|
expect(attachment).toContain("useAuthenticatedImageSource");
|
||||||
|
expect(attachment).toContain(`authorization: \`Bearer \${accessToken}\``);
|
||||||
|
expect(attachmentPrimitive).toContain("overflow-x-auto");
|
||||||
|
expect(chatMessage).toContain("<MessageAttachments parts={fileParts} />");
|
||||||
|
expect(chatMessage).toContain("<MessageResponse");
|
||||||
|
expect(messageRenderer).toContain("const streamdownPlugins = {");
|
||||||
|
expect(messageRenderer).toContain("mermaid");
|
||||||
|
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps markdown readable on the forced-dark workspace surface", () => {
|
||||||
|
const styles = source("../../index.css");
|
||||||
|
|
||||||
|
// The workspace surface is light, but the app forces the dark theme, so
|
||||||
|
// the markdown context must re-declare the theme tokens streamdown and the
|
||||||
|
// table/code rules read from. Without these the dark tokens (~22-28%
|
||||||
|
// lightness) produce near-black text, borders, and backgrounds.
|
||||||
|
expect(styles).toContain(".workspace-surface .chat-markdown");
|
||||||
|
expect(styles).toContain("--muted: #f4f4f2");
|
||||||
|
expect(styles).toContain("--border: #e2e0d6");
|
||||||
|
expect(styles).toContain("--sidebar: #ffffff");
|
||||||
|
// Table headers and zebra rows must stand out on the surface.
|
||||||
|
expect(styles).toContain(".chat-markdown thead th");
|
||||||
|
expect(styles).toContain(".chat-markdown tbody tr:nth-child(even)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { WorkNotice } from "@code/primitives/work";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import type { ConversationMessage } from "@/lib/chat/types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkspaceTimeline,
|
||||||
|
findSourceMessageTarget,
|
||||||
|
} from "./presentation";
|
||||||
|
|
||||||
|
const textMessage = (
|
||||||
|
id: string,
|
||||||
|
role: "assistant" | "user",
|
||||||
|
text: string
|
||||||
|
): ConversationMessage => ({
|
||||||
|
id,
|
||||||
|
parts: [{ state: "done", text, type: "text" }],
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notice: WorkNotice = {
|
||||||
|
createdAt: 10,
|
||||||
|
eventId: "event-1",
|
||||||
|
sourceMessageIds: ["source-1"],
|
||||||
|
sourceTexts: ["Build the phone flow."],
|
||||||
|
workId: "work-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Workspace presentation", () => {
|
||||||
|
test("places proposed Work after the assistant response to its source", () => {
|
||||||
|
const timeline = buildWorkspaceTimeline(
|
||||||
|
[
|
||||||
|
textMessage("user-1", "user", "Build the phone flow."),
|
||||||
|
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
|
||||||
|
],
|
||||||
|
[notice]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
timeline.map((item) =>
|
||||||
|
item.kind === "message" ? item.message.id : item.notice.workId
|
||||||
|
)
|
||||||
|
).toEqual(["user-1", "assistant-1", "work-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds the exact source message without trimming it", () => {
|
||||||
|
const messages = [textMessage("user-1", "user", " Exact source ")];
|
||||||
|
expect(findSourceMessageTarget(messages, " Exact source ")).toBe(
|
||||||
|
"user-1"
|
||||||
|
);
|
||||||
|
expect(findSourceMessageTarget(messages, "Exact source")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { WorkNotice } from "@code/primitives/work";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getMessageText,
|
||||||
|
getRawMessageText,
|
||||||
|
getReasoningText,
|
||||||
|
} from "@/lib/chat/transforms";
|
||||||
|
import type { ConversationMessage } from "@/lib/chat/types";
|
||||||
|
|
||||||
|
import type { WorkspaceTimelineItem } from "./types";
|
||||||
|
|
||||||
|
export const isVisibleConversationMessage = (
|
||||||
|
message: ConversationMessage
|
||||||
|
): boolean =>
|
||||||
|
message.role === "user" ||
|
||||||
|
getMessageText(message).length > 0 ||
|
||||||
|
getReasoningText(message).length > 0;
|
||||||
|
|
||||||
|
const targetIndexForNotice = (
|
||||||
|
messages: readonly ConversationMessage[],
|
||||||
|
notice: WorkNotice
|
||||||
|
): number => {
|
||||||
|
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
|
||||||
|
const index = messages.findIndex(
|
||||||
|
(message) =>
|
||||||
|
message.role === "user" && getRawMessageText(message) === sourceText
|
||||||
|
);
|
||||||
|
return index === -1 ? [] : [index];
|
||||||
|
});
|
||||||
|
if (sourceIndexes.length === 0) {
|
||||||
|
return messages.length - 1;
|
||||||
|
}
|
||||||
|
const sourceIndex = Math.max(...sourceIndexes);
|
||||||
|
const responseOffset = messages
|
||||||
|
.slice(sourceIndex + 1)
|
||||||
|
.findIndex((message) => message.role === "assistant");
|
||||||
|
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildWorkspaceTimeline = (
|
||||||
|
allMessages: readonly ConversationMessage[],
|
||||||
|
notices: readonly WorkNotice[]
|
||||||
|
): readonly WorkspaceTimelineItem[] => {
|
||||||
|
const messages = allMessages.filter(isVisibleConversationMessage);
|
||||||
|
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
|
||||||
|
for (const notice of notices) {
|
||||||
|
const targetIndex = targetIndexForNotice(messages, notice);
|
||||||
|
const atTarget = noticesByMessageIndex.get(targetIndex) ?? [];
|
||||||
|
atTarget.push(notice);
|
||||||
|
noticesByMessageIndex.set(targetIndex, atTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline: WorkspaceTimelineItem[] = [];
|
||||||
|
for (const [index, message] of messages.entries()) {
|
||||||
|
timeline.push({ kind: "message", message });
|
||||||
|
for (const notice of noticesByMessageIndex.get(index) ?? []) {
|
||||||
|
timeline.push({ kind: "work", notice });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (messages.length === 0) {
|
||||||
|
for (const notice of noticesByMessageIndex.get(-1) ?? []) {
|
||||||
|
timeline.push({ kind: "work", notice });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return timeline;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findSourceMessageTarget = (
|
||||||
|
messages: readonly ConversationMessage[],
|
||||||
|
sourceText: string
|
||||||
|
): string | undefined =>
|
||||||
|
messages.find(
|
||||||
|
(message) =>
|
||||||
|
message.role === "user" && getRawMessageText(message) === sourceText
|
||||||
|
)?.id;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import type { WorkNotice } from "@code/primitives/work";
|
||||||
|
|
||||||
|
import type { ConversationMessage } from "@/lib/chat/types";
|
||||||
|
|
||||||
|
export interface WorkRecord {
|
||||||
|
readonly _id: Id<"works">;
|
||||||
|
readonly title: string;
|
||||||
|
readonly objective: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly definitionVersion?: number;
|
||||||
|
readonly designVersion?: number;
|
||||||
|
readonly signals: readonly {
|
||||||
|
readonly sources: readonly { messageId: string; rawText: string }[];
|
||||||
|
}[];
|
||||||
|
readonly runs: readonly WorkRun[];
|
||||||
|
readonly definition: {
|
||||||
|
readonly risk?: string;
|
||||||
|
readonly questions?: readonly { status: string }[];
|
||||||
|
} | null;
|
||||||
|
readonly design: {
|
||||||
|
readonly architectureSummary?: string;
|
||||||
|
readonly slices?: readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly observableBehavior: string;
|
||||||
|
}[];
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkRun {
|
||||||
|
readonly artifacts?: readonly WorkArtifact[];
|
||||||
|
readonly attemptEvents?: readonly WorkAttemptEvent[];
|
||||||
|
readonly baseRevision?: string;
|
||||||
|
readonly candidateRevision?: string;
|
||||||
|
readonly _id: Id<"workRuns">;
|
||||||
|
readonly status: string;
|
||||||
|
readonly terminalClassification?: string;
|
||||||
|
readonly terminalSummary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkArtifact {
|
||||||
|
readonly _id: string;
|
||||||
|
readonly metadataJson: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly uri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkAttemptEvent {
|
||||||
|
readonly _id: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitConnection {
|
||||||
|
readonly id: string;
|
||||||
|
readonly provider: "github" | "gitea";
|
||||||
|
readonly serverUrl: string;
|
||||||
|
readonly username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListItem {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceState {
|
||||||
|
readonly agent: {
|
||||||
|
readonly error?: Error;
|
||||||
|
readonly historyReady: boolean;
|
||||||
|
readonly messages: readonly ConversationMessage[];
|
||||||
|
readonly sendMessage: (
|
||||||
|
message: string,
|
||||||
|
options?: { readonly images?: readonly File[] }
|
||||||
|
) => Promise<void>;
|
||||||
|
readonly status:
|
||||||
|
| "connecting"
|
||||||
|
| "error"
|
||||||
|
| "idle"
|
||||||
|
| "streaming"
|
||||||
|
| "submitted";
|
||||||
|
};
|
||||||
|
readonly authorizeGithub: () => Promise<unknown>;
|
||||||
|
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||||
|
readonly clearOperationError: () => void;
|
||||||
|
readonly connectGitea: (input: {
|
||||||
|
readonly token: string;
|
||||||
|
readonly username?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
readonly connectLinkedGithub: () => Promise<void>;
|
||||||
|
readonly connectRepository: () => Promise<void>;
|
||||||
|
readonly error?: Error;
|
||||||
|
readonly gitConnections: readonly GitConnection[] | undefined;
|
||||||
|
readonly operationError?: Error;
|
||||||
|
readonly pending: boolean;
|
||||||
|
readonly projectGitConnection: GitConnection | null | undefined;
|
||||||
|
readonly projects: readonly ProjectListItem[] | undefined;
|
||||||
|
readonly repository: string;
|
||||||
|
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
|
||||||
|
readonly retryExecution: (runId: Id<"workRuns">) => Promise<unknown>;
|
||||||
|
readonly saveDefinition: (
|
||||||
|
workId: Id<"works">,
|
||||||
|
payload: unknown
|
||||||
|
) => Promise<unknown>;
|
||||||
|
readonly saveDesign: (
|
||||||
|
workId: Id<"works">,
|
||||||
|
payload: unknown
|
||||||
|
) => Promise<unknown>;
|
||||||
|
readonly selectProject: (projectId: string) => void;
|
||||||
|
readonly selectedProject: ProjectListItem | null;
|
||||||
|
readonly setRepository: (value: string) => void;
|
||||||
|
readonly startExecution: (
|
||||||
|
workId: Id<"works">,
|
||||||
|
sliceId?: string
|
||||||
|
) => Promise<unknown>;
|
||||||
|
readonly works: readonly WorkRecord[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceTimelineItem =
|
||||||
|
| { readonly kind: "message"; readonly message: ConversationMessage }
|
||||||
|
| { readonly kind: "work"; readonly notice: WorkNotice };
|
||||||
|
|
||||||
|
export interface ArtifactMetadata {
|
||||||
|
readonly changedFiles?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseArtifactMetadata = (
|
||||||
|
artifact: WorkArtifact
|
||||||
|
): ArtifactMetadata => {
|
||||||
|
if (!artifact.metadataJson) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changedFilesFor = (artifact: WorkArtifact): readonly string[] =>
|
||||||
|
parseArtifactMetadata(artifact).changedFiles ?? [];
|
||||||
+36
-39
@@ -1,11 +1,8 @@
|
|||||||
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
|
import { WebAuthProvider } from "@code/auth/web";
|
||||||
import { env } from "@code/env/web";
|
|
||||||
import { Toaster } from "@code/ui/components/sonner";
|
import { Toaster } from "@code/ui/components/sonner";
|
||||||
import { FlueProvider } from "@flue/react";
|
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { createFlueClient } from "@flue/sdk";
|
import { LoaderCircle } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
Links,
|
Links,
|
||||||
@@ -13,11 +10,14 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
|
useNavigation,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
import { ThemeProvider } from "./components/theme-provider";
|
import { ThemeProvider } from "./components/theme-provider";
|
||||||
import { createFlueFetch } from "./lib/flue-transport";
|
import { loadAuthToken } from "./lib/auth.server";
|
||||||
|
|
||||||
|
export const loader = ({ request }: Route.LoaderArgs) => loadAuthToken(request);
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
|
||||||
@@ -32,39 +32,14 @@ export const links: Route.LinksFunction = () => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
|
|
||||||
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
|
|
||||||
|
|
||||||
const AuthenticatedFlueProvider = ({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const resolveAccessToken = useConvexAccessToken();
|
|
||||||
const client = useMemo(
|
|
||||||
() =>
|
|
||||||
createFlueClient({
|
|
||||||
baseUrl: flueBaseUrl.toString(),
|
|
||||||
fetch: flueFetch,
|
|
||||||
headers: async () => {
|
|
||||||
const accessToken = await resolveAccessToken();
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
if (accessToken) {
|
|
||||||
headers.authorization = `Bearer ${accessToken}`;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[resolveAccessToken]
|
|
||||||
);
|
|
||||||
|
|
||||||
return <FlueProvider client={client}>{children}</FlueProvider>;
|
|
||||||
};
|
|
||||||
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
|
||||||
|
/>
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
@@ -76,9 +51,31 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
||||||
const App = () => (
|
const RouteProgress = () => {
|
||||||
<WebAuthProvider>
|
const navigation = useNavigation();
|
||||||
<AuthenticatedFlueProvider>
|
if (!navigation.location) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label="Loading page"
|
||||||
|
className="fixed inset-x-0 top-0 z-50 h-0.5 overflow-hidden bg-[#d7d3c7]"
|
||||||
|
>
|
||||||
|
<div className="route-progress-bar h-full w-1/3 bg-[#7f9130]" />
|
||||||
|
</output>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HydrateFallback = () => (
|
||||||
|
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] text-[#69675f]">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" /> Loading Zopu…
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
const App = ({ loaderData }: Route.ComponentProps) => (
|
||||||
|
<WebAuthProvider initialToken={loaderData.token}>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="dark"
|
defaultTheme="dark"
|
||||||
@@ -86,10 +83,10 @@ const App = () => (
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
storageKey="vite-ui-theme"
|
storageKey="vite-ui-theme"
|
||||||
>
|
>
|
||||||
|
<RouteProgress />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AuthenticatedFlueProvider>
|
|
||||||
</WebAuthProvider>
|
</WebAuthProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ export default [
|
|||||||
route("signup", "./routes/auth/signup/page.tsx"),
|
route("signup", "./routes/auth/signup/page.tsx"),
|
||||||
]),
|
]),
|
||||||
layout("./routes/app/layout.tsx", [
|
layout("./routes/app/layout.tsx", [
|
||||||
layout("./routes/app/mobile/layout.tsx", [
|
index("./routes/app/workspace/page.tsx"),
|
||||||
index("./routes/app/mobile/page.tsx"),
|
route("projects", "./routes/app/projects/page.tsx"),
|
||||||
route("chat", "./routes/app/mobile/chat/page.tsx"),
|
|
||||||
route("work", "./routes/app/mobile/work-list/page.tsx"),
|
|
||||||
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
|
|
||||||
]),
|
|
||||||
route("dashboard", "./routes/app/dashboard/page.tsx"),
|
|
||||||
route("todos", "./routes/app/todos/page.tsx"),
|
|
||||||
]),
|
]),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { WorkOsPage } from "@/components/work-os/work-os-page";
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
return <WorkOsPage />;
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,35 @@
|
|||||||
import { useWebAuth } from "@code/auth/web";
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
import { Navigate, Outlet, useLocation } from "react-router";
|
import { useQuery } from "convex/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Outlet, useNavigate } from "react-router";
|
||||||
|
|
||||||
|
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
|
||||||
|
import { requireAuthToken } from "@/lib/auth.server";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/layout";
|
||||||
|
|
||||||
|
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||||
|
requireAuthToken(request);
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const auth = useWebAuth();
|
const organization = usePersonalOrganization();
|
||||||
const location = useLocation();
|
const navigate = useNavigate();
|
||||||
|
const projects = useQuery(
|
||||||
if (auth.status === "loading") {
|
api.projects.list,
|
||||||
return (
|
organization.organizationId ? {} : "skip"
|
||||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.status === "inconsistent") {
|
useEffect(() => {
|
||||||
return (
|
if (
|
||||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
projects !== undefined &&
|
||||||
Your session could not be verified
|
projects.length === 0 &&
|
||||||
</div>
|
window.location.pathname === "/" &&
|
||||||
);
|
!window.location.search.includes("resume=") &&
|
||||||
}
|
!window.location.search.includes("project=")
|
||||||
|
) {
|
||||||
if (auth.status === "unauthenticated") {
|
void navigate("/projects", { replace: true });
|
||||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
|
||||||
}
|
}
|
||||||
|
}, [projects, navigate]);
|
||||||
|
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
|
||||||
|
|
||||||
export const meta = (_args: Route.MetaArgs) => [
|
|
||||||
{ title: "Zopu" },
|
|
||||||
{ content: "Chat with Zopu", name: "description" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MobileChatPage() {
|
|
||||||
return <MobileFlowPage screen="assistant-chat" />;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Outlet } from "react-router";
|
|
||||||
|
|
||||||
export default function MobileProductLayout() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-svh bg-[#11110f]">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
|
||||||
|
|
||||||
export const meta = (_args: Route.MetaArgs) => [
|
|
||||||
{ title: "Daily focus | Zopu" },
|
|
||||||
{
|
|
||||||
content: "Supervise active work and open the next action",
|
|
||||||
name: "description",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MobileHomePage() {
|
|
||||||
return <MobileFlowPage screen="home" />;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
|
||||||
|
|
||||||
export const meta = (_args: Route.MetaArgs) => [
|
|
||||||
{ title: "Active work | Zopu" },
|
|
||||||
{
|
|
||||||
content: "Browse active work and expand a work unit in place",
|
|
||||||
name: "description",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MobileWorkListPage() {
|
|
||||||
return <MobileFlowPage screen="work-list" />;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { MobileFlowPage } from "@/components/mobile-workspace/mobile-flow-page";
|
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
|
||||||
|
|
||||||
export const meta = (_args: Route.MetaArgs) => [
|
|
||||||
{ title: "Work unit | Zopu" },
|
|
||||||
{
|
|
||||||
content: "Review the state, next milestone, and activity for a work unit",
|
|
||||||
name: "description",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MobileWorkUnitPage() {
|
|
||||||
return <MobileFlowPage screen="work-unit-detail" />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { api } from "@code/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||||
|
import { useAction, useQuery } from "convex/react";
|
||||||
|
import { makeFunctionReference } from "convex/server";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
|
import { AddProjectPanel } from "@/components/projects/add-project-panel";
|
||||||
|
import { LoadingState } from "@/components/projects/loading-state";
|
||||||
|
import { ProjectsGrid } from "@/components/projects/projects-grid";
|
||||||
|
import { ProjectsHeader } from "@/components/projects/projects-header";
|
||||||
|
import type { GitProviderAccountOption } from "@/components/projects/provider-chips";
|
||||||
|
import { useFilteredProjects } from "@/hooks/use-filtered-projects";
|
||||||
|
import { useProjectsPageState } from "@/hooks/use-projects-page-state";
|
||||||
|
|
||||||
|
const connectGithubRef = makeFunctionReference<
|
||||||
|
"action",
|
||||||
|
Record<string, never>,
|
||||||
|
{ connectionId: Id<"gitConnections"> }
|
||||||
|
>("gitConnections:connectGithub");
|
||||||
|
|
||||||
|
export default function ProjectsRoute() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const projects = useQuery(api.projects.list, {});
|
||||||
|
const providerAccounts = useQuery(
|
||||||
|
api.gitProvisioning.listProviderAccounts,
|
||||||
|
{}
|
||||||
|
) as readonly GitProviderAccountOption[] | undefined;
|
||||||
|
const connectGithubAction = useAction(connectGithubRef);
|
||||||
|
const [resumeError, setResumeError] = useState<string>();
|
||||||
|
const pageState = useProjectsPageState({
|
||||||
|
hasProjects: projects === undefined ? undefined : projects.length > 0,
|
||||||
|
});
|
||||||
|
const { openAddProject } = pageState;
|
||||||
|
const handleProjectSearchChange = (value: string) => {
|
||||||
|
pageState.setProjectSearch(value);
|
||||||
|
};
|
||||||
|
const filteredProjects = useFilteredProjects({
|
||||||
|
projects: projects ?? [],
|
||||||
|
search: pageState.projectSearch,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("resume") !== "github") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await connectGithubAction({});
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
openAddProject();
|
||||||
|
} catch (caughtError) {
|
||||||
|
setResumeError(
|
||||||
|
caughtError instanceof Error
|
||||||
|
? caughtError.message
|
||||||
|
: "GitHub connection failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [connectGithubAction, openAddProject, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
if (projects === undefined) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSourceUrls = projects.flatMap((project) =>
|
||||||
|
project.sources.map((source) => source.url)
|
||||||
|
);
|
||||||
|
const hasProjects = projects.length > 0;
|
||||||
|
const handleProjectCreated = (projectId: string) => {
|
||||||
|
void navigate(`/?project=${projectId}`, { replace: true });
|
||||||
|
};
|
||||||
|
const handleCloseAddProject = () => {
|
||||||
|
pageState.closeAddProject();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-svh bg-[#f2f0e7] px-5 py-7 text-[#20201d] sm:px-8 sm:py-10">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
<ProjectsHeader
|
||||||
|
hasProjects={hasProjects}
|
||||||
|
onGoHome={() => void navigate("/")}
|
||||||
|
onOpenAddProject={openAddProject}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{resumeError ? (
|
||||||
|
<p className="mt-6 border border-red-300 bg-red-50 p-3 text-xs leading-5 text-red-700">
|
||||||
|
{resumeError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasProjects ? (
|
||||||
|
<ProjectsGrid
|
||||||
|
onSearchChange={handleProjectSearchChange}
|
||||||
|
projects={filteredProjects}
|
||||||
|
search={pageState.projectSearch}
|
||||||
|
totalProjects={projects.length}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section className="mt-10 max-w-2xl">
|
||||||
|
<p className="text-[11px] font-medium tracking-[0.16em] text-[#858277] uppercase">
|
||||||
|
Begin here
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-semibold tracking-tight text-[#20201d]">
|
||||||
|
Connect the repository where work happens.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 max-w-xl text-base leading-7 text-[#68665e]">
|
||||||
|
Every project has its own conversation, durable context, and work
|
||||||
|
history. Start by choosing a repository.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pageState.isAddProjectOpen ? (
|
||||||
|
<AddProjectPanel
|
||||||
|
accounts={providerAccounts}
|
||||||
|
existingSourceUrls={existingSourceUrls}
|
||||||
|
onClose={handleCloseAddProject}
|
||||||
|
onProjectCreated={handleProjectCreated}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { api } from "@code/backend/convex/_generated/api";
|
|
||||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
|
||||||
import { Button } from "@code/ui/components/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@code/ui/components/card";
|
|
||||||
import { Checkbox } from "@code/ui/components/checkbox";
|
|
||||||
import { Input } from "@code/ui/components/input";
|
|
||||||
import { useMutation, useQuery } from "convex/react";
|
|
||||||
import { Loader2, Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { FormEvent, ReactNode } from "react";
|
|
||||||
|
|
||||||
export default function Todos() {
|
|
||||||
const [newTodoText, setNewTodoText] = useState("");
|
|
||||||
|
|
||||||
const todos = useQuery(api.todos.getAll);
|
|
||||||
const createTodo = useMutation(api.todos.create);
|
|
||||||
const toggleTodo = useMutation(api.todos.toggle);
|
|
||||||
const deleteTodo = useMutation(api.todos.deleteTodo);
|
|
||||||
|
|
||||||
const handleAddTodo = async (event: FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const text = newTodoText.trim();
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await createTodo({ text });
|
|
||||||
setNewTodoText("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
|
|
||||||
void toggleTodo({ completed: !currentCompleted, id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteTodo = (id: Id<"todos">) => {
|
|
||||||
void deleteTodo({ id });
|
|
||||||
};
|
|
||||||
|
|
||||||
let todoContent: ReactNode;
|
|
||||||
if (todos === undefined) {
|
|
||||||
todoContent = (
|
|
||||||
<div className="flex justify-center py-4">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (todos.length === 0) {
|
|
||||||
todoContent = (
|
|
||||||
<p className="py-4 text-center">No todos yet. Add one above!</p>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
todoContent = (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{todos.map((todo) => (
|
|
||||||
<li
|
|
||||||
className="flex items-center justify-between rounded-md border p-2"
|
|
||||||
key={todo._id}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={todo.completed}
|
|
||||||
id={`todo-${todo._id}`}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
handleToggleTodo(todo._id, todo.completed)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
className={
|
|
||||||
todo.completed
|
|
||||||
? "text-muted-foreground line-through"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
htmlFor={`todo-${todo._id}`}
|
|
||||||
>
|
|
||||||
{todo.text}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
aria-label="Delete todo"
|
|
||||||
onClick={() => handleDeleteTodo(todo._id)}
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-md py-10">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Todo List</CardTitle>
|
|
||||||
<CardDescription>Manage your tasks efficiently</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form
|
|
||||||
className="mb-6 flex items-center space-x-2"
|
|
||||||
onSubmit={handleAddTodo}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
onChange={(event) => setNewTodoText(event.target.value)}
|
|
||||||
placeholder="Add a new task..."
|
|
||||||
value={newTodoText}
|
|
||||||
/>
|
|
||||||
<Button disabled={!newTodoText.trim()} type="submit">
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{todoContent}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProjectWorkspacePage } from "@/components/workspace/project-workspace-page";
|
||||||
|
|
||||||
|
export default function ProjectWorkspaceRoute() {
|
||||||
|
return <ProjectWorkspacePage />;
|
||||||
|
}
|
||||||
@@ -1,21 +1,13 @@
|
|||||||
import { useWebAuth } from "@code/auth/web";
|
import { Outlet } from "react-router";
|
||||||
import { Navigate, Outlet } from "react-router";
|
|
||||||
|
import { redirectAuthenticated } from "@/lib/auth.server";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/layout";
|
||||||
|
|
||||||
|
export const loader = ({ request }: Route.LoaderArgs) =>
|
||||||
|
redirectAuthenticated(request);
|
||||||
|
|
||||||
export default function AuthLayout() {
|
export default function AuthLayout() {
|
||||||
const auth = useWebAuth();
|
|
||||||
|
|
||||||
if (auth.status === "loading") {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (auth.status === "authenticated") {
|
|
||||||
return <Navigate replace to="/" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { LoginForm } from "@code/auth/web";
|
import { LoginForm } from "@code/auth/web";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/page";
|
import type { Route } from "./+types/page";
|
||||||
|
|
||||||
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
|
|||||||
|
|
||||||
export default function LoginRoute() {
|
export default function LoginRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const returnTo = searchParams.get("returnTo");
|
||||||
|
|
||||||
return <LoginForm onSuccess={() => navigate("/", { replace: true })} />;
|
return (
|
||||||
|
<LoginForm
|
||||||
|
onSuccess={() =>
|
||||||
|
navigate(returnTo?.startsWith("/") ? returnTo : "/", { replace: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -2,13 +2,27 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { reactRouter } from "@react-router/dev/vite";
|
import { reactRouter } from "@react-router/dev/vite";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from "vite-plus";
|
import { defineConfig, loadEnv } from "vite-plus";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
const envDir = path.resolve(import.meta.dirname, "../..");
|
||||||
|
const env = loadEnv(mode, envDir, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
envDir,
|
||||||
plugins: [tailwindcss(), reactRouter()],
|
plugins: [tailwindcss(), reactRouter()],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ["convex", "react", "react-dom"],
|
dedupe: ["convex", "react", "react-dom"],
|
||||||
tsconfigPaths: true,
|
tsconfigPaths: true,
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy: {
|
||||||
|
"/api/auth": {
|
||||||
|
changeOrigin: true,
|
||||||
|
target: env.CONVEX_SITE_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Caddy reverse proxy for the Zopu VDS staging agent stack.
|
||||||
|
#
|
||||||
|
# This is the ONLY public ingress to the private worker (see
|
||||||
|
# docs/DEPLOYMENT_PLAN.md §"Public surface"). It terminates TLS for the Flue
|
||||||
|
# callback hostname that Convex reaches, then forwards ONLY the documented agent
|
||||||
|
# worker paths to the internal `agents` service.
|
||||||
|
#
|
||||||
|
# Hard rules this file enforces:
|
||||||
|
# - No generic /api/* proxy and no browser-to-Flue traffic (DEPLOYMENT_PLAN.md
|
||||||
|
# line 86). Browsers talk only to Convex.
|
||||||
|
# - Only the Flue Node worker paths Convex actually calls are routed:
|
||||||
|
# /agents/zopu/<organizationId> conversation admission
|
||||||
|
# /internal/work-attempts/* work attempt execute + cancel
|
||||||
|
# /api/rivet/* RivetKit Actor gateway surface
|
||||||
|
# /workflows/* Flue workflow execution
|
||||||
|
# /health liveness probe (Compose healthcheck/CI)
|
||||||
|
# - Everything else returns 404. The private worker protocol stays narrow.
|
||||||
|
#
|
||||||
|
# Variables are injected by the `caddy` Compose service environment:
|
||||||
|
# {$AGENTS_HOST} public hostname, e.g. agents-staging.example.com
|
||||||
|
# {$AGENTS_UPSTREAM} internal upstream host:port, e.g. agents:3000
|
||||||
|
# {$ACME_EMAIL} email for the Let's Encrypt account
|
||||||
|
#
|
||||||
|
# TLS certs and the ACME account persist in the caddy-data volume. Caddy serves
|
||||||
|
# its own ACME HTTP-01 challenge responses on :80 automatically, so the :80
|
||||||
|
# block below only redirects everything else to HTTPS — worker traffic is never
|
||||||
|
# served over plain HTTP.
|
||||||
|
{
|
||||||
|
email {$ACME_EMAIL}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- TLS termination for the Flue callback hostname ---------------------------
|
||||||
|
{$AGENTS_HOST} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
# Conversation admission: Convex POSTs to /agents/zopu/<organizationId>.
|
||||||
|
# The conversationRoute middleware requires the FLUE_DB_TOKEN bearer and the
|
||||||
|
# matching x-zopu-organization-id header.
|
||||||
|
reverse_proxy /agents/zopu/* {$AGENTS_UPSTREAM}
|
||||||
|
|
||||||
|
# Internal work-attempt execute/cancel. Requires the internalRoute bearer.
|
||||||
|
reverse_proxy /internal/work-attempts/* {$AGENTS_UPSTREAM}
|
||||||
|
|
||||||
|
# RivetKit Actor gateway surface (app.all("/api/rivet/*")). Routed so the
|
||||||
|
# runtime registry handler is reachable for actor metadata/start.
|
||||||
|
reverse_proxy /api/rivet/* {$AGENTS_UPSTREAM}
|
||||||
|
|
||||||
|
# Flue workflow execution (workflows/plan-work). Requires the workflowRoute
|
||||||
|
# bearer + x-zopu-organization-id header.
|
||||||
|
reverse_proxy /workflows/* {$AGENTS_UPSTREAM}
|
||||||
|
|
||||||
|
# Caddy routes the liveness request to the actual worker; it must not mask a
|
||||||
|
# failed worker process with a synthetic successful response.
|
||||||
|
reverse_proxy /health {$AGENTS_UPSTREAM}
|
||||||
|
|
||||||
|
# Everything else is not part of the private worker protocol.
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- HTTP → HTTPS redirect (Caddy still answers ACME challenges on :80) -------
|
||||||
|
:80 {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Zopu VDS staging — Rivet engine + runner on host network.
|
||||||
|
#
|
||||||
|
# The agents registry and Caddy run on the HOST via systemd (not Docker).
|
||||||
|
# This compose only manages the two Rivet services, both on host networking:
|
||||||
|
#
|
||||||
|
# engine — Rivet Engine (RocksDB backend), binds 127.0.0.1:6420 on the host.
|
||||||
|
# UFW (policy DROP, only 22/80/443 open) keeps it private.
|
||||||
|
# runner — AgentOS runner, reaches the engine at 127.0.0.1:6420.
|
||||||
|
#
|
||||||
|
# With network_mode: host there are no Docker bridge networks, no port
|
||||||
|
# forwarding, and no host.docker.internal gymnastics — both services share
|
||||||
|
# the host network namespace directly.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose --env-file .env up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
engine:
|
||||||
|
image: ${ENGINE_IMAGE:?ENGINE_IMAGE must be set}
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${ZOPU_DEPLOY_ROOT:?ZOPU_DEPLOY_ROOT is required}/data/rivet
|
||||||
|
target: /data
|
||||||
|
environment:
|
||||||
|
RIVET__FILE_SYSTEM__PATH: /data
|
||||||
|
RIVET__AUTH__ADMIN_TOKEN: ${RIVET_ADMIN_TOKEN:?RIVET_ADMIN_TOKEN is required}
|
||||||
|
RIVET_LOG_LEVEL: ${RIVET_LOG_LEVEL:-info}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:6420/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
runner:
|
||||||
|
image: ${RUNNER_IMAGE:?RUNNER_IMAGE must be set}
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
RIVET_RUNNER_VERSION: ${RIVET_RUNNER_VERSION:?RIVET_RUNNER_VERSION is required}
|
||||||
|
RIVET_ENVOY_VERSION: ${RIVET_RUNNER_VERSION:?RIVET_RUNNER_VERSION is required}
|
||||||
|
RIVET_ENDPOINT: http://${RIVET_NAMESPACE:-default}:${RIVET_ADMIN_TOKEN:?RIVET_ADMIN_TOKEN is required}@127.0.0.1:6420
|
||||||
|
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN:?RIVET_WORKSPACE_TOKEN is required}
|
||||||
|
AGENT_WORKSPACE_ROOT: /var/lib/zopu/workspaces
|
||||||
|
BUN_EXECUTABLE: /usr/local/bin/bun
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${ZOPU_DEPLOY_ROOT:?ZOPU_DEPLOY_ROOT is required}/workspaces
|
||||||
|
target: /var/lib/zopu/workspaces
|
||||||
|
depends_on:
|
||||||
|
engine:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://rivet.dev/engine-config-schema.json",
|
||||||
|
"file_system": {
|
||||||
|
"path": "/data"
|
||||||
|
},
|
||||||
|
"singleplayer": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
email ops@zopu.ai
|
||||||
|
}
|
||||||
|
{$AGENTS_HOST} {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
route {
|
||||||
|
reverse_proxy /agents/zopu/* 127.0.0.1:3000
|
||||||
|
reverse_proxy /internal/work-attempts/* 127.0.0.1:3000
|
||||||
|
reverse_proxy /workflows/* 127.0.0.1:3000
|
||||||
|
reverse_proxy /health 127.0.0.1:3000
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:80 {
|
||||||
|
redir https://{host}{uri} permanent
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Zopu agent registry
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=zopu
|
||||||
|
Group=zopu
|
||||||
|
WorkingDirectory=/srv/zopu/source/packages/agents
|
||||||
|
EnvironmentFile=/etc/zopu/agents.env
|
||||||
|
ExecStart=/opt/zopu/node/bin/node dist/server.mjs
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/srv/zopu/workspaces
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/zopu/agents.env
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
###############################################################################
|
|
||||||
# Zopu Runtime Deployment — Environment Template
|
|
||||||
#
|
|
||||||
# Copy to .env and fill in real values. Never commit .env to the repository.
|
|
||||||
# This file documents every environment group required by the single-node
|
|
||||||
# execution plane. Lines marked REQUIRED must be set before first start.
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Convex (control plane) — REQUIRED
|
|
||||||
# Convex is already deployed; provide the production deployment URL.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
CONVEX_URL=https://your-deployment.convex.cloud
|
|
||||||
CONVEX_SITE_URL=https://your-deployment.convex.site
|
|
||||||
SITE_URL=http://localhost:5173
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
|
|
||||||
# The agent daemon clones repos and creates PRs through Gitea.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
GITEA_URL=https://git.openputer.com
|
|
||||||
GITEA_TOKEN=replace-with-gitea-api-token
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Model gateway — REQUIRED
|
|
||||||
# All model calls route through this OpenAI-compatible endpoint.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
AGENT_MODEL_PROVIDER=cheaptricks
|
|
||||||
AGENT_MODEL_NAME=glm-5.2
|
|
||||||
AGENT_MODEL_API=openai-completions
|
|
||||||
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
|
|
||||||
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
|
|
||||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
|
||||||
AGENT_MODEL_MAX_TOKENS=131072
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown)
|
|
||||||
# registry.start() in the daemon boots an in-process RivetKit engine
|
|
||||||
# (envoy mode) backed by a native Rust sidecar. createClient() connects
|
|
||||||
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
|
|
||||||
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
|
|
||||||
# library default (http://localhost:6420).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#RIVET_ENDPOINT=http://localhost:6420
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Zopu agent service (Flue)
|
|
||||||
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
|
|
||||||
# PORT is the Flue Node HTTP server listen port.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
FLUE_DB_TOKEN=replace-with-long-random-token
|
|
||||||
PORT=3583
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Daemon identity
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DAEMON_ID=zopu-dedicated
|
|
||||||
DAEMON_NAME=Zopu Dedicated Server
|
|
||||||
DAEMON_VERSION=0.0.0
|
|
||||||
DAEMON_HEARTBEAT_MS=15000
|
|
||||||
DAEMON_COMMAND_LEASE_MS=60000
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Docker sandbox
|
|
||||||
# The zopu service user is added to the docker group during bootstrap.
|
|
||||||
# Orb sandboxes will use Docker for full-system isolation, but the Orb
|
|
||||||
# lane contract has not landed yet. Docker access is provisioned now so
|
|
||||||
# the boundary is ready; no Docker-backed sandbox code is wired today.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# No env vars required; Docker socket access is via group membership.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Service authentication / secrets
|
|
||||||
# These tokens authenticate inter-service calls. Generate strong randoms.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
|
|
||||||
#AUTH_SECRET=replace-with-64-char-hex
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 9. Private networking (Tailscale) — OPTIONAL
|
|
||||||
# When Tailscale is available, set the hostname for private DNS.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#TAILSCALE_HOSTNAME=zopu-runtime
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
# Zopu Single-Node Runtime Deployment
|
|
||||||
|
|
||||||
Deployment artifacts for the Zopu execution plane on a single Debian dedicated
|
|
||||||
server. Convex is already deployed as the control plane; this lane deploys only
|
|
||||||
the execution plane: the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent
|
|
||||||
service, Docker Engine for future Orb sandboxes, and supporting infrastructure.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Debian Dedicated Server │
|
|
||||||
│ ~12 CPU cores · ~40 GB RAM · single-node │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ zopu-daemon │ │ zopu-agent │ │ Docker │ │
|
|
||||||
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
|
|
||||||
│ │ │ │ │ │ │ │
|
|
||||||
│ │ Effect daemon│ │ Flue Node │ │ Orb sandboxes│ │
|
|
||||||
│ │ + RivetKit │ │ server.mjs │ │ (future) │ │
|
|
||||||
│ │ in-process │ │ :3583 │ │ │ │
|
|
||||||
│ │ engine │ │ │ │ │ │
|
|
||||||
│ │ + native │ │ │ │ │ │
|
|
||||||
│ │ sidecar │ │ │ │ │ │
|
|
||||||
│ │ :6420 │ │ │ │ │ │
|
|
||||||
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ └────────┬───────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌────────▼─────────┐ │
|
|
||||||
│ │ Convex (cloud) │ ← control plane (pre-deployed)│
|
|
||||||
│ └──────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ systemd timers: health-check (60s), docker-cleanup (daily) │
|
|
||||||
│ cron: disk-monitor (daily 06:00) │
|
|
||||||
│ ufw: deny-incoming, SSH + tailscale0 │
|
|
||||||
│ Tailscale: optional private overlay │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Single-node RivetKit topology
|
|
||||||
|
|
||||||
The daemon calls `registry.start()` from `rivetkit`, which boots an
|
|
||||||
**in-process RivetKit engine** (envoy mode) backed by a **native Rust
|
|
||||||
sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine
|
|
||||||
listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then
|
|
||||||
calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process
|
|
||||||
engine for actor dispatch.
|
|
||||||
|
|
||||||
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 —
|
|
||||||
`DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method
|
|
||||||
calls `#startEnvoy()` → `runtime.serveRegistry()` for serverful mode (Mode A).
|
|
||||||
The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the
|
|
||||||
same `http://localhost:6420`.
|
|
||||||
|
|
||||||
**No separate Rivet Engine process is required.** The engine, actor envoy, and
|
|
||||||
sidecar all run inside the daemon process. A future multi-node deployment
|
|
||||||
would externalize the engine, but that is out of scope.
|
|
||||||
|
|
||||||
### Docker / Orb boundary
|
|
||||||
|
|
||||||
Docker Engine is installed and the `zopu` service user is in the `docker`
|
|
||||||
group. The daemon's systemd unit includes `SupplementaryGroups=docker`.
|
|
||||||
However, **no Docker-backed sandbox code is currently wired**. The Orb
|
|
||||||
sandbox lane contract has not landed; Docker access is provisioned now so the
|
|
||||||
boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8)
|
|
||||||
sandbox, not Docker.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
```
|
|
||||||
deploy/zopu-runtime/
|
|
||||||
├── bootstrap.sh # One-shot Debian installer
|
|
||||||
├── .env.template # Environment template (all groups documented)
|
|
||||||
├── README.md # This file (runbook)
|
|
||||||
├── systemd/
|
|
||||||
│ ├── zopu-daemon.service # Daemon systemd unit
|
|
||||||
│ ├── zopu-agent.service # Agent systemd unit
|
|
||||||
│ ├── zopu-health.service # Health check oneshot
|
|
||||||
│ ├── zopu-health.timer # Health check every 60s
|
|
||||||
│ ├── zopu-docker-cleanup.service
|
|
||||||
│ └── zopu-docker-cleanup.timer
|
|
||||||
├── scripts/
|
|
||||||
│ ├── health-check.sh # TCP/process health probes
|
|
||||||
│ ├── update.sh # Update to branch or commit
|
|
||||||
│ ├── rollback.sh # Roll back to previous commit
|
|
||||||
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
|
|
||||||
│ └── disk-monitor.sh # Disk usage alerting
|
|
||||||
└── caddy/
|
|
||||||
└── Caddyfile # Optional reverse proxy config (documentation)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Fresh install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. SSH into the fresh Debian 12 server as root.
|
|
||||||
|
|
||||||
# 2. Set environment overrides (optional):
|
|
||||||
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
|
|
||||||
export ZOPU_REPO_BRANCH="dogfood/v0"
|
|
||||||
# export TAILSCALE_AUTHKEY="tskey-..."
|
|
||||||
# export TAILSCALE_HOSTNAME="zopu-runtime"
|
|
||||||
|
|
||||||
# 3. Run the bootstrap script:
|
|
||||||
bash bootstrap.sh
|
|
||||||
|
|
||||||
# 4. Edit .env with real values:
|
|
||||||
nano /opt/zopu/.env
|
|
||||||
|
|
||||||
# 5. Start services:
|
|
||||||
systemctl start zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl start zopu-agent
|
|
||||||
|
|
||||||
# 6. Enable timers:
|
|
||||||
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
|
|
||||||
|
|
||||||
# 7. Verify:
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Start / stop / restart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start both services
|
|
||||||
systemctl start zopu-daemon zopu-agent
|
|
||||||
|
|
||||||
# Stop both services
|
|
||||||
systemctl stop zopu-agent zopu-daemon
|
|
||||||
|
|
||||||
# Restart (daemon first — it owns the RivetKit engine)
|
|
||||||
systemctl restart zopu-daemon && sleep 3 && systemctl restart zopu-agent
|
|
||||||
|
|
||||||
# Enable on boot
|
|
||||||
systemctl enable zopu-daemon zopu-agent
|
|
||||||
|
|
||||||
# Disable on boot
|
|
||||||
systemctl disable zopu-daemon zopu-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
## Log inspection
|
|
||||||
|
|
||||||
All service logs go to journald with `SyslogIdentifier` tags.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Daemon logs (live follow)
|
|
||||||
journalctl -u zopu-daemon -f
|
|
||||||
|
|
||||||
# Agent logs (live follow)
|
|
||||||
journalctl -u zopu-agent -f
|
|
||||||
|
|
||||||
# Last 100 lines of daemon
|
|
||||||
journalctl -u zopu-daemon -n 100
|
|
||||||
|
|
||||||
# Logs since boot
|
|
||||||
journalctl -u zopu-daemon -b
|
|
||||||
|
|
||||||
# Health check timer logs
|
|
||||||
journalctl -u zopu-health.service -n 50
|
|
||||||
|
|
||||||
# Docker cleanup logs
|
|
||||||
journalctl -u zopu-docker-cleanup.service -n 50
|
|
||||||
|
|
||||||
# Disk monitor logs (cron → file)
|
|
||||||
tail -100 /var/log/zopu/disk-monitor.log
|
|
||||||
|
|
||||||
# All Zopu syslog identifiers
|
|
||||||
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual health check (prints all probes)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
|
|
||||||
|
|
||||||
# Quiet mode (exit code only)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
|
|
||||||
|
|
||||||
# Check systemd timer is running
|
|
||||||
systemctl status zopu-health.timer
|
|
||||||
systemctl list-timers zopu-health.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
The health check probes:
|
|
||||||
1. `zopu-daemon` systemd unit is active
|
|
||||||
2. `zopu-agent` systemd unit is active
|
|
||||||
3. RivetKit engine port (default 6420) accepts TCP connections
|
|
||||||
4. Flue agent port (default 3583) accepts TCP connections
|
|
||||||
5. Docker daemon responds to `docker info`
|
|
||||||
|
|
||||||
No HTTP health endpoints are assumed. Flue does not expose one by design
|
|
||||||
(per Flue docs: "Flue does not add a health endpoint"). RivetKit's health
|
|
||||||
route is internal to the registry runtime and not documented as
|
|
||||||
publicly addressable on the engine endpoint.
|
|
||||||
|
|
||||||
## Update to commit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Update to latest of dogfood/v0 (default)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
|
|
||||||
|
|
||||||
# Update to a specific branch
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
|
|
||||||
|
|
||||||
# Update to a specific commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
The update script:
|
|
||||||
1. Records current HEAD to `.last-deployed-sha`
|
|
||||||
2. Fetches, resolves branch-or-commit, checks out
|
|
||||||
3. `bun install`, builds daemon and agent
|
|
||||||
4. Restarts daemon, waits, restarts agent
|
|
||||||
5. Runs health check; reports failure and rollback instructions
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Roll back to the previously deployed commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
|
|
||||||
|
|
||||||
# Roll back to a specific commit
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that
|
|
||||||
commit, rebuilds, and restarts services. The pre-rollback SHA is saved to
|
|
||||||
`.pre-rollback-sha` for re-rollback if needed.
|
|
||||||
|
|
||||||
## Docker cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual cleanup
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
|
|
||||||
|
|
||||||
# Check Docker disk usage
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
# Timer runs daily; check its schedule
|
|
||||||
systemctl list-timers zopu-docker-cleanup.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
Cleanup prunes:
|
|
||||||
- Stopped containers older than 24 hours
|
|
||||||
- Dangling (untagged) images
|
|
||||||
- Unused networks
|
|
||||||
|
|
||||||
|
|
||||||
Named volumes and running containers are never removed.
|
|
||||||
|
|
||||||
## Disk-space monitoring
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Manual check
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
|
|
||||||
|
|
||||||
# Custom threshold (90%)
|
|
||||||
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
|
|
||||||
```
|
|
||||||
|
|
||||||
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`.
|
|
||||||
Default alert threshold is 80%.
|
|
||||||
|
|
||||||
## Firewall and private networking
|
|
||||||
|
|
||||||
The firewall (`ufw`) is deny-by-default:
|
|
||||||
- SSH (port 22) is allowed on all interfaces
|
|
||||||
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
|
|
||||||
- All other incoming traffic is denied
|
|
||||||
|
|
||||||
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not**
|
|
||||||
exposed on public interfaces. Reachability options:
|
|
||||||
|
|
||||||
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0`
|
|
||||||
so all ports are reachable over the private overlay. Set
|
|
||||||
`TAILSCALE_AUTHKEY` before running bootstrap to configure automatically.
|
|
||||||
Other Tailscale-connected machines can reach the agent at
|
|
||||||
`http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
|
|
||||||
2. **Custom private interface**: if you have a non-Tailscale private network
|
|
||||||
(e.g. a VLAN or wireguard interface), add an explicit rule:
|
|
||||||
```bash
|
|
||||||
ufw allow in on eth1 # or your private interface name
|
|
||||||
```
|
|
||||||
Do NOT assume direct private IP access works by default — the deny-incoming
|
|
||||||
policy blocks it until an interface-specific rule is added.
|
|
||||||
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in
|
|
||||||
`caddy/` if you need TLS termination or a public ingress point.
|
|
||||||
|
|
||||||
## Environment groups
|
|
||||||
|
|
||||||
See [`.env.template`](./.env.template) for the full annotated template. The
|
|
||||||
eight required groups:
|
|
||||||
|
|
||||||
| Group | Variables |
|
|
||||||
|-------|-----------|
|
|
||||||
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
|
|
||||||
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
|
|
||||||
| Model gateway | `AGENT_MODEL_*` |
|
|
||||||
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
|
|
||||||
| Zopu agent | `FLUE_DB_TOKEN`, `PORT` |
|
|
||||||
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
|
|
||||||
| Docker sandbox | group membership (no env vars) |
|
|
||||||
| Service auth | `AUTH_SECRET` (if needed) |
|
|
||||||
|
|
||||||
## What is NOT deployed
|
|
||||||
|
|
||||||
- **Web app**: the web frontend is not deployed in this lane.
|
|
||||||
- **Kubernetes**: no container orchestration.
|
|
||||||
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own
|
|
||||||
storage; no external PostgreSQL is required.
|
|
||||||
- **Multi-node coordination**: single-node only.
|
|
||||||
- **Public administration endpoints**: no admin HTTP surface.
|
|
||||||
- **Secrets in source**: `.env` is never committed; `.env.template` contains
|
|
||||||
only placeholder values.
|
|
||||||
- **Production generated-app hosting**: only the execution plane runs here.
|
|
||||||
- **Docker-backed Orb sandboxes**: Docker is installed and access is
|
|
||||||
provisioned, but no Orb sandbox code is wired. This is a boundary
|
|
||||||
prepared for the Orb lane, not a working feature.
|
|
||||||
|
|
||||||
## Reproducibility
|
|
||||||
|
|
||||||
The deployment does not require the developer's MacBook to remain online.
|
|
||||||
Once bootstrap completes and `.env` is filled in:
|
|
||||||
|
|
||||||
1. Services run under systemd with `Restart=always`.
|
|
||||||
2. Logs persist in journald.
|
|
||||||
3. Health checks run every 60 seconds via systemd timer.
|
|
||||||
4. Docker cleanup runs daily.
|
|
||||||
5. Disk usage is monitored daily.
|
|
||||||
6. Unattended-upgrades handles Debian security patches.
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# bootstrap.sh — One-shot installer for the Zopu single-node execution plane.
|
|
||||||
#
|
|
||||||
# Run as root on a fresh Debian 12 host:
|
|
||||||
#
|
|
||||||
# bash bootstrap.sh
|
|
||||||
#
|
|
||||||
# Environment overrides (set before running):
|
|
||||||
# ZOPU_REPO_URL — SSH clone URL (default: ssh://git@git.openputer.com:2222/puter/zopu-code.git)
|
|
||||||
# ZOPU_REPO_BRANCH — branch to deploy (default: dogfood/v0)
|
|
||||||
# ZOPU_INSTALL_DIR — install path (default: /opt/zopu)
|
|
||||||
# ZOPU_SERVICE_USER — system user (default: zopu)
|
|
||||||
# TAILSCALE_AUTHKEY — if set, configure Tailscale
|
|
||||||
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
|
|
||||||
#
|
|
||||||
# Installs: Docker Engine, Bun, clones the repo, runs bun install, builds the
|
|
||||||
# daemon and agent, creates a non-root service user, installs systemd units,
|
|
||||||
# and configures firewall/Tailscale defaults.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
REPO_URL="${ZOPU_REPO_URL:-ssh://git@git.openputer.com:2222/puter/zopu-code.git}"
|
|
||||||
REPO_BRANCH="${ZOPU_REPO_BRANCH:-dogfood/v0}"
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
DEPLOY_DIR="${INSTALL_DIR}/deploy/zopu-runtime"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[bootstrap]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[bootstrap]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[bootstrap]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
# runuser is part of util-linux (essential on Debian) and always available.
|
|
||||||
# sudo is NOT assumed on minimal Debian installs.
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pre-flight
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ "$EUID" -ne 0 ]]; then
|
|
||||||
err "This script must be run as root."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f /etc/debian_version ]]; then
|
|
||||||
log "Detected Debian $(cat /etc/debian_version)"
|
|
||||||
else
|
|
||||||
warn "This script targets Debian 12. Other distributions may need manual adjustments."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. System packages
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Updating apt and installing base packages..."
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gnupg \
|
|
||||||
ufw \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
netcat-openbsd \
|
|
||||||
openssh-client \
|
|
||||||
unattended-upgrades \
|
|
||||||
rsyslog
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Docker Engine
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! command -v docker &>/dev/null; then
|
|
||||||
log "Installing Docker Engine..."
|
|
||||||
install -m 0755 -d /etc/apt/keyrings
|
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
|
||||||
-o /etc/apt/keyrings/docker.asc
|
|
||||||
chmod a+r /etc/apt/keyrings/docker.asc
|
|
||||||
|
|
||||||
echo \
|
|
||||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
|
||||||
https://download.docker.com/linux/debian \
|
|
||||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
|
||||||
> /etc/apt/sources.list.d/docker.list
|
|
||||||
|
|
||||||
apt-get update -y
|
|
||||||
apt-get install -y \
|
|
||||||
docker-ce \
|
|
||||||
docker-ce-cli \
|
|
||||||
containerd.io \
|
|
||||||
docker-buildx-plugin \
|
|
||||||
docker-compose-plugin
|
|
||||||
else
|
|
||||||
log "Docker Engine already installed: $(docker --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
systemctl enable --now docker
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Bun
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! command -v bun &>/dev/null; then
|
|
||||||
log "Installing Bun..."
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
else
|
|
||||||
log "Bun already installed: $(bun --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Service user
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
log "Creating service user: $SERVICE_USER"
|
|
||||||
useradd -r -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! id -nG "$SERVICE_USER" | grep -qw docker; then
|
|
||||||
usermod -aG docker "$SERVICE_USER"
|
|
||||||
log "Added $SERVICE_USER to docker group"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Clone or update repository
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
|
||||||
log "Repository exists at $INSTALL_DIR, fetching latest..."
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
git fetch origin
|
|
||||||
git checkout "$REPO_BRANCH"
|
|
||||||
git reset --hard "origin/$REPO_BRANCH"
|
|
||||||
else
|
|
||||||
log "Cloning $REPO_URL (branch $REPO_BRANCH) into $INSTALL_DIR..."
|
|
||||||
git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5b. Hand ownership of the checkout to the service user
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 6. Install dependencies and build
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building daemon binary..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 7. Environment file
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ENV_FILE="$INSTALL_DIR/.env"
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
|
||||||
log "Copying .env.template to .env — EDIT BEFORE STARTING SERVICES"
|
|
||||||
cp "$DEPLOY_DIR/.env.template" "$ENV_FILE"
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
warn "Edit $ENV_FILE with real values before starting services."
|
|
||||||
else
|
|
||||||
log ".env already exists at $ENV_FILE"
|
|
||||||
# Ensure correct ownership and permissions on existing .env
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
|
|
||||||
chmod 600 "$ENV_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Persistent log directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
LOG_DIR="/var/log/zopu"
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 9. Install systemd units (substitute placeholders)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Installing systemd units..."
|
|
||||||
for unit in zopu-daemon.service zopu-agent.service \
|
|
||||||
zopu-health.timer zopu-health.service \
|
|
||||||
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
|
|
||||||
SRC="$DEPLOY_DIR/systemd/$unit"
|
|
||||||
DST="/etc/systemd/system/$unit"
|
|
||||||
if [[ -f "$SRC" ]]; then
|
|
||||||
sed \
|
|
||||||
-e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
|
|
||||||
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
|
|
||||||
"$SRC" > "$DST"
|
|
||||||
log " installed $unit"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
systemctl daemon-reload
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 10. Firewall (deny-by-default, explicit allow for Tailscale)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Configuring firewall..."
|
|
||||||
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
|
|
||||||
ufw allow 22/tcp
|
|
||||||
ufw default deny incoming
|
|
||||||
ufw default allow outgoing
|
|
||||||
|
|
||||||
# Allow all traffic on the Tailscale interface (if present)
|
|
||||||
# This lets the agent and engine ports be reached over the private overlay.
|
|
||||||
ufw allow in on tailscale0 || warn "tailscale0 not present yet; rule will activate when interface appears"
|
|
||||||
|
|
||||||
ufw --force enable
|
|
||||||
log "Firewall enabled: SSH (22) allowed, tailscale0 allowed."
|
|
||||||
warn "Agent and RivetKit ports are NOT exposed on public interfaces."
|
|
||||||
warn "Reachability is via Tailscale (tailscale0) only."
|
|
||||||
else
|
|
||||||
log "Firewall already active. Ensuring tailscale0 rule..."
|
|
||||||
ufw allow in on tailscale0 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 11. Tailscale (optional)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
|
|
||||||
log "Installing and configuring Tailscale..."
|
|
||||||
if ! command -v tailscaled &>/dev/null; then
|
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
|
||||||
fi
|
|
||||||
tailscale up --authkey "$TAILSCALE_AUTHKEY" \
|
|
||||||
--hostname "${TAILSCALE_HOSTNAME:-zopu-runtime}" \
|
|
||||||
--accept-routes
|
|
||||||
log "Tailscale configured: $(tailscale ip -4 2>/dev/null || echo 'waiting for IP')"
|
|
||||||
|
|
||||||
# Re-apply the tailscale0 firewall rule now that the interface exists
|
|
||||||
ufw allow in on tailscale0 2>/dev/null || true
|
|
||||||
else
|
|
||||||
warn "TAILSCALE_AUTHKEY not set — skipping Tailscale setup."
|
|
||||||
warn "Without Tailscale, services are reachable only via localhost."
|
|
||||||
warn "To use a private network interface, add an explicit UFW rule:"
|
|
||||||
warn " ufw allow in on <interface>"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 12. Disk-space monitoring cron
|
|
||||||
# /etc/cron.d format REQUIRES a username field.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Installing disk-space monitor (daily at 06:00)..."
|
|
||||||
CRON_LINE="0 6 * * * ${SERVICE_USER} ${DEPLOY_DIR}/scripts/disk-monitor.sh --warn-percent 80 >> /var/log/zopu/disk-monitor.log 2>&1"
|
|
||||||
echo "$CRON_LINE" > /etc/cron.d/zopu-disk-monitor
|
|
||||||
chmod 644 /etc/cron.d/zopu-disk-monitor
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Done
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log "Bootstrap complete."
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " 1. Edit $ENV_FILE with real values"
|
|
||||||
echo " 2. Start services:"
|
|
||||||
echo " systemctl start zopu-daemon zopu-agent"
|
|
||||||
echo " 3. Enable health monitoring:"
|
|
||||||
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
|
|
||||||
echo " 4. Verify health:"
|
|
||||||
echo " $DEPLOY_DIR/scripts/health-check.sh"
|
|
||||||
echo ""
|
|
||||||
warn "Services are NOT started automatically. Edit .env first."
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Caddyfile — Minimal reverse proxy for the Zopu agent service.
|
|
||||||
#
|
|
||||||
# This is OPTIONAL. The agent service does not need to be publicly exposed
|
|
||||||
# for the execution plane to function. Use Caddy only when an HTTP endpoint
|
|
||||||
# is genuinely needed (e.g., a webhook ingress from Convex site functions).
|
|
||||||
#
|
|
||||||
# In the default single-node deployment, the agent listens on localhost:3583
|
|
||||||
# and is reachable over Tailscale. Install Caddy only if you need TLS
|
|
||||||
# termination or a public ingress point.
|
|
||||||
#
|
|
||||||
# --- Private/Tailscale deployment (recommended) ---
|
|
||||||
#
|
|
||||||
# Listen only on the Tailscale interface. Replace the hostname with your
|
|
||||||
# Tailscale machine name or IP.
|
|
||||||
#
|
|
||||||
# http://zopu-runtime.tail-xxxx.ts.net {
|
|
||||||
# reverse_proxy localhost:3583
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# --- Public deployment with automatic TLS (only if needed) ---
|
|
||||||
#
|
|
||||||
# agent.example.invalid {
|
|
||||||
# reverse_proxy localhost:3583
|
|
||||||
# }
|
|
||||||
|
|
||||||
# Default: do not ship an active Caddyfile. The file is documentation.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# disk-monitor.sh — Check disk usage and alert when above threshold.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# disk-monitor.sh # print usage, warn at 80%
|
|
||||||
# disk-monitor.sh --warn-percent 90 # custom threshold
|
|
||||||
#
|
|
||||||
# Requires GNU coreutils df (standard on Debian). Uses --output for
|
|
||||||
# deterministic column ordering regardless of locale.
|
|
||||||
#
|
|
||||||
# Exit code 0 if under threshold, 1 if at or above.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
WARN_PERCENT=80
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--warn-percent)
|
|
||||||
WARN_PERCENT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown argument: $1" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
EXIT_CODE=0
|
|
||||||
|
|
||||||
# Partitions to check: install root and /var (Docker data-root is often here)
|
|
||||||
PARTITIONS="${PARTITIONS:-/ /var}"
|
|
||||||
|
|
||||||
for partition in $PARTITIONS; do
|
|
||||||
if [[ ! -d "$partition" ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
# GNU df --output columns: pcent (Use%), size, avail, target (Mounted on)
|
|
||||||
read -r USAGE_PCT SIZE AVAIL MOUNT <<< "$(df -h --output=pcent,size,avail,target "$partition" | awk 'NR==2 {gsub(/%/,"",$1); print $1, $2, $3, $4}')"
|
|
||||||
|
|
||||||
if [[ -z "${USAGE_PCT:-}" || ! "${USAGE_PCT:-}" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo " [skip] Could not read usage for ${partition}"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$USAGE_PCT" -ge "$WARN_PERCENT" ]]; then
|
|
||||||
echo -e " ${RED}WARN${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — threshold ${WARN_PERCENT}%"
|
|
||||||
EXIT_CODE=1
|
|
||||||
elif [[ "$USAGE_PCT" -ge $((WARN_PERCENT - 10)) ]]; then
|
|
||||||
echo -e " ${YELLOW}NOTE${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — approaching threshold"
|
|
||||||
else
|
|
||||||
echo -e " ${GREEN}OK${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE})"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
exit "$EXIT_CODE"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# docker-cleanup.sh — Remove stopped containers, dangling images, and unused networks.
|
|
||||||
#
|
|
||||||
# Safe to run via systemd timer (daily). Uses Docker's built-in pruning
|
|
||||||
# commands with conservative scope.
|
|
||||||
#
|
|
||||||
# Does NOT prune volumes. Named volumes may hold durable data and cannot be
|
|
||||||
# safely auto-pruned in a deployment that mixes stateful workloads. When the
|
|
||||||
# Orb lane lands with labeled resources, volume pruning can be scoped to
|
|
||||||
# Orb-managed labels (e.g. --filter label=org.openputer.orb). Until then,
|
|
||||||
# manage volumes manually.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "[docker-cleanup] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
||||||
|
|
||||||
# Remove stopped containers older than 24 hours
|
|
||||||
echo "[docker-cleanup] Pruning stopped containers (>24h old)..."
|
|
||||||
docker container prune -f --filter "until=24h"
|
|
||||||
|
|
||||||
# Remove dangling images (untagged intermediate layers only)
|
|
||||||
echo "[docker-cleanup] Pruning dangling images..."
|
|
||||||
docker image prune -f
|
|
||||||
|
|
||||||
# Remove unused networks
|
|
||||||
echo "[docker-cleanup] Pruning unused networks..."
|
|
||||||
docker network prune -f
|
|
||||||
|
|
||||||
# Volumes are intentionally NOT pruned. See header comment.
|
|
||||||
|
|
||||||
# Show remaining disk usage
|
|
||||||
echo "[docker-cleanup] Docker disk usage:"
|
|
||||||
docker system df
|
|
||||||
|
|
||||||
echo "[docker-cleanup] Done."
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# health-check.sh — Probe the Zopu execution plane.
|
|
||||||
#
|
|
||||||
# Checks (TCP/process-level; no assumed HTTP health routes):
|
|
||||||
# 1. zopu-daemon systemd unit is active
|
|
||||||
# 2. zopu-agent systemd unit is active
|
|
||||||
# 3. RivetKit engine TCP port (RIVET_ENDPOINT, default 6420) accepts connections
|
|
||||||
# 4. Flue agent TCP port (PORT, default 3583) accepts connections
|
|
||||||
# 5. Docker daemon is reachable
|
|
||||||
#
|
|
||||||
# Flue does not expose a health endpoint by design. RivetKit's health route
|
|
||||||
# is internal to the registry runtime. We use TCP connection checks only.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# health-check.sh # print results
|
|
||||||
# health-check.sh --quiet # suppress output, exit 0 only if all healthy
|
|
||||||
#
|
|
||||||
# Exit codes: 0 = all healthy, 1 = one or more unhealthy
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
QUIET=false
|
|
||||||
[[ "${1:-}" == "--quiet" ]] && QUIET=true
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
pass() { $QUIET || echo -e " ${GREEN}PASS${NC} $*"; }
|
|
||||||
fail() { echo -e " ${RED}FAIL${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
|
|
||||||
|
|
||||||
FAILURES=0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Load environment
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
ENV_FILE="${ENV_FILE:-/opt/zopu/.env}"
|
|
||||||
if [[ -f "$ENV_FILE" ]]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
set -a
|
|
||||||
. "$ENV_FILE"
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
RIVET_PORT="6420"
|
|
||||||
if [[ -n "${RIVET_ENDPOINT:-}" ]]; then
|
|
||||||
RIVET_PORT=$(echo "$RIVET_ENDPOINT" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')
|
|
||||||
[[ -z "$RIVET_PORT" ]] && RIVET_PORT="6420"
|
|
||||||
fi
|
|
||||||
|
|
||||||
AGENT_PORT="${PORT:-3583}"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TCP probe helper: works on Debian (nc from netcat-openbsd) and macOS.
|
|
||||||
# Falls back to bash /dev/tcp if nc is unavailable.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
tcp_probe() {
|
|
||||||
local host="$1" port="$2"
|
|
||||||
if command -v nc &>/dev/null; then
|
|
||||||
nc -z -w 5 "$host" "$port" 2>/dev/null
|
|
||||||
else
|
|
||||||
timeout 5 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Daemon systemd unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet zopu-daemon 2>/dev/null; then
|
|
||||||
pass "zopu-daemon service is active"
|
|
||||||
else
|
|
||||||
fail "zopu-daemon service is not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Agent systemd unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if systemctl is-active --quiet zopu-agent 2>/dev/null; then
|
|
||||||
pass "zopu-agent service is active"
|
|
||||||
else
|
|
||||||
fail "zopu-agent service is not active"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. RivetKit engine TCP port
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if tcp_probe localhost "$RIVET_PORT"; then
|
|
||||||
pass "RivetKit engine port ${RIVET_PORT} is accepting connections"
|
|
||||||
else
|
|
||||||
fail "RivetKit engine port ${RIVET_PORT} is not accepting connections"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 4. Flue agent TCP port
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if tcp_probe localhost "$AGENT_PORT"; then
|
|
||||||
pass "Flue agent port ${AGENT_PORT} is accepting connections"
|
|
||||||
else
|
|
||||||
fail "Flue agent port ${AGENT_PORT} is not accepting connections"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 5. Docker daemon
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if docker info &>/dev/null; then
|
|
||||||
pass "Docker daemon is reachable"
|
|
||||||
else
|
|
||||||
fail "Docker daemon is not reachable"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Summary
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
$QUIET || echo ""
|
|
||||||
if [[ "$FAILURES" -eq 0 ]]; then
|
|
||||||
$QUIET || echo -e "${GREEN}All checks passed.${NC}"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo -e "${RED}${FAILURES} check(s) failed.${NC}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# rollback.sh — Roll back to the previously deployed commit.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# rollback.sh # roll back to .last-deployed-sha
|
|
||||||
# rollback.sh <sha> # roll back to a specific commit
|
|
||||||
#
|
|
||||||
# .env is gitignored and is never touched by git operations. It survives
|
|
||||||
# updates and rollbacks unchanged.
|
|
||||||
#
|
|
||||||
# Must be run as root (uses runuser to build as the service user).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[rollback]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[rollback]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Determine target
|
|
||||||
ROLLBACK_SHA="${1:-}"
|
|
||||||
if [[ -z "$ROLLBACK_SHA" ]]; then
|
|
||||||
LAST_SHA_FILE="${INSTALL_DIR}/.last-deployed-sha"
|
|
||||||
if [[ ! -f "$LAST_SHA_FILE" ]]; then
|
|
||||||
err "No previous deployment recorded in ${LAST_SHA_FILE}."
|
|
||||||
err "Pass a commit SHA explicitly: rollback.sh <sha>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ROLLBACK_SHA=$(cat "$LAST_SHA_FILE")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Validate commit exists
|
|
||||||
if ! git rev-parse --verify "${ROLLBACK_SHA}^{commit}" &>/dev/null; then
|
|
||||||
err "Commit ${ROLLBACK_SHA} does not exist in the local repository."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
CURRENT_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Current: ${CURRENT_SHA:0:12}"
|
|
||||||
log "Rolling back to: ${ROLLBACK_SHA:0:12}"
|
|
||||||
|
|
||||||
# Save current state before rolling back (enables re-rollback)
|
|
||||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.pre-rollback-sha"
|
|
||||||
|
|
||||||
# Checkout target
|
|
||||||
git checkout "$ROLLBACK_SHA"
|
|
||||||
|
|
||||||
# Restore ownership of the checkout to the service user after git operations
|
|
||||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Confirm .env is intact and has correct permissions
|
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
|
||||||
log ".env preserved."
|
|
||||||
chmod 600 "${INSTALL_DIR}/.env"
|
|
||||||
else
|
|
||||||
err ".env is missing! Restore it from backup before starting services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building daemon..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
log "Restarting services..."
|
|
||||||
systemctl restart zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl restart zopu-agent
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
|
||||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
|
||||||
log "Running health check..."
|
|
||||||
if "$HEALTH_SCRIPT"; then
|
|
||||||
log "Rollback complete and healthy."
|
|
||||||
else
|
|
||||||
err "Health check failed after rollback!"
|
|
||||||
err " journalctl -u zopu-daemon -n 50"
|
|
||||||
err " journalctl -u zopu-agent -n 50"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# update.sh — Update the Zopu runtime to a specific branch or commit.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# update.sh # update to latest dogfood/v0
|
|
||||||
# update.sh dogfood/v0 # update to latest of branch dogfood/v0
|
|
||||||
# update.sh <commit-sha> # checkout and build a specific commit
|
|
||||||
#
|
|
||||||
# The argument is treated as a branch name first; if no matching remote
|
|
||||||
# tracking branch exists it is treated as a commit SHA.
|
|
||||||
#
|
|
||||||
# .env is gitignored and is never touched by git operations. It survives
|
|
||||||
# updates and rollbacks unchanged.
|
|
||||||
#
|
|
||||||
# Must be run as root (uses runuser to build as the service user).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
|
|
||||||
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
|
|
||||||
TARGET="${1:-dogfood/v0}"
|
|
||||||
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${GREEN}[update]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[update]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[update]${NC} $*" >&2; }
|
|
||||||
|
|
||||||
run_as_service() {
|
|
||||||
runuser -u "$SERVICE_USER" -- "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
cd "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Record current commit for rollback
|
|
||||||
CURRENT_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Current HEAD: ${CURRENT_SHA:0:12}"
|
|
||||||
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.last-deployed-sha"
|
|
||||||
|
|
||||||
# Fetch all remotes
|
|
||||||
log "Fetching from origin..."
|
|
||||||
git fetch origin
|
|
||||||
|
|
||||||
# Resolve target: branch first, then commit
|
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET}"; then
|
|
||||||
log "Target is a branch: ${TARGET}"
|
|
||||||
git checkout -B "$TARGET" "origin/${TARGET}"
|
|
||||||
elif git rev-parse --verify "${TARGET}^{commit}" &>/dev/null; then
|
|
||||||
log "Target is a commit: ${TARGET:0:12}"
|
|
||||||
git checkout "$TARGET"
|
|
||||||
else
|
|
||||||
err "'${TARGET}' is not a known branch or valid commit."
|
|
||||||
err "Available branches: $(git branch -r | tr '\n' ' ')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
NEW_SHA=$(git rev-parse HEAD)
|
|
||||||
log "Now at: ${NEW_SHA:0:12}"
|
|
||||||
|
|
||||||
# Restore ownership of the checkout to the service user after git operations
|
|
||||||
log "Setting ownership of checkout to $SERVICE_USER..."
|
|
||||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
|
||||||
|
|
||||||
# Confirm .env is intact and has correct permissions
|
|
||||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
|
||||||
log ".env preserved."
|
|
||||||
chmod 600 "${INSTALL_DIR}/.env"
|
|
||||||
else
|
|
||||||
err ".env is missing! Restore it from backup before starting services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install and build
|
|
||||||
log "Running bun install..."
|
|
||||||
run_as_service bun install
|
|
||||||
|
|
||||||
log "Building daemon binary..."
|
|
||||||
run_as_service bun run build:daemon
|
|
||||||
|
|
||||||
log "Building agent service..."
|
|
||||||
run_as_service bun run build:agents
|
|
||||||
|
|
||||||
# Graceful restart: daemon first (owns RivetKit engine), then agent
|
|
||||||
log "Restarting services..."
|
|
||||||
systemctl restart zopu-daemon
|
|
||||||
sleep 3
|
|
||||||
systemctl restart zopu-agent
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
|
|
||||||
if [[ -x "$HEALTH_SCRIPT" ]]; then
|
|
||||||
log "Running health check..."
|
|
||||||
if "$HEALTH_SCRIPT"; then
|
|
||||||
log "Update complete and healthy."
|
|
||||||
else
|
|
||||||
err "Health check failed after update!"
|
|
||||||
err " journalctl -u zopu-daemon -n 50"
|
|
||||||
err " journalctl -u zopu-agent -n 50"
|
|
||||||
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Update complete. Verify manually."
|
|
||||||
fi
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user