Compare commits

..

72 Commits

Author SHA1 Message Date
-Puter
6364c33499 updates 2026-08-01 22:45:22 +05:30
-Puter
db9afd7a24 fix(lint): resolve all lint errors in github search and settings UI
- Merge duplicate react imports in repository-selector
- Use <dialog open> instead of role=dialog in settings popover
- Export type re-export from source module in use-github-repo-search
- Sort action args alphabetically in searchGithubRepositories
- Disable react-compiler rule for synchronous setState in debounced search
- Simplify getActiveGithubConnection query
2026-08-01 20:38:57 +05:30
-Puter
ce16813fbc feat(github): live repo search with /user/repos pagination and connection gear UI
- searchGithubRepositories action fetches up to 300 repos via paginated
  /user/repos, filters by name, upserts results into gitRepositories so
  createProjectFromRepository works unchanged; token never leaves backend
- connectGithub now persists grantedScopesJson from getAccessToken().scopes
- listProviderAccounts and list queries return grantedScopesJson
- Gear/settings popover on connected GitHub chip shows scopes and triggers
  linkSocial with expanded scope set [repo, read:org, read:user, user:email]
- RepositorySelector merges debounced live search results with synced snapshot
- Restored CONVEX_SITE_URL env var (removed in prior commit but still used)
2026-08-01 20:33:55 +05:30
-Puter
95c23db4fc feat(web): debounced github repo search and connection settings
Search hook calls gitConnections:searchGithubRepositories with the
stored encrypted credential (token never reaches the browser).
Connection settings popover shows granted scopes and drives
reauthorization with the expanded scope set.
2026-08-01 20:18:27 +05:30
-Puter
08bc5ae259 feat(web): vercel deployment and vds compose/systemd setup
Add Vercel config with React Router preset, vercel-build script and
/api/auth rewrite to the Convex site. Add VDS staging compose for the
Rivet engine and runner plus systemd units, deployment plan, and
Docker/Vercel ignore rules. Lockfile covers both the web and agents
dependency additions.
2026-08-01 20:18:09 +05:30
-Puter
4dc878b8cb fix(agents): bundle workspace deps into flue runtime
Replace @code/env and @code/primitives imports with local modules so
the Node deploy artifact does not resolve workspace packages to
TypeScript source at runtime. Switch Docker build to pnpm frozen
install, add runner Dockerfile and a liveness health endpoint.
2026-08-01 20:17:49 +05:30
-Puter
3e3f06403f fix(backend): github connection identity and live repo search
Resolve identity in actions and pass organizationId into sub-queries,
since runQuery from an action does not propagate auth. Add live GitHub
repository search backed by the stored encrypted credential, persisting
results into gitRepositories. Webhook now targets CONVEX_SITE_URL
directly instead of the web origin.
2026-08-01 20:17:45 +05:30
-Puter
8bd3953440 flue flow fixes 2026-07-31 23:43:49 +05:30
-Puter
bb4bd9e0d3 .ts import fixees 2026-07-31 21:00:47 +05:30
-Puter
f208d4e4b8 projects layout 2026-07-31 20:52:43 +05:30
-Puter
fc1d0b6916 github 0auth fixes 2026-07-31 19:29:00 +05:30
-Puter
fc8cfabacb refactor projects page 2026-07-31 18:53:04 +05:30
-Puter
ce1f33aa69 feat: consolidate agent execution stack 2026-07-31 18:26:41 +05:30
-Puter
c644ec8d01 feat(git): thin project onboarding, provider integration, and AgentOS repository access
Effect primitives:
- git-provider: GitProvider, connection states, normalized errors, URL
  normalization, host compatibility, credential freshness window
- git-provisioning: validated Puter commands, migration states, idempotency
  keys, safe replacement rules, owner-safe guards
- git-webhook: supported events, signature verification, delivery states
- host-repository: provider-neutral credential-safe clone via GIT_ASKPASS

Normalized Convex schema:
- gitProviderAccounts, refined gitConnections, gitProviderOrganizations,
  gitRepositories, gitMigrations, gitWebhookDeliveries
- projects: gitRepositoryId + instructions fields
- Schema fields optional for backward compatibility, with backfill cron

Backend:
- Connection health: verify action, hourly reconciliation (covers stale
  active + reauth-required + undefined-state legacy connections)
- Puter provisioning: createPuterUser/Organization/Repository with owner
  binding, startGithubMigration (durable via scheduler), getMigration
- Org ownership: explicit member add with admin role + verification
- Webhook HTTP actions: HMAC verification, delivery persistence with
  idempotency, repository resolution, byte-length payload limit
- Automatic Puter webhook creation after repo creation/migration with
  fail-loud state tracking
- Repository sync after connection (Gitea + GitHub)
- AgentOS execution resolves gitRepositoryId for real clone URL
- Credential gating: state + freshness checks before execution and
  project creation
- listForOrganization for cross-project Work filtering

Frontend:
- /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT
- Zero-project redirect, repository selection, context editor
- Provider-aware settings panel (no serverUrl for Puter)
- Project selection via ?project= query param
- GitHub scopes: repo + read:org

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
2026-07-31 14:36:56 +05:30
-Puter
88f53f550d Zopu Agent Tool Fixing 2026-07-31 01:31:12 +05:30
-Puter
54a5993274 Minor iOS phone fix 2026-07-31 01:30:59 +05:30
-Puter
def3fc949a doc 2026-07-31 01:30:45 +05:30
-Puter
9f7dd3c1a6 fuck them garbage deploys 2026-07-29 20:49:52 +05:30
-Puter
a8b2ff5e2e feat(agents): add 2026-07-29 20:11:08 +05:30
-Puter
40e0f7e1eb fix(workspace): ensure chat markdown readability on forced-dark surface 2026-07-29 19:34:20 +05:30
-Puter
bf2300a6be feat: same-origin auth via reverse proxy and secure cookies
Route /api/auth through the same origin in both dev and prod so
cookies stay first-party and the browser and React Router SSR share
one auth surface.

- Vite dev server proxies /api/auth to the Convex HTTP site.
- Better Auth uses secure cookies when the site URL is https.
- Add docs/auth-proxy.md documenting the required production ingress
  (Caddy/Traefik) and Convex SITE_URL / CONVEX_SITE_URL setup.
2026-07-29 19:32:26 +05:30
-Puter
0657037c84 feat: make Rivet endpoint optional for self-hosted runtime
RIVET_ENDPOINT and RIVET_PUBLIC_ENDPOINT are now optional in the agent
env schema; empty strings are coerced to undefined. The agent runtime
only passes an endpoint override to the Rivet client when one is set,
so the client falls back to its default (suitable for self-hosted
Rivet where no explicit endpoint is required).
2026-07-29 19:32:22 +05:30
-Puter
64a783b445 build: allow importing .ts extensions in Convex tsconfig
Enable allowImportingTsExtensions in the Convex tsconfig so the
backend code that uses explicit .ts import specifiers type-checks
cleanly.
2026-07-29 19:32:18 +05:30
-Puter
ed28943e7a fix: drop .ts extensions from primitives imports
Use extensionless import specifiers in project.ts and work.ts so the
modules resolve under Node ESM and bundler resolution, consistent with
allowImportingTsExtensions on the consuming packages.
2026-07-29 19:32:15 +05:30
-Puter
9e148489f0 chore: stop hiding repos/ in VS Code file explorer
Drop the files.exclude rule for repos/**. The directory is still
excluded from auto-imports and file watching, but no longer hidden
from the explorer.
2026-07-29 19:32:10 +05:30
-Puter
25f86d94cc chore: broaden .env ignore patterns
Add .env.* to .gitignore so per-environment secrets like
.env.staging and .env.production are never tracked, alongside the
existing .env and .env*.local rules.
2026-07-29 19:32:05 +05:30
-Puter
7668fa69cc fix: route web auth through same origin 2026-07-29 17:01:22 +05:30
-Puter
3ffa1cfc7c feat: project durable agent conversations 2026-07-29 08:19:17 +05:30
-Puter
18eb150d7d fix: serve web assets from app directory 2026-07-29 08:13:52 +05:30
-Puter
e1b0b731e0 deploy: attach backend to shared network 2026-07-29 08:09:30 +05:30
-Puter
d428a2492b fix: make primitives Node ESM compatible 2026-07-29 07:51:07 +05:30
-Puter
fe0fd9b16c deploy: enable Node TypeScript export resolution 2026-07-29 07:40:44 +05:30
-Puter
fc1fcf5d44 deploy: run agent backend with Bun 2026-07-29 07:31:36 +05:30
-Puter
3ae72864bd deploy: target agent package during Flue build 2026-07-29 07:21:02 +05:30
-Puter
0d5d54caa8 deploy: build agent without local env file 2026-07-29 07:15:19 +05:30
-Puter
830bcc4756 deploy: build applications on Node base 2026-07-29 07:09:07 +05:30
-Puter
0e56a462cd deploy: prefer Node for native install scripts 2026-07-29 07:03:32 +05:30
-Puter
9fb293a539 deploy: build native modules with Node 2026-07-29 07:00:47 +05:30
-Puter
062c00f53c deploy: install frontend build toolchain 2026-07-29 06:58:04 +05:30
-Puter
a7e70c9b2a deploy: install backend build toolchain 2026-07-29 06:56:36 +05:30
-Puter
526ed59776 deploy: fix compose build contexts 2026-07-29 06:46:07 +05:30
-Puter
fd3980c6bf deploy: split Dokploy services 2026-07-29 06:42:06 +05:30
-Puter
d8a4bbe804 chore cleanp 2026-07-29 01:24:37 +05:30
-Puter
9eb6bcd25f refactor canonical components and remove slice 1 2026-07-29 01:24:16 +05:30
-Puter
a907539810 flue refactor and migrations 2026-07-29 00:45:46 +05:30
-Puter
601aca73c2 pnpm migrations 2026-07-29 00:45:33 +05:30
-Puter
ffecff3857 Run fixed Zopu worktrees with Pi 2026-07-28 22:20:42 +05:30
-Puter
0d7162544b fix: run AgentOS workspaces on remote runner 2026-07-28 20:08:46 +05:30
-Puter
092a9793ea fix: resolve repository hosts outside AgentOS 2026-07-28 18:30:08 +05:30
-Puter
5ee0a8d50e fix: allow AgentOS process environment 2026-07-28 18:27:16 +05:30
-Puter
420676f2d7 fix: allow AgentOS command execution 2026-07-28 18:21:31 +05:30
-Puter
24d82e2a06 fix: allow AgentOS workspace filesystem 2026-07-28 18:18:24 +05:30
-Puter
d47fa0e96a fix: allow slow AgentOS workspace boot 2026-07-28 18:14:32 +05:30
-Puter
1e7c893985 fix: apply AgentOS runtime permissions 2026-07-28 17:49:41 +05:30
-Puter
f9ebcb4a01 fix: align AgentOS runtime packages 2026-07-28 17:40:40 +05:30
-Puter
dceaa2b417 fix: allow AgentOS outbound networking 2026-07-28 17:29:47 +05:30
-Puter
a4f121e190 chore: preserve work attempt schema order 2026-07-28 17:12:54 +05:30
-Puter
4edd456b5b feat: wire real AgentOS execution 2026-07-28 16:52:49 +05:30
-Puter
4d9f6da41b fix: install AgentOS Codex session adapter 2026-07-28 16:09:34 +05:30
-Puter
a53029cf7a fix: await Rivet envoy registration 2026-07-28 15:34:08 +05:30
-Puter
eede4c10ba fix: build runner native modules with Node 2026-07-28 15:20:03 +05:30
-Puter
35169672e1 fix: add runner native build toolchain 2026-07-28 15:12:57 +05:30
-Puter
359d9e2285 feat: add durable AgentOS slice execution 2026-07-28 14:50:45 +05:30
-Puter
05a3baaac3 fix: bundle all SSR deps to eliminate React instance duplication 2026-07-28 03:27:41 +05:30
-Puter
4cc40cb2e4 fix: broaden SSR noExternal to cover all React-dependent workspace packages 2026-07-28 03:25:27 +05:30
-Puter
814df02be9 fix: bundle @convex-dev/better-auth in SSR to prevent React instance duplication 2026-07-28 03:21:41 +05:30
-Puter
d7f6cbdcdc fix: pin react/react-dom to 19.2.8 to prevent SSR duplicate instance crash 2026-07-28 03:16:34 +05:30
-Puter
a8d946b6a9 Slice 4 control-plane repairs, lint/catalog cleanup, Flue adapter fixes
Convex control plane (Slices 1-4 prerequisites for Slice 5):
- Versioned Definition/Design persistence: revisions preserve history
  instead of deleting prior slices/approvals
- Fenced conversation turn queue: attempt-numbered leases prevent stale
  worker overwrites; expired turns reconciled by cron
- Fenced Run/Attempt claiming: only queued attempts from running Runs can
  be claimed; lease expiry checks on every finish/checkpoint
- Multi-slice progression: successful slice marks next slice ready instead
  of completing the whole Work; completed Runs blocked from retry
- Typed AttemptClassification in schema and resolver decisions table
- Durable resolver decisions for normal outcomes, cancellation, and
  lease-expiry recovery
- Persistent artifacts and delivery metadata with provenance, source
  revision, verification status, and controlled delivery transitions
- High-impact open questions block definition approval
- Question submission gated to defining/awaiting-approval states only
- Delivery status updates validate JSON metadata before persisting
- Planner failures recorded as durable blocked events
- Approval identity uses canonical tokenIdentifier

Primitives:
- decodeDefinition separates decode from validate
- Design validation enforces 1-4 slices with unique IDs and valid deps
- Artifact and delivery draft schemas with verified-revision binding
- Delivery status transition table

Package management:
- Consolidated shared deps into single catalog (hono, valibot, streamdown,
  @types/react, @types/node, @tailwindcss/*, react-native)
- Removed cross-version Hono boundary: flue() exported as Fetchable
- Deleted bun.lock and regenerated from clean catalog resolution

Lint/format:
- Removed .eslintignore; oxc uses native ignorePatterns in oxlint.config.ts
- Deleted redundant packages/backend/.oxlintrc.json
- Added repos/** and scripts/** to ignore patterns
- Convex-specific rule overrides for ES2022 target constraints
- Fixed all oxc errors in fluePersistence.ts and agents/src/db.ts
- Fixed all formatting across changed files
2026-07-28 02:53:05 +05:30
0e32c35515 Slice 4 hardening: bounded resolver, terminal states, reconciliation (#21)
Fixes review findings before Slice 5. Resolver: resolveOutcome + WORK_STATUS_FOR_OUTCOME + lifecycle fixes. Execution: bounded finishAttempt retry, terminal Work status preservation, slice validation, bounded reconciliation with 30s cron. Planning: revision guards, durable submitQuestion, design-approval invalidation. Primitives 69 (+4), backend 22 (+10) tests; root typecheck/build/Ultracite pass.
2026-07-27 18:39:09 +00:00
-Puter
005b26fa32 Slices 2-4: Work becomes executable
Domain primitives: versioned WorkDefinition/DesignPacket with risk, questions,
and approval gates; lifecycle transition table with invalidation rules;
Resolver (Run/Attempt/outcomes/CodingKitV0); provider-neutral HarnessRuntime
with normalized events and a deterministic FakeHarnessLive that always reaches
a terminal classification and never claims implementation.

Convex durable model: workDefinitions, workQuestions, workApprovals,
designPackets, workSlices, workRuns, workAttempts, workAttemptEvents tables;
broadened works.status union; generalized workEvents with optional signalId,
referenceId, and payloadJson. Authenticated commands for definition request/
save/revise/approve, question answer/withdraw, design save/revise/approve, and
simulated execution start/cancel/retry with leased attempts, checkpointed
events, and expired-lease reconciliation.

Private work-planner FLUE agent with proposal-only tools (definition, design,
question). Convex validates and stores every proposal; FLUE never approves or
advances Work.

Expanded Work card with Outcome, Design, and Build sections wired to the new
mutations. Slice 1 conversation and provenance preserved.
2026-07-27 22:52:14 +05:30
cb7484912c Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20) 2026-07-27 16:03:36 +00:00
-Puter
cc47007fa9 Slice 1 polish: archive dormant code, merge work-os into primitives, add futures
- Archive dormant apps (daemon, desktop, native, tui) and superseded
  packages/server into repos/ for reference
- Archive 21 dead web components (chat page shell, work-os cluster, strays)
  into repos/web/; keep reused message-rendering primitives in components/chat
- Merge @code/work-os into @code/primitives; work.ts absorbed via ./work
  subpath and barrel export; update all four importers
- Add docs/futures.md tracking audio/video (blocked at Flue + provider),
  web layout cleanup, and message rendering quality
- Repoint dev:zopu script to @code/agents (the live Flue server)
- Note repos/ reference-code convention in AGENTS.md
2026-07-27 16:34:17 +05:30
419 changed files with 40359 additions and 26846 deletions

35
.dockerignore Normal file
View 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

View File

@@ -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,10 +17,15 @@ 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=xiaomi

22
.gitignore vendored
View File

@@ -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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
verify-deps-before-run=false

17
.vercelignore Normal file
View 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/

View File

@@ -1,9 +1,6 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"files.exclude": {
"repos/**": true
},
"files.watcherExclude": {
"repos/**": true
},

View File

@@ -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 12 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 12 dense sentences, replacing stale guidance rather than appending; this limit applies per instruction, not to the whole file.

36
apps/web/Dockerfile Normal file
View 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"]

View File

@@ -15,9 +15,6 @@
"@code/env": "workspace:*",
"@code/primitives": "workspace:*",
"@code/ui": "workspace:*",
"@code/work-os": "workspace:*",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
"@react-router/fs-routes": "^8.1.0",
"@react-router/node": "^8.1.0",
"@react-router/serve": "^8.1.0",
@@ -25,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:",

View File

@@ -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;

View File

@@ -9,13 +9,13 @@ import {
AttachmentMedia,
AttachmentTitle,
} from "@code/ui/components/attachment";
import type { FlueConversationPart } from "@flue/react";
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<FlueConversationPart, { type: "file" }>;
type FilePart = Extract<ConversationPart, { type: "file" }>;
const isDirectlyRenderableUrl = (url: string): boolean =>
url.startsWith("blob:") || url.startsWith("data:");

View File

@@ -7,12 +7,10 @@ import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { FlueConversationMessage } from "@flue/react";
import {
getMessageText,
getReasoningText,
hasToolActivity,
isReasoningStreaming,
isMessageStreaming,
} from "@/lib/chat/transforms";
@@ -23,7 +21,6 @@ import type {
import { AssistantIdentity } from "./assistant-identity";
import { MessageAttachments } from "./chat-attachments";
import { ChatToolCall } from "./chat-tool-call";
const ReasoningTrace = ({
isStreaming,
@@ -96,27 +93,7 @@ const assistantState = (
return isStreaming ? "writing" : undefined;
};
const ToolActivity = ({
hidden,
message,
}: {
readonly hidden: boolean;
readonly message: FlueConversationMessage;
}) => {
if (hidden) {
return null;
}
return message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
);
};
export const ChatMessage = ({
hideToolActivity = false,
message,
}: ChatMessageProps) => {
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
@@ -124,16 +101,6 @@ export const ChatMessage = ({
const reasoningStreaming = isReasoningStreaming(message);
const fileParts = message.parts.filter((part) => part.type === "file");
if (
hideToolActivity &&
!isUser &&
hasToolActivity(message) &&
!text &&
!reasoning
) {
return null;
}
if (message.parts.length === 0 && !isStreaming) {
return null;
}
@@ -167,7 +134,6 @@ export const ChatMessage = ({
text={reasoning}
/>
)}
<ToolActivity hidden={hideToolActivity} message={message} />
{content}
</MobileChatBubble>
</div>

View File

@@ -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>
);
};

View File

@@ -1,200 +0,0 @@
import { signOutWeb, useWebAuth } from "@code/auth/web";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkChatScreen,
MobileWorkListScreen,
MobileWorkStackScreen,
MobileWorkUnitDetailScreen,
} from "@code/ui/components/mobile-product";
import type { FormEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { useMobileProjectWorkspace } from "@/hooks/use-mobile-project-workspace";
import { useMobileWorkStack } from "@/hooks/use-mobile-work-stack";
import { useMobileWorkspace } from "@/hooks/use-mobile-workspace";
import { MODEL_ID, MODEL_LABEL } from "@/lib/chat/constants";
import { getMobileStatusMessage } from "@/lib/mobile-workspace/mobile-status-message";
import { getUserInitials } from "@/lib/users/get-user-initials";
export type MobileFlowScreen =
| "assistant-chat"
| "home"
| "stack-home"
| "work-chat"
| "work-list"
| "work-unit-detail";
interface MobileFlowPageProps {
screen: MobileFlowScreen;
}
export const MobileFlowPage = ({ screen }: MobileFlowPageProps) => {
const navigate = useNavigate();
const { workUnitId } = useParams();
const auth = useWebAuth();
const projectWorkspace = useMobileProjectWorkspace({
selectedWorkUnitId: workUnitId,
});
const workspace = useMobileWorkspace({
onCreateIssue: (title, body) =>
projectWorkspace.raiseIssue({ body, title }),
onSend: projectWorkspace.sendMessage,
});
const workStack = useMobileWorkStack(projectWorkspace.data.workUnits);
const workPath = "/work";
const chatPath = "/chat";
const handleRestoreChecked = workStack.restoreChecked;
const handleWorkUnitChecked = workStack.markChecked;
const handleWorkUnitSentBack = workStack.sendToBack;
const user = auth.status === "authenticated" ? auth.user : undefined;
const handleSignOut = async () => {
await signOutWeb();
navigate("/login");
};
const statusMessage = getMobileStatusMessage({
assistantError: projectWorkspace.error?.message,
localStatus: workspace.statusMessage,
});
const handleRepositoryChange =
projectWorkspace.projectWorkspace.setRepository;
const handleOpenUnit = (selectedWorkUnitId?: string) => {
const targetId =
selectedWorkUnitId ?? projectWorkspace.data.selectedWorkUnit?.id;
if (!targetId) {
workspace.setProjectManagerOpen(true);
return;
}
if (screen === "work-list" && !workspace.expanded) {
workspace.setExpanded(true);
return;
}
navigate(`/chat/${targetId}`);
};
const screenProps = {
composerValue: workspace.composerValue,
createIssueBody: workspace.issueBody,
createIssueTitle: workspace.issueTitle,
data: projectWorkspace.data,
modelId: MODEL_ID,
modelLabel: MODEL_LABEL,
onBack: () => navigate(chatPath),
onComposerChange: workspace.handleComposerChange,
onComposerMessageSubmit: workspace.handleComposerMessageSubmit,
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: () => workspace.setProjectManagerOpen(true),
onOpenAssistant: () => navigate(chatPath),
onOpenUnit: handleOpenUnit,
onProjectManagerClose: () => workspace.setProjectManagerOpen(false),
onProjectSelect: (projectId: string) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
),
onRepositoryChange: projectWorkspace.projectWorkspace.setRepository,
onRepositoryConnect: (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void projectWorkspace.projectWorkspace.connectRepository();
},
onReviewUnit: (reviewUrl: string) => {
window.open(reviewUrl, "_blank", "noopener,noreferrer");
},
onSettingsClose: () => workspace.setSettingsOpen(false),
onSettingsOpen: () => workspace.setSettingsOpen(true),
onStartUnit: (selectedIssueId: string) => {
void projectWorkspace.startWorkUnit(
selectedIssueId as Id<"projectIssues">
);
},
onViewWork: () => navigate(workPath),
pendingAction: projectWorkspace.projectWorkspace.pendingAction,
projectManagerOpen: workspace.projectManagerOpen,
repositoryValue: projectWorkspace.projectWorkspace.repository,
settingsOpen: workspace.settingsOpen,
statusMessage,
};
if (screen === "assistant-chat") {
return <MobileAssistantChatScreen {...screenProps} />;
}
if (screen === "stack-home") {
return (
<MobileWorkStackScreen
checkedCount={workStack.checkedCount}
composerValue={workspace.composerValue}
data={projectWorkspace.data}
modelId={MODEL_ID}
modelLabel={MODEL_LABEL}
onAddProject={() => workspace.setProjectManagerOpen(true)}
onComposerChange={workspace.handleComposerChange}
onComposerSubmit={workspace.handleComposerSubmit}
onOpenUnit={(selectedWorkUnitId) =>
navigate(`/chat/${selectedWorkUnitId}`)
}
onProjectSelect={(projectId) =>
projectWorkspace.projectWorkspace.setSelectedProjectId(
projectId as Id<"projects">
)
}
onProjectManagerClose={() => workspace.setProjectManagerOpen(false)}
onRepositoryChange={handleRepositoryChange}
onRepositoryConnect={(event) => {
event.preventDefault();
void projectWorkspace.projectWorkspace.connectRepository();
}}
onRestoreChecked={handleRestoreChecked}
onSettingsClose={() => workspace.setSettingsOpen(false)}
onSettingsOpen={() => workspace.setSettingsOpen(true)}
onSignOut={() => {
void handleSignOut();
}}
onWorkUnitChecked={handleWorkUnitChecked}
onWorkUnitSentBack={handleWorkUnitSentBack}
pendingAction={projectWorkspace.projectWorkspace.pendingAction}
projectManagerOpen={workspace.projectManagerOpen}
repositoryValue={projectWorkspace.projectWorkspace.repository}
settingsOpen={workspace.settingsOpen}
statusMessage={statusMessage}
userEmail={user?.email}
userInitials={getUserInitials(user?.name)}
userName={user?.name}
workUnits={workStack.visibleWorkUnits}
/>
);
}
if (screen === "work-chat") {
return (
<MobileWorkChatScreen
composerValue={workspace.composerValue}
data={projectWorkspace.data}
onBack={() => navigate(chatPath)}
onComposerChange={workspace.handleComposerChange}
onComposerMessageSubmit={workspace.handleComposerMessageSubmit}
onComposerSubmit={workspace.handleComposerSubmit}
statusMessage={statusMessage}
/>
);
}
if (screen === "home") {
return <MobileHomeScreen {...screenProps} />;
}
if (screen === "work-unit-detail") {
return <MobileWorkUnitDetailScreen {...screenProps} />;
}
if (workspace.expanded) {
return <MobileExpandedWorkScreen {...screenProps} />;
}
return <MobileWorkListScreen {...screenProps} />;
};

View 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
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);

View 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>
);
};

View 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>
);

View 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>
);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -1,416 +0,0 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import { projectWorkNotices } from "@code/work-os";
import {
ChevronRight,
FolderGit2,
LoaderCircle,
Menu,
MessageSquareText,
ImagePlus,
Send,
Sparkles,
X,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
import { chatImageToPromptImage } from "@/lib/chat/attachments";
import {
buildSliceOneTimeline,
findSourceMessageTarget,
} from "@/lib/slice-one/presentation";
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = [];
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: SliceWork;
}
const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
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]">
Proposed 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>
{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"
>
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
</article>
);
};
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const attachments = useChatImages();
const imageInput = useRef<HTMLInputElement>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const works = slice.works ?? EMPTY_WORKS;
const workById = useMemo(
() => new Map(works.map((work) => [String(work._id), work])),
[works]
);
const notices = useMemo(() => projectWorkNotices(works), [works]);
const timeline = useMemo(
() => buildSliceOneTimeline(slice.agent.messages, notices),
[notices, slice.agent.messages]
);
const busy =
slice.agent.status === "submitted" || slice.agent.status === "streaming";
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() => {
document
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
if (slice.projects === undefined) {
return (
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin" />
</div>
);
}
if (!slice.selectedProject) {
return (
<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 slice.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 one project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Slice 1 turns actionable 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) => slice.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={slice.repository}
/>
{slice.error ? (
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={slice.pending}
type="submit"
>
{slice.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{slice.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);
}
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
const images = await Promise.all(
attachments.images.map(chatImageToPromptImage)
);
await slice.agent.sendMessage(message, { images });
setDraft("");
attachments.clear();
};
return (
<main
className="slice-one-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">
<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) => slice.selectProject(event.target.value)}
value={slice.selectedProject.id}
>
{slice.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>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
</header>
<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">
{slice.agent.historyReady && timeline.length === 0 ? (
<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>
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId);
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>
<WorkCard
onSourceSelect={revealSourceMessage}
work={work}
/>
</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={`slice-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage hideToolActivity message={item.message} />
</div>
);
})}
{slice.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
<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}
{slice.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{slice.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-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => setDraft(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>
</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>
<div className="space-y-3">
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
work={work}
/>
))}
</div>
</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"
>
<X className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{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={revealSourceMessage}
work={work}
/>
))}
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -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";

View 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>
);
};

View 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>
);
};

View 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>
);

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);

View File

@@ -1,79 +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", undefined);
});
test("forwards image attachments through to the Flue agent", async () => {
mocks.organization = { organizationId: "org-a" };
const images = [
{
data: "abc",
filename: "a.png",
mimeType: "image/png",
type: "image" as const,
},
];
const chat = useChatAgent();
await chat.sendMessage("describe this", { images });
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("describe this", {
images,
});
});
});

View File

@@ -1,45 +1,111 @@
import type { SendMessageOptions } from "@flue/react";
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 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?: SendMessageOptions
options?: { readonly images?: readonly File[] }
): Promise<void> => {
if (!organization.organizationId) {
if (!organizationId) {
throw (
organization.error ??
new Error("Personal organization is still being prepared")
);
}
await agent.sendMessage(message, options);
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());

View File

@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
import { MAX_CHAT_IMAGES, validateChatImage } from "@/lib/chat/attachments";
import type { PendingChatImage } from "@/lib/chat/attachments";
import { generateBrowserRequestId } from "@/lib/flue-transport";
const generateImageId = (): string =>
globalThis.crypto?.randomUUID?.() ??
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
export const useChatImages = () => {
const [images, setImages] = useState<PendingChatImage[]>([]);
@@ -40,7 +43,7 @@ export const useChatImages = () => {
previewUrls.current.add(previewUrl);
next.push({
file,
id: generateBrowserRequestId(),
id: generateImageId(),
previewUrl,
});
}

View File

@@ -1,72 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useQuery } from "convex/react";
import { useMemo, useState } from "react";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
export const useSliceOne = () => {
const projects = useQuery(api.projects.list);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useChatAgent();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>();
const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? selectedProjectId
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
api.works.listForProject,
activeProjectId ? { projectId: activeProjectId } : "skip"
);
const selectedProject = useMemo(
() =>
projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null,
[activeProjectId, projects]
);
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 selectProject = (projectId: string) => {
setSelectedProjectId(projectId as unknown as Id<"projects">);
};
return {
agent,
connectRepository,
error,
pending,
projects,
repository,
selectProject,
selectedProject,
setRepository,
works,
} as const;
};

View 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]);

View 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;
};

View File

@@ -1,126 +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 WORK_UNIT_SCOPE_PATTERN =
/^<zopu-work-unit id="(?<workUnitId>[^"]+)">[\s\S]*?<message>(?<message>[\s\S]*?)<\/message>\s*<\/zopu-work-unit>$/u;
const toAssistantMessages = (
messages: ReturnType<typeof useOrganizationChatAgent>["messages"],
selectedWorkUnitId?: string
): MobileAssistantMessageView[] => {
const result: MobileAssistantMessageView[] = [];
for (const message of messages) {
if (message.role !== "assistant" && message.role !== "user") {
continue;
}
const rawText = getMessageText(message);
if (!rawText) {
continue;
}
if (!selectedWorkUnitId) {
result.push({ id: message.id, role: message.role, text: rawText });
continue;
}
if (message.role === "user") {
const match = WORK_UNIT_SCOPE_PATTERN.exec(rawText);
if (match?.groups?.workUnitId !== selectedWorkUnitId) {
result.length = 0;
continue;
}
result.push({
id: message.id,
role: message.role,
text: match.groups.message?.trim() ?? rawText,
});
continue;
}
if (result.length > 0) {
result.push({ id: message.id, role: message.role, text: rawText });
}
}
return result;
};
const getStatusTone = (
status: ReturnType<typeof useOrganizationChatAgent>["status"]
): 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 selectedIssue = projectWorkspace.issues?.find(
(issue) => String(issue._id) === selectedWorkUnitId
);
const sendMessage = (message: string) => {
if (!selectedIssue) {
return chatAgent.sendMessage(message);
}
return chatAgent.sendMessage(
`<zopu-work-unit id="${selectedIssue._id}">\n<context>Issue #${selectedIssue.number}: ${selectedIssue.title}</context>\n<message>${message}</message>\n</zopu-work-unit>`
);
};
const assistant: MobileAssistantView = {
isBusy:
chatAgent.status === "connecting" ||
chatAgent.status === "streaming" ||
chatAgent.status === "submitted",
messages: toAssistantMessages(chatAgent.messages, selectedWorkUnitId),
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 ??
(projectWorkspace.error ? new Error(projectWorkspace.error) : undefined),
organization,
projectWorkspace,
raiseIssue: projectWorkspace.raiseIssue,
raiseIssueFromSignal: (signalId: string) =>
projectWorkspace.raiseIssueFromSignal(signalId as Id<"signals">),
sendMessage,
startWorkUnit: projectWorkspace.startWorkUnit,
} as const;
};

View File

@@ -1,66 +0,0 @@
import type { MobileWorkUnitView } from "@code/ui/components/mobile-product";
import { useCallback, useMemo, useState } from "react";
export const useMobileWorkStack = (
workUnits: readonly MobileWorkUnitView[]
) => {
const [order, setOrder] = useState<readonly string[]>([]);
const [checkedIds, setCheckedIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const workUnitsById = useMemo(
() => new Map(workUnits.map((workUnit) => [workUnit.id, workUnit])),
[workUnits]
);
const availableIds = useMemo(
() => new Set(workUnitsById.keys()),
[workUnitsById]
);
const visibleWorkUnits = useMemo(() => {
const retainedOrder = order.filter(
(workUnitId) =>
availableIds.has(workUnitId) && !checkedIds.has(workUnitId)
);
const retainedIds = new Set(retainedOrder);
const addedIds = workUnits
.map((workUnit) => workUnit.id)
.filter(
(workUnitId) =>
!retainedIds.has(workUnitId) && !checkedIds.has(workUnitId)
);
return [...retainedOrder, ...addedIds].flatMap((workUnitId) => {
const workUnit = workUnitsById.get(workUnitId);
return workUnit ? [workUnit] : [];
});
}, [availableIds, checkedIds, order, workUnits, workUnitsById]);
const sendToBack = useCallback(
(workUnitId: string) => {
if (visibleWorkUnits[0]?.id !== workUnitId) {
return;
}
setOrder([...visibleWorkUnits.slice(1).map(({ id }) => id), workUnitId]);
},
[visibleWorkUnits]
);
const markChecked = useCallback((workUnitId: string) => {
setCheckedIds((current) => new Set(current).add(workUnitId));
}, []);
const restoreChecked = useCallback(() => {
setCheckedIds(new Set());
setOrder([]);
}, []);
return {
checkedCount: [...checkedIds].filter((workUnitId) =>
availableIds.has(workUnitId)
).length,
markChecked,
restoreChecked,
sendToBack,
visibleWorkUnits,
} as const;
};

View File

@@ -1,98 +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 [projectManagerOpen, setProjectManagerOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [statusMessage, setStatusMessage] = useState<string>();
const handleComposerChange = (value: string) => {
setComposerValue(value);
setStatusMessage(undefined);
};
const submitMessage = async (message: string) => {
const trimmedMessage = message.trim();
if (!trimmedMessage) {
return;
}
setStatusMessage("Sending to Zopu…");
try {
await onSend(trimmedMessage);
setComposerValue("");
setStatusMessage("Sent to Zopu");
} catch (error) {
setStatusMessage(errorMessage(error));
throw error;
}
};
const handleComposerSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const sendCurrentMessage = async () => {
try {
await submitMessage(composerValue);
} catch {
// submitMessage already exposes the failure through statusMessage.
}
};
void sendCurrentMessage();
};
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,
handleComposerMessageSubmit: submitMessage,
handleComposerSubmit,
handleCreateIssueSubmit,
issueBody,
issueTitle,
projectManagerOpen,
setExpanded,
setIssueBody,
setIssueTitle,
setProjectManagerOpen,
setSettingsOpen,
settingsOpen,
statusMessage,
};
};

View File

@@ -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 {};
}

View File

@@ -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;
};

View 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,
};
};

View 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,
};
};

View 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,
};
};

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Slice 1 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",

View File

@@ -33,7 +33,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
});
};
root.classList.add("slice-one-viewport-lock");
root.classList.add("workspace-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
@@ -41,7 +41,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("slice-one-viewport-lock");
root.classList.remove("workspace-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);

View File

@@ -6,8 +6,8 @@ body {
min-height: 100%;
}
html.slice-one-viewport-lock,
html.slice-one-viewport-lock body {
html.workspace-viewport-lock,
html.workspace-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
@@ -75,6 +75,19 @@ html.slice-one-viewport-lock 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%,
@@ -103,9 +116,42 @@ html.slice-one-viewport-lock body {
color: var(--foreground);
}
.slice-one-surface .chat-markdown,
.slice-one-surface .chat-reasoning,
.slice-one-surface .thinking-line {
/*
* 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;
}
@@ -210,6 +256,16 @@ html.slice-one-viewport-lock 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,

View 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;
};

View File

@@ -1,10 +1,6 @@
import { describe, expect, test } from "vitest";
import {
MAX_CHAT_IMAGE_BYTES,
bytesToBase64,
validateChatImage,
} from "./attachments";
import { MAX_CHAT_IMAGE_BYTES, validateChatImage } from "./attachments";
describe("chat image attachments", () => {
test("accepts supported image payloads within the upload limit", () => {
@@ -24,8 +20,4 @@ describe("chat image attachments", () => {
}).accepted
).toBe(false);
});
test("encodes image bytes for the Flue prompt contract", () => {
expect(bytesToBase64(new Uint8Array([90, 111, 112, 117]))).toBe("Wm9wdQ==");
});
});

View File

@@ -1,5 +1,3 @@
import type { AgentPromptImage } from "@flue/react";
export const MAX_CHAT_IMAGES = 4;
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -28,20 +26,3 @@ export const validateChatImage = (
}
return { accepted: true };
};
export const bytesToBase64 = (bytes: Uint8Array): string => {
let binary = "";
for (const byte of bytes) {
binary += String.fromCodePoint(byte);
}
return btoa(binary);
};
export const chatImageToPromptImage = async (
image: PendingChatImage
): Promise<AgentPromptImage> => ({
data: bytesToBase64(new Uint8Array(await image.file.arrayBuffer())),
filename: image.file.name,
mimeType: image.file.type,
type: "image",
});

View File

@@ -1,24 +1,13 @@
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_ID = "openrouter/openai/gpt-oss-20b:free";
export const MODEL_LABEL = "GPT-OSS 20B Free";
export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"],
["Make a plan", "Turn an idea into clear next steps"],
["Explain simply", "Break down something complex"],
] as const;
export const STATUS_COPY: Record<AgentStatus, string> = {
connecting: "Connecting",
error: "Connection issue",

View 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);
});
});

View 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,
};
};

View File

@@ -1,53 +1,18 @@
import type { FlueConversationMessage } from "@flue/react";
import { describe, expect, test } from "vitest";
import {
extractThinkingMarkup,
getReasoningText,
hasToolActivity,
isReasoningStreaming,
} from "./transforms";
import type { ConversationMessage } from "./types";
const message = (
parts: FlueConversationMessage["parts"]
): FlueConversationMessage => ({
const message = (parts: ConversationMessage["parts"]): ConversationMessage => ({
id: "message-1",
parts,
role: "assistant",
});
describe("hasToolActivity", () => {
test("detects an orchestration turn", () => {
expect(
hasToolActivity(
message([
{
input: { projectId: "project-1" },
state: "input-available",
toolCallId: "tool-1",
toolName: "list_proposed_work",
type: "dynamic-tool",
},
])
)
).toBe(true);
});
test("keeps a product-facing assistant response", () => {
expect(
hasToolActivity(
message([
{
state: "done",
text: "Captured the signal and proposed Work.",
type: "text",
},
])
)
).toBe(false);
});
});
describe("reasoning traces", () => {
test("extracts completed and streaming MiniMax think blocks", () => {
expect(

View File

@@ -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(
@@ -33,16 +33,16 @@ export const extractThinkingMarkup = (text: string): string => {
return completed.join("\n\n");
};
export const getRawMessageText = (message: FlueConversationMessage): string =>
export const getRawMessageText = (message: ConversationMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
export const getMessageText = (message: FlueConversationMessage): string =>
export const getMessageText = (message: ConversationMessage): string =>
stripThinkingMarkup(getRawMessageText(message));
export const getReasoningText = (message: FlueConversationMessage): string => {
export const getReasoningText = (message: ConversationMessage): string => {
const nativeReasoning = message.parts
.filter((part) => part.type === "reasoning")
.map((part) => part.text)
@@ -51,9 +51,7 @@ export const getReasoningText = (message: FlueConversationMessage): string => {
return [nativeReasoning, inlineReasoning].filter(Boolean).join("\n\n");
};
export const isReasoningStreaming = (
message: FlueConversationMessage
): boolean => {
export const isReasoningStreaming = (message: ConversationMessage): boolean => {
const nativeReasoningStreaming = message.parts.some(
(part) => part.type === "reasoning" && part.state === "streaming"
);
@@ -66,19 +64,12 @@ export const isReasoningStreaming = (
return nativeReasoningStreaming || inlineReasoningStreaming;
};
export const hasToolActivity = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => part.type === "dynamic-tool");
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
message.parts.some((part) => {
if (part.type === "dynamic-tool") {
return part.state === "input-available";
}
return (
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") {

View File

@@ -1,17 +1,42 @@
import type {
AgentPromptImage,
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[];
messages: ConversationMessage[];
sendMessage: (
message: string,
options?: { readonly images?: AgentPromptImage[] }
options?: { readonly images?: readonly File[] }
) => Promise<void>;
status: AgentStatus;
}
@@ -24,7 +49,7 @@ export interface ChatComposerProps {
export interface ChatConversationProps {
historyReady: boolean;
messages: FlueConversationMessage[];
messages: ConversationMessage[];
onSuggestion: (suggestion: string) => void;
status: AgentStatus;
}
@@ -34,12 +59,7 @@ export interface ChatHeaderProps {
}
export interface ChatMessageProps {
hideToolActivity?: boolean;
message: FlueConversationMessage;
}
export interface ChatToolCallProps {
part: Extract<FlueConversationPart, { type: "dynamic-tool" }>;
message: ConversationMessage;
}
export type AssistantResponseState = "thinking" | "writing";

View File

@@ -1,152 +0,0 @@
import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest";
import { createFlueFetch, generateBrowserRequestId } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
const GLOBAL_RECEIVER_FETCH = function globalReceiverFetch(this: unknown) {
if (this !== globalThis) {
throw new TypeError("fetch receiver must be globalThis");
}
return Promise.resolve(new Response(null, { status: 204 }));
} as typeof fetch;
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("invokes fetch with the browser global receiver", async () => {
await expect(
createFlueFetch({ baseUrl: BASE_URL, fetchImpl: GLOBAL_RECEIVER_FETCH })(
"https://flue.example/api/agents/zopu/org-a?view=history"
)
).resolves.toBeInstanceOf(Response);
});
test("uses the default browser request ID generator without losing its receiver", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
await createFlueFetch({ baseUrl: BASE_URL, fetchImpl })(
"https://flue.example/api/agents/zopu/org-a",
{ method: "POST" }
);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toMatch(
/^[0-9a-f-]{36}$/u
);
});
test("generates a UUID without requiring crypto.randomUUID", () => {
expect(generateBrowserRequestId()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
);
});
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);
});
});

View File

@@ -1,120 +0,0 @@
interface FlueFetchOptions {
readonly baseUrl: URL;
readonly fetchImpl?: typeof fetch;
readonly generateRequestId?: () => string;
}
export const generateBrowserRequestId = (): string => {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = ((bytes[6] ?? 0) % 16) + 64;
bytes[8] = ((bytes[8] ?? 0) % 64) + 128;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
};
const addRequestContext = async (
response: Response,
method: string,
requestUrl: URL
): Promise<Response> => {
if (response.ok) {
return response;
}
const responseClone = response.clone();
const responseText = await responseClone.text();
const detail = responseText.trim() || "request failed";
return Response.json(
{
error: {
message: `${method} ${requestUrl.pathname}${requestUrl.search}: ${detail}`,
},
},
{
headers: response.headers,
status: response.status,
statusText: response.statusText,
}
);
};
/**
* 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 = generateBrowserRequestId,
}: 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 rawResponse = await fetchImpl.call(globalThis, input, requestInit);
const response = await addRequestContext(rawResponse, method, requestUrl);
if (!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,
}
);
};
};

View File

@@ -1,191 +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) => {
const [source] = project.sources;
return {
connected: Boolean(source),
host: source?.host,
id: project.id,
name: project.name,
repositoryPath: source?.repositoryPath,
};
}) ?? [];
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,
};
};

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { getMobileStatusMessage } from "./mobile-status-message";
describe("getMobileStatusMessage", () => {
it("keeps local composer feedback", () => {
expect(
getMobileStatusMessage({
assistantError: "Flue API error 401",
localStatus: "Sent to Zopu",
})
).toBe("Sent to Zopu");
});
it("does not expose Flue authorization internals", () => {
expect(
getMobileStatusMessage({
assistantError:
'Flue API error 401: GET /agents/zopu/example: {"error":"Unauthorized"}',
})
).toBe("Zopu is reconnecting…");
});
});

View File

@@ -1,25 +0,0 @@
interface MobileStatusMessageInput {
readonly assistantError?: string;
readonly localStatus?: string;
}
const isAgentAuthorizationError = (message: string) =>
message.includes("Flue API error 401") ||
message.includes('"Unauthorized"') ||
message.includes("GET /agents/");
export const getMobileStatusMessage = ({
assistantError,
localStatus,
}: MobileStatusMessageInput): string | undefined => {
if (localStatus) {
return localStatus;
}
if (!assistantError) {
return undefined;
}
if (isAgentAuthorizationError(assistantError)) {
return "Zopu is reconnecting…";
}
return "Zopu needs attention";
};

View File

@@ -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,
});
});
});

View File

@@ -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),
};
};

View File

@@ -5,27 +5,36 @@ import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Slice 1 frontend regression contracts", () => {
describe("Workspace frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
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(page).not.toContain("slice-one-surface flex h-svh");
expect(styles).toContain("html.slice-one-viewport-lock body");
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/slice-one/slice-one-page.tsx");
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(page).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t');
expect(page).toContain(
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"'
);
});
@@ -50,4 +59,20 @@ describe("Slice 1 frontend regression contracts", () => {
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)");
});
});

View File

@@ -1,14 +1,18 @@
import type { WorkNotice } from "@code/work-os";
import type { FlueConversationMessage } from "@flue/react";
import type { WorkNotice } from "@code/primitives/work";
import { describe, expect, test } from "vitest";
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
import type { ConversationMessage } from "@/lib/chat/types";
import {
buildWorkspaceTimeline,
findSourceMessageTarget,
} from "./presentation";
const textMessage = (
id: string,
role: "assistant" | "user",
text: string
): FlueConversationMessage => ({
): ConversationMessage => ({
id,
parts: [{ state: "done", text, type: "text" }],
role,
@@ -22,24 +26,11 @@ const notice: WorkNotice = {
workId: "work-1",
};
describe("Slice 1 presentation", () => {
describe("Workspace presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildSliceOneTimeline(
const timeline = buildWorkspaceTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
{
id: "tool-1",
parts: [
{
input: {},
state: "input-available",
toolCallId: "call-1",
toolName: "create_signal",
type: "dynamic-tool",
},
],
role: "assistant",
},
textMessage("assistant-1", "assistant", "Captured and proposed Work."),
],
[notice]

View File

@@ -1,30 +1,23 @@
import type { WorkNotice } from "@code/work-os";
import type { FlueConversationMessage } from "@flue/react";
import type { WorkNotice } from "@code/primitives/work";
import {
getMessageText,
getRawMessageText,
getReasoningText,
hasToolActivity,
} from "@/lib/chat/transforms";
import type { ConversationMessage } from "@/lib/chat/types";
export type SliceTimelineItem =
| {
readonly kind: "message";
readonly message: FlueConversationMessage;
}
| { readonly kind: "work"; readonly notice: WorkNotice };
import type { WorkspaceTimelineItem } from "./types";
export const isSliceOneVisibleMessage = (
message: FlueConversationMessage
export const isVisibleConversationMessage = (
message: ConversationMessage
): boolean =>
message.role === "user" ||
!hasToolActivity(message) ||
getMessageText(message).length > 0 ||
getReasoningText(message).length > 0;
const targetIndexForNotice = (
messages: readonly FlueConversationMessage[],
messages: readonly ConversationMessage[],
notice: WorkNotice
): number => {
const sourceIndexes = notice.sourceTexts.flatMap((sourceText) => {
@@ -40,17 +33,15 @@ const targetIndexForNotice = (
const sourceIndex = Math.max(...sourceIndexes);
const responseOffset = messages
.slice(sourceIndex + 1)
.findIndex(
(message) => message.role === "assistant" && !hasToolActivity(message)
);
.findIndex((message) => message.role === "assistant");
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildSliceOneTimeline = (
allMessages: readonly FlueConversationMessage[],
export const buildWorkspaceTimeline = (
allMessages: readonly ConversationMessage[],
notices: readonly WorkNotice[]
): readonly SliceTimelineItem[] => {
const messages = allMessages.filter(isSliceOneVisibleMessage);
): readonly WorkspaceTimelineItem[] => {
const messages = allMessages.filter(isVisibleConversationMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
@@ -59,7 +50,7 @@ export const buildSliceOneTimeline = (
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: SliceTimelineItem[] = [];
const timeline: WorkspaceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {
@@ -75,7 +66,7 @@ export const buildSliceOneTimeline = (
};
export const findSourceMessageTarget = (
messages: readonly FlueConversationMessage[],
messages: readonly ConversationMessage[],
sourceText: string
): string | undefined =>
messages.find(

View 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 ?? [];

View File

@@ -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,34 +32,6 @@ 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>
@@ -79,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>
);

View File

@@ -2,21 +2,12 @@ import { index, layout, route } from "@react-router/dev/routes";
import type { RouteConfig } from "@react-router/dev/routes";
export default [
// Standalone chat page — no auth, mobile-first, talks to the zopu server.
route("chat", "./routes/standalone/chat/page.tsx"),
layout("./routes/auth/layout.tsx", [
route("login", "./routes/auth/login/page.tsx"),
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("chat/:workUnitId", "./routes/app/mobile/chat/work/page.tsx"),
route("work", "./routes/app/mobile/work-list/page.tsx"),
route("work/:workUnitId", "./routes/app/mobile/work/page.tsx"),
]),
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;

View File

@@ -1,5 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function Dashboard() {
return <SliceOnePage />;
}

View File

@@ -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 />;
}

View File

@@ -1,12 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-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 <SliceOnePage />;
}

View File

@@ -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 chat | Zopu" },
{
content: "Chat with Zopu in the context of a selected work unit",
name: "description",
},
];
export default function MobileWorkChatPage() {
return <MobileFlowPage screen="work-chat" />;
}

View File

@@ -1,9 +0,0 @@
import { Outlet } from "react-router";
export default function MobileProductLayout() {
return (
<div className="min-h-svh bg-[#11110f]">
<Outlet />
</div>
);
}

View File

@@ -1,5 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function MobileLandingRedirect() {
return <SliceOnePage />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,5 @@
import { ProjectWorkspacePage } from "@/components/workspace/project-workspace-page";
export default function ProjectWorkspaceRoute() {
return <ProjectWorkspacePage />;
}

View File

@@ -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">

View File

@@ -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 })
}
/>
);
}

View File

@@ -2,16 +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, "../.."),
server: {
allowedHosts: true,
},
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,
},
},
},
};
});

4712
bun.lock

File diff suppressed because it is too large Load Diff

63
deploy/compose/Caddyfile Normal file
View 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
}

View 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

View 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
View 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
}

View 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

View File

@@ -0,0 +1,4 @@
[Service]
EnvironmentFile=/etc/zopu/agents.env
ExecStart=
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile

View File

@@ -1,91 +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
# Public URLs for the self-hosted Convex deployment.
# ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site
SITE_URL=http://localhost:5173
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
VITE_FLUE_URL=http://localhost:3583
# Self-hosted Convex origins used by convex/docker-compose.yml
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET=
# ---------------------------------------------------------------------------
# 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.
# zopu-agent.service pins the Flue Node server to port 3583.
# ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token
# ---------------------------------------------------------------------------
# 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

View File

@@ -1,306 +0,0 @@
# Zopu Single-Node Runtime Deployment
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Debian Dedicated Server │
│ ~12 CPU cores · ~40 GB RAM · single-node │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ │ │ :3583 │ │ :3210/:3211 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ┌──────▼───────┐ │
│ │ zopu-daemon │ Bun + Effect + RivetKit :6420 │
│ └──────────────┘ │
│ │
│ 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-web.service # React Router web 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-web 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 all services
systemctl start zopu-web zopu-daemon zopu-agent
# Stop all services
systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot
systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot
systemctl disable zopu-web 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` (`zopu-agent.service` sets port 3583) |
| 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) |
## Public single-node routes
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
```bash
docker compose \
--env-file /opt/zopu/.env \
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
exec backend ./generate_admin_key.sh
```
## What is NOT deployed
- **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.
- **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.

View File

@@ -1,290 +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, Node.js 22, Bun, clones the repo, runs bun install, builds the
# web app, 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. Node.js 22 (required by the Flue Node target)
# ---------------------------------------------------------------------------
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
log "Installing Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get install -y nodejs
else
log "Node.js already installed: $(node --version)"
fi
# ---------------------------------------------------------------------------
# 4. Bun
# ---------------------------------------------------------------------------
if ! command -v bun &>/dev/null; then
log "Installing Bun..."
curl -fsSL https://bun.sh/install | bash
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
else
log "Bun already installed: $(bun --version)"
fi
# ---------------------------------------------------------------------------
# 5. 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
# ---------------------------------------------------------------------------
# 6. 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
# ---------------------------------------------------------------------------
# 6b. 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"
# ---------------------------------------------------------------------------
# 7. Install dependencies and build
# ---------------------------------------------------------------------------
log "Running bun install..."
run_as_service bun install
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# ---------------------------------------------------------------------------
# 8. 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
# ---------------------------------------------------------------------------
# 9. Persistent log directory
# ---------------------------------------------------------------------------
LOG_DIR="/var/log/zopu"
mkdir -p "$LOG_DIR"
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
# ---------------------------------------------------------------------------
# 10. Install systemd units (substitute placeholders)
# ---------------------------------------------------------------------------
log "Installing systemd units..."
for unit in zopu-web.service 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
# ---------------------------------------------------------------------------
# 11. 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
# ---------------------------------------------------------------------------
# 12. 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
# ---------------------------------------------------------------------------
# 13. 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-web 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."

View File

@@ -1,15 +0,0 @@
# Caddyfile — Public reverse proxy for the Zopu web + API.
#
# The web frontend (port 5173) is served at the root, and the Flue agent/API
# (port 3585) is mounted under /api so the browser talks same-origin.
zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
handle_path /api/* {
reverse_proxy 127.0.0.1:3585
}
reverse_proxy 127.0.0.1:5173
}

View File

@@ -1,25 +0,0 @@
{
admin 127.0.0.1:2019
auto_https off
http_port 8080
}
http://zopu.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:13100
}
http://zopu-api.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3210
}
http://zopu-site.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3211
}
http://zopu-agent.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3583
}

Some files were not shown because too many files have changed in this diff Show More