Compare commits
87 Commits
t3code/exp
...
t3code/ext
| 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 |
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@@ -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
|
||||
20
.env.example
20
.env.example
@@ -6,10 +6,8 @@ SITE_URL=http://localhost:5173
|
||||
NATIVE_APP_URL=code://
|
||||
|
||||
# Browser and native public endpoints
|
||||
VITE_AUTH_URL=http://localhost:5173
|
||||
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_SITE_URL=https://example.convex.site
|
||||
|
||||
@@ -19,20 +17,28 @@ DAEMON_NAME=Local MacBook
|
||||
DAEMON_VERSION=0.0.0
|
||||
DAEMON_HEARTBEAT_MS=15000
|
||||
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_DB_TOKEN=replace-with-a-long-random-token
|
||||
FLUE_URL=http://127.0.0.1:3585
|
||||
|
||||
# Agent model provider
|
||||
AGENT_MODEL_PROVIDER=cheaptricks
|
||||
AGENT_MODEL_NAME=glm-5.2
|
||||
AGENT_MODEL_PROVIDER=xiaomi
|
||||
AGENT_MODEL_NAME=mimo-v2.5
|
||||
AGENT_MODEL_API=openai-completions
|
||||
AGENT_MODEL_BASE_URL=https://ai.example.com/v1
|
||||
AGENT_MODEL_API_KEY=replace-with-provider-api-key
|
||||
AGENT_MODEL_CONTEXT_WINDOW=262000
|
||||
AGENT_MODEL_CONTEXT_WINDOW=1048576
|
||||
AGENT_MODEL_MAX_TOKENS=131072
|
||||
|
||||
# Self-hosted git (Gitea) — canonical repository host and API token
|
||||
GITEA_URL=https://git.openputer.com
|
||||
GITEA_TOKEN=replace-with-a-gitea-personal-access-token
|
||||
|
||||
# Zopu lean server (standalone chat + work actor)
|
||||
VITE_ZOPU_SERVER_URL=http://localhost:3590
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -54,3 +54,25 @@ coverage
|
||||
.cache
|
||||
tmp
|
||||
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
|
||||
|
||||
17
.vercelignore
Normal file
17
.vercelignore
Normal file
@@ -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/
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
|
||||
"files.exclude": {
|
||||
"repos/**": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"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.
|
||||
|
||||
36
apps/web/Dockerfile
Normal file
36
apps/web/Dockerfile
Normal 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/primitives": "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/node": "^8.1.0",
|
||||
"@react-router/serve": "^8.1.0",
|
||||
@@ -24,19 +22,20 @@
|
||||
"isbot": "^5.1.44",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "^8.1.0",
|
||||
"sonner": "catalog:",
|
||||
"streamdown": "2.5.0"
|
||||
"streamdown": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@react-router/dev": "^8.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vercel/react-router": "1.3.1",
|
||||
"react-router-devtools": "^6.2.1",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
import { vercelPreset } from "@vercel/react-router/vite";
|
||||
|
||||
export default {
|
||||
// Desktop addons package static web assets; SSR output cannot be bundled
|
||||
ssr: false,
|
||||
appDirectory: "src",
|
||||
presets: [vercelPreset()],
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
|
||||
162
apps/web/src/components/chat/chat-attachments.tsx
Normal file
162
apps/web/src/components/chat/chat-attachments.tsx
Normal file
@@ -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,
|
||||
MobileChatMessage,
|
||||
} from "@code/ui/components/mobile-chat";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
|
||||
import type { ChatMessageProps } from "@/lib/chat/types";
|
||||
import {
|
||||
getMessageText,
|
||||
getReasoningText,
|
||||
isReasoningStreaming,
|
||||
isMessageStreaming,
|
||||
} from "@/lib/chat/transforms";
|
||||
import type {
|
||||
AssistantResponseState,
|
||||
ChatMessageProps,
|
||||
} from "@/lib/chat/types";
|
||||
|
||||
import { AssistantIdentity } from "./assistant-identity";
|
||||
import { ChatToolCall } from "./chat-tool-call";
|
||||
import { MessageAttachments } from "./chat-attachments";
|
||||
|
||||
export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||
const isUser = message.role === "user";
|
||||
const isStreaming = isMessageStreaming(message);
|
||||
const text = getMessageText(message);
|
||||
|
||||
if (message.parts.length === 0 && !isStreaming) {
|
||||
const ReasoningTrace = ({
|
||||
isStreaming,
|
||||
text,
|
||||
}: {
|
||||
readonly isStreaming: boolean;
|
||||
readonly text: string;
|
||||
}) => {
|
||||
if (!text) {
|
||||
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;
|
||||
if (!isUser) {
|
||||
content = text ? (
|
||||
const AssistantText = ({
|
||||
hasReasoning,
|
||||
isStreaming,
|
||||
text,
|
||||
}: {
|
||||
readonly hasReasoning: boolean;
|
||||
readonly isStreaming: boolean;
|
||||
readonly text: string;
|
||||
}) => {
|
||||
if (text) {
|
||||
return (
|
||||
<MessageResponse
|
||||
caret={isStreaming ? "block" : undefined}
|
||||
className="chat-markdown min-w-0"
|
||||
@@ -34,33 +68,71 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
|
||||
>
|
||||
{text}
|
||||
</MessageResponse>
|
||||
) : (
|
||||
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (hasReasoning) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="thinking-line" aria-label="Zopu is preparing a response">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</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 content = isUser ? (
|
||||
text
|
||||
) : (
|
||||
<AssistantText
|
||||
hasReasoning={Boolean(reasoning)}
|
||||
isStreaming={isStreaming}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
const identityState = assistantState(isStreaming, reasoningStreaming, text);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<MobileChatMessage className="chat-message" sender={sender}>
|
||||
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
|
||||
{!isUser && (
|
||||
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
|
||||
)}
|
||||
{isUser ? null : <AssistantIdentity state={identityState} />}
|
||||
<MobileChatBubble
|
||||
className={isUser ? "whitespace-pre-wrap" : undefined}
|
||||
sender={sender}
|
||||
>
|
||||
{message.parts.map((part) =>
|
||||
part.type === "dynamic-tool" ? (
|
||||
<ChatToolCall key={part.toolCallId} part={part} />
|
||||
) : null
|
||||
<MessageAttachments parts={fileParts} />
|
||||
{isUser ? null : (
|
||||
<ReasoningTrace
|
||||
isStreaming={reasoningStreaming}
|
||||
text={reasoning}
|
||||
/>
|
||||
)}
|
||||
{content}
|
||||
</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} />;
|
||||
};
|
||||
74
apps/web/src/components/projects/add-project-panel.tsx
Normal file
74
apps/web/src/components/projects/add-project-panel.tsx
Normal file
@@ -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
|
||||
);
|
||||
};
|
||||
70
apps/web/src/components/projects/context-editor.tsx
Normal file
70
apps/web/src/components/projects/context-editor.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
36
apps/web/src/components/projects/github-connect-button.tsx
Normal file
36
apps/web/src/components/projects/github-connect-button.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
189
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
189
apps/web/src/components/projects/github-connection-settings.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
7
apps/web/src/components/projects/loading-state.tsx
Normal file
7
apps/web/src/components/projects/loading-state.tsx
Normal file
@@ -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>
|
||||
);
|
||||
76
apps/web/src/components/projects/project-card.tsx
Normal file
76
apps/web/src/components/projects/project-card.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
65
apps/web/src/components/projects/projects-grid.tsx
Normal file
@@ -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>
|
||||
);
|
||||
51
apps/web/src/components/projects/projects-header.tsx
Normal file
51
apps/web/src/components/projects/projects-header.tsx
Normal file
@@ -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>
|
||||
);
|
||||
166
apps/web/src/components/projects/provider-chips.tsx
Normal file
166
apps/web/src/components/projects/provider-chips.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
46
apps/web/src/components/projects/provider-section.tsx
Normal file
46
apps/web/src/components/projects/provider-section.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
80
apps/web/src/components/projects/puter-connect-form.tsx
Normal file
80
apps/web/src/components/projects/puter-connect-form.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
346
apps/web/src/components/projects/repository-selector.tsx
Normal file
346
apps/web/src/components/projects/repository-selector.tsx
Normal file
@@ -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 * as React from "react";
|
||||
|
||||
export function ThemeProvider({
|
||||
export const ThemeProvider = ({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) => (
|
||||
<NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
);
|
||||
|
||||
export { useTheme } from "next-themes";
|
||||
|
||||
112
apps/web/src/components/workspace/conversation-composer.tsx
Normal file
112
apps/web/src/components/workspace/conversation-composer.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
108
apps/web/src/components/workspace/conversation-feed.tsx
Normal file
108
apps/web/src/components/workspace/conversation-feed.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
50
apps/web/src/components/workspace/project-connect-form.tsx
Normal file
50
apps/web/src/components/workspace/project-connect-form.tsx
Normal file
@@ -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>
|
||||
);
|
||||
51
apps/web/src/components/workspace/project-header.tsx
Normal file
51
apps/web/src/components/workspace/project-header.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
123
apps/web/src/components/workspace/project-workspace-page.tsx
Normal file
123
apps/web/src/components/workspace/project-workspace-page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
343
apps/web/src/components/workspace/work-card.tsx
Normal file
343
apps/web/src/components/workspace/work-card.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/components/workspace/work-feed.tsx
Normal file
29
apps/web/src/components/workspace/work-feed.tsx
Normal file
@@ -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 { CHAT_AGENT } from "@/lib/chat/constants";
|
||||
import type { ChatAgentState } from "@/lib/chat/types";
|
||||
import { projectConversation } from "@/lib/chat/conversation";
|
||||
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 = (
|
||||
organization: PersonalOrganizationState
|
||||
): ChatAgentState => {
|
||||
const agent = useFlueAgent({
|
||||
...CHAT_AGENT,
|
||||
id: organization.organizationId,
|
||||
});
|
||||
const { organizationId } = organization;
|
||||
const rows = useQuery(
|
||||
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> => {
|
||||
if (!organization.organizationId) {
|
||||
const projected = projectConversation(rows ?? []);
|
||||
|
||||
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 (
|
||||
organization.error ??
|
||||
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 {
|
||||
error: organization.error ?? agent.error,
|
||||
historyReady: agent.historyReady,
|
||||
messages: agent.messages,
|
||||
error:
|
||||
organization.error ??
|
||||
sendError ??
|
||||
(projected.failedError ? new Error(projected.failedError) : undefined),
|
||||
historyReady: rows !== undefined,
|
||||
messages: projected.messages,
|
||||
sendMessage,
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatAgent = (): ChatAgentState =>
|
||||
useOrganizationChatAgent(usePersonalOrganization());
|
||||
|
||||
79
apps/web/src/hooks/chat/use-chat-images.ts
Normal file
79
apps/web/src/hooks/chat/use-chat-images.ts
Normal file
@@ -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;
|
||||
};
|
||||
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
25
apps/web/src/hooks/use-filtered-projects.ts
Normal file
@@ -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]);
|
||||
102
apps/web/src/hooks/use-github-repo-search.ts
Normal file
102
apps/web/src/hooks/use-github-repo-search.ts
Normal file
@@ -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 { api } from "@code/backend/convex/_generated/api";
|
||||
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";
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
@@ -13,6 +13,8 @@ export interface PersonalOrganizationState {
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const auth = useWebAuth();
|
||||
const { isAuthenticated, isRefreshing } = useConvexAuth();
|
||||
const convexReady = isAuthenticated && !isRefreshing;
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
@@ -25,7 +27,7 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
if (!convexReady || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,9 +59,9 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
}, [convexReady, ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!userId || state?.userId !== userId) {
|
||||
if (!convexReady || !userId || state?.userId !== userId) {
|
||||
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;
|
||||
};
|
||||
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
22
apps/web/src/hooks/use-projects-page-state.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
64
apps/web/src/hooks/use-repository-picker.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
232
apps/web/src/hooks/workspace/use-project-workspace.ts
Normal file
232
apps/web/src/hooks/workspace/use-project-workspace.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
26
apps/web/src/hooks/workspace/use-visual-viewport.test.ts
Normal file
26
apps/web/src/hooks/workspace/use-visual-viewport.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
52
apps/web/src/hooks/workspace/use-visual-viewport.ts
Normal file
52
apps/web/src/hooks/workspace/use-visual-viewport.ts
Normal file
@@ -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%;
|
||||
}
|
||||
|
||||
html.workspace-viewport-lock,
|
||||
html.workspace-viewport-lock body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.ai-conversation-mobile > div {
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
@@ -68,6 +75,19 @@ body {
|
||||
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 {
|
||||
0%,
|
||||
60%,
|
||||
@@ -96,6 +116,45 @@ body {
|
||||
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 {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -197,6 +256,16 @@ body {
|
||||
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) {
|
||||
.chat-markdown *,
|
||||
.chat-message,
|
||||
|
||||
63
apps/web/src/lib/auth.server.ts
Normal file
63
apps/web/src/lib/auth.server.ts
Normal file
@@ -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;
|
||||
};
|
||||
23
apps/web/src/lib/chat/attachments.test.ts
Normal file
23
apps/web/src/lib/chat/attachments.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
28
apps/web/src/lib/chat/attachments.ts
Normal file
28
apps/web/src/lib/chat/attachments.ts
Normal file
@@ -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
|
||||
* (resolved at runtime by the chat hook), not the legacy `main` shared
|
||||
* instance. Organization scoping is the hard tenancy boundary.
|
||||
*/
|
||||
export const CHAT_AGENT = {
|
||||
live: "sse",
|
||||
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 MODEL_ID = "openrouter/openai/gpt-oss-20b:free";
|
||||
export const MODEL_LABEL = "GPT-OSS 20B Free";
|
||||
|
||||
export const STATUS_COPY: Record<AgentStatus, string> = {
|
||||
connecting: "Connecting",
|
||||
|
||||
94
apps/web/src/lib/chat/conversation.test.ts
Normal file
94
apps/web/src/lib/chat/conversation.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
83
apps/web/src/lib/chat/conversation.ts
Normal file
83
apps/web/src/lib/chat/conversation.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
43
apps/web/src/lib/chat/transforms.test.ts
Normal file
43
apps/web/src/lib/chat/transforms.test.ts
Normal file
@@ -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 => {
|
||||
const withoutCompletedBlocks = text.replaceAll(
|
||||
@@ -15,24 +15,61 @@ export const stripThinkingMarkup = (text: string): string => {
|
||||
return visibleText.replaceAll("</think>", "").trimStart();
|
||||
};
|
||||
|
||||
export const getMessageText = (message: FlueConversationMessage): string =>
|
||||
stripThinkingMarkup(
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
);
|
||||
|
||||
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
|
||||
message.parts.some((part) => {
|
||||
if (part.type === "dynamic-tool") {
|
||||
return part.state === "input-available";
|
||||
export const extractThinkingMarkup = (text: string): string => {
|
||||
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 (
|
||||
}
|
||||
return completed.join("\n\n");
|
||||
};
|
||||
|
||||
export const getRawMessageText = (message: ConversationMessage): string =>
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
|
||||
export const getMessageText = (message: ConversationMessage): string =>
|
||||
stripThinkingMarkup(getRawMessageText(message));
|
||||
|
||||
export const getReasoningText = (message: ConversationMessage): string => {
|
||||
const nativeReasoning = message.parts
|
||||
.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.state === "streaming"
|
||||
);
|
||||
});
|
||||
);
|
||||
|
||||
export const getStatusDotClass = (status: AgentStatus): string => {
|
||||
if (status === "error") {
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
import type {
|
||||
AgentStatus,
|
||||
FlueConversationMessage,
|
||||
FlueConversationPart,
|
||||
} from "@flue/react";
|
||||
export type AgentStatus =
|
||||
| "connecting"
|
||||
| "error"
|
||||
| "idle"
|
||||
| "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 {
|
||||
error?: Error;
|
||||
historyReady: boolean;
|
||||
messages: FlueConversationMessage[];
|
||||
sendMessage: (message: string) => Promise<void>;
|
||||
messages: ConversationMessage[];
|
||||
sendMessage: (
|
||||
message: string,
|
||||
options?: { readonly images?: readonly File[] }
|
||||
) => Promise<void>;
|
||||
status: AgentStatus;
|
||||
}
|
||||
|
||||
@@ -20,7 +49,7 @@ export interface ChatComposerProps {
|
||||
|
||||
export interface ChatConversationProps {
|
||||
historyReady: boolean;
|
||||
messages: FlueConversationMessage[];
|
||||
messages: ConversationMessage[];
|
||||
onSuggestion: (suggestion: string) => void;
|
||||
status: AgentStatus;
|
||||
}
|
||||
@@ -30,11 +59,7 @@ export interface ChatHeaderProps {
|
||||
}
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: FlueConversationMessage;
|
||||
}
|
||||
|
||||
export interface ChatToolCallProps {
|
||||
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
|
||||
message: ConversationMessage;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
};
|
||||
13
apps/web/src/lib/users/get-user-initials.test.ts
Normal file
13
apps/web/src/lib/users/get-user-initials.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getUserInitials } from "./get-user-initials";
|
||||
|
||||
describe("getUserInitials", () => {
|
||||
it("uses the first two name parts", () => {
|
||||
expect(getUserInitials("Sai Karthik")).toBe("SK");
|
||||
});
|
||||
|
||||
it("uses a neutral fallback while auth loads", () => {
|
||||
expect(getUserInitials()).toBe("U");
|
||||
});
|
||||
});
|
||||
10
apps/web/src/lib/users/get-user-initials.ts
Normal file
10
apps/web/src/lib/users/get-user-initials.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const getUserInitials = (name?: string): string => {
|
||||
const parts = name?.trim().split(/\s+/u).filter(Boolean) ?? [];
|
||||
if (parts.length === 0) {
|
||||
return "U";
|
||||
}
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
};
|
||||
78
apps/web/src/lib/workspace/frontend-regressions.test.ts
Normal file
78
apps/web/src/lib/workspace/frontend-regressions.test.ts
Normal file
@@ -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)");
|
||||
});
|
||||
});
|
||||
53
apps/web/src/lib/workspace/presentation.test.ts
Normal file
53
apps/web/src/lib/workspace/presentation.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
75
apps/web/src/lib/workspace/presentation.ts
Normal file
75
apps/web/src/lib/workspace/presentation.ts
Normal file
@@ -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;
|
||||
140
apps/web/src/lib/workspace/types.ts
Normal file
140
apps/web/src/lib/workspace/types.ts
Normal file
@@ -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 ?? [];
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
|
||||
import { env } from "@code/env/web";
|
||||
import { WebAuthProvider } from "@code/auth/web";
|
||||
import { Toaster } from "@code/ui/components/sonner";
|
||||
import { FlueProvider } from "@flue/react";
|
||||
|
||||
import "./index.css";
|
||||
import { createFlueClient } from "@flue/sdk";
|
||||
import { useMemo } from "react";
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
@@ -13,11 +10,14 @@ import {
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useNavigation,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
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 = () => [
|
||||
{ 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 }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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 />
|
||||
<Links />
|
||||
</head>
|
||||
@@ -76,20 +51,42 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
</html>
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<WebAuthProvider>
|
||||
<AuthenticatedFlueProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<Outlet />
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</AuthenticatedFlueProvider>
|
||||
const RouteProgress = () => {
|
||||
const navigation = useNavigation();
|
||||
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
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<RouteProgress />
|
||||
<Outlet />
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</WebAuthProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -7,13 +7,7 @@ export default [
|
||||
route("signup", "./routes/auth/signup/page.tsx"),
|
||||
]),
|
||||
layout("./routes/app/layout.tsx", [
|
||||
layout("./routes/app/mobile/layout.tsx", [
|
||||
index("./routes/app/mobile/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"),
|
||||
index("./routes/app/workspace/page.tsx"),
|
||||
route("projects", "./routes/app/projects/page.tsx"),
|
||||
]),
|
||||
] 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 { Navigate, Outlet, useLocation } from "react-router";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
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() {
|
||||
const auth = useWebAuth();
|
||||
const location = useLocation();
|
||||
const organization = usePersonalOrganization();
|
||||
const navigate = useNavigate();
|
||||
const projects = useQuery(
|
||||
api.projects.list,
|
||||
organization.organizationId ? {} : "skip"
|
||||
);
|
||||
|
||||
if (auth.status === "loading") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "inconsistent") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Your session could not be verified
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "unauthenticated") {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (
|
||||
projects !== undefined &&
|
||||
projects.length === 0 &&
|
||||
window.location.pathname === "/" &&
|
||||
!window.location.search.includes("resume=") &&
|
||||
!window.location.search.includes("project=")
|
||||
) {
|
||||
void navigate("/projects", { replace: true });
|
||||
}
|
||||
}, [projects, navigate]);
|
||||
|
||||
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" />;
|
||||
}
|
||||
125
apps/web/src/routes/app/projects/page.tsx
Normal file
125
apps/web/src/routes/app/projects/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/routes/app/workspace/page.tsx
Normal file
5
apps/web/src/routes/app/workspace/page.tsx
Normal file
@@ -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 { Navigate, Outlet } from "react-router";
|
||||
import { 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() {
|
||||
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 (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoginForm } from "@code/auth/web";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
@@ -10,6 +10,14 @@ export const meta = (_args: Route.MetaArgs) => [
|
||||
|
||||
export default function LoginRoute() {
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,27 @@ import path from "node:path";
|
||||
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite-plus";
|
||||
import { defineConfig, loadEnv } from "vite-plus";
|
||||
|
||||
export default defineConfig({
|
||||
envDir: path.resolve(import.meta.dirname, "../.."),
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
dedupe: ["convex", "react", "react-dom"],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
export default defineConfig(({ mode }) => {
|
||||
const envDir = path.resolve(import.meta.dirname, "../..");
|
||||
const env = loadEnv(mode, envDir, "");
|
||||
|
||||
return {
|
||||
envDir,
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
dedupe: ["convex", "react", "react-dom"],
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
"/api/auth": {
|
||||
changeOrigin: true,
|
||||
target: env.CONVEX_SITE_URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
63
deploy/compose/Caddyfile
Normal file
63
deploy/compose/Caddyfile
Normal file
@@ -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
|
||||
}
|
||||
55
deploy/compose/docker-compose.yml
Normal file
55
deploy/compose/docker-compose.yml
Normal file
@@ -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
|
||||
7
deploy/compose/rivet-config.json
Normal file
7
deploy/compose/rivet-config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://rivet.dev/engine-config-schema.json",
|
||||
"file_system": {
|
||||
"path": "/data"
|
||||
},
|
||||
"singleplayer": false
|
||||
}
|
||||
17
deploy/systemd/Caddyfile
Normal file
17
deploy/systemd/Caddyfile
Normal file
@@ -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
|
||||
}
|
||||
23
deploy/systemd/zopu-agents.service
Normal file
23
deploy/systemd/zopu-agents.service
Normal file
@@ -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
|
||||
4
deploy/systemd/zopu-caddy.service.conf
Normal file
4
deploy/systemd/zopu-caddy.service.conf
Normal file
@@ -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