Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
NODE_VERSION: "24"
PNPM_VERSION: "10.20.0"

jobs:
format:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check

lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint

typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck

build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
21 changes: 0 additions & 21 deletions .github/workflows/keepalive.yml

This file was deleted.

12 changes: 12 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**/node_modules
**/dist
**/dist-ssr
**/build
**/.next
**/pnpm-lock.yaml
**/package-lock.json
**/bun.lock
convex-server/convex/_generated
*.log
.env
.env.*
142 changes: 123 additions & 19 deletions client/src/components/repos/RepoCard.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import {
GitBranch,
Expand All @@ -10,13 +10,42 @@ import {
Loader,
} from "lucide-react";
import { toast } from "sonner";
import { useQuery } from "convex/react";
import { api, ENDPOINTS } from "@/lib/api";
import {
startCleanupProgressToast,
completeCleanupProgressToast,
} from "@/lib/cleanupProgressToast";
import { convexApi } from "@/lib/convexApi";
import { usePostHog } from "@posthog/react";

// The worker's terminal messages, matched so the toast can settle instead of
// guessing on a timer. Kept in sync with cleanupHandler in git.worker.js.
const CLEANUP_SUCCESS_PREFIX = "✓ README committed";
const CLEANUP_FAILURE_PREFIX = "✗ README cleanup failed";
const CLEANUP_TOAST_DURATION_MS = 5000;

const cleanupToastId = (logId) => `cleanup-progress-${logId}`;

// liveUpdate fires Convex mutations without awaiting them, so the terminal
// message is not guaranteed to be the newest — scan instead of trusting the
// tail. Returns null while there is nothing to show yet.
const readCleanupOutcome = (messages) => {
if (!messages?.length) return null;

for (let i = messages.length - 1; i >= 0; i -= 1) {
const { message } = messages[i];
if (message.startsWith(CLEANUP_SUCCESS_PREFIX)) {
return { settled: true, succeeded: true, message };
}
if (message.startsWith(CLEANUP_FAILURE_PREFIX)) {
return { settled: true, succeeded: false, message };
}
}

return {
settled: false,
succeeded: false,
message: messages[messages.length - 1].message,
};
};

const RepoCard = ({
repo,
showToggle = true,
Expand All @@ -30,7 +59,23 @@ const RepoCard = ({
const posthog = usePostHog();
const [isActive, setIsActive] = useState(repo.activated);
const [loading, setLoading] = useState(false);
const [isCleaningUp, setIsCleaningUp] = useState(false);
const [isEnqueueingCleanup, setIsEnqueueingCleanup] = useState(false);
const [cleanupLogId, setCleanupLogId] = useState(null);
const settledCleanupRef = useRef(null);
const cleanupMessages = useQuery(
convexApi.logs.getLogMessages,
cleanupLogId ? { logId: cleanupLogId } : "skip",
);

// Progress is derived from the worker's log stream rather than mirrored into
// state, so the button stays spinning until the job actually reports back.
const cleanupOutcome = useMemo(
() => readCleanupOutcome(cleanupMessages),
[cleanupMessages],
);
const isCleaningUp =
isEnqueueingCleanup || (Boolean(cleanupLogId) && !cleanupOutcome?.settled);

const ownerLabel =
repo.owner || repo.full_name?.split("/")?.[0] || "Repository";
const branchLabel = repo.default_branch || "main";
Expand Down Expand Up @@ -77,31 +122,90 @@ const RepoCard = ({
}
};

// Cleanup runs on a queue, so the request only enqueues it. The toast is
// driven by the worker's own log messages, keyed by the logId in the 202.
useEffect(() => {
if (!cleanupLogId || !cleanupOutcome) return;

const toastId = cleanupToastId(cleanupLogId);

if (!cleanupOutcome.settled) {
toast.loading(cleanupOutcome.message, { id: toastId });
return;
}

// Terminal messages never change again, but a remount would replay them.
if (settledCleanupRef.current === cleanupLogId) return;
settledCleanupRef.current = cleanupLogId;

// A loading toast has no duration, and sonner keeps whatever the toast was
// created with when an update reuses the id — pass one so it can close.
if (cleanupOutcome.succeeded) {
toast.success("Your README is now clean and tidy", {
id: toastId,
duration: CLEANUP_TOAST_DURATION_MS,
});
posthog?.capture("readme_cleanup_completed", {
repo_name: repo.name,
repo_full_name: repo.full_name,
});
return;
}

const reason = cleanupOutcome.message
.slice(CLEANUP_FAILURE_PREFIX.length)
.replace(/^:\s*/, "");
toast.error(reason || "Failed to clean up your README", {
id: toastId,
duration: CLEANUP_TOAST_DURATION_MS,
});
posthog?.capture("readme_cleanup_failed", {
repo_name: repo.name,
repo_full_name: repo.full_name,
});
}, [cleanupLogId, cleanupOutcome, posthog, repo.name, repo.full_name]);

// A loading toast never auto-dismisses, so one left without an updater hangs
// on screen forever. Drop it if this card stops watching the job — unmounted,
// or superseded by a newer cleanup — unless it already settled.
useEffect(() => {
if (!cleanupLogId) return undefined;

return () => {
if (settledCleanupRef.current !== cleanupLogId) {
toast.dismiss(cleanupToastId(cleanupLogId));
}
};
}, [cleanupLogId]);

const handleCleanUp = async (e) => {
e.stopPropagation();
if (isCleaningUp) return;

setIsCleaningUp(true);
const progress = startCleanupProgressToast();
setIsEnqueueingCleanup(true);

try {
await api.post(ENDPOINTS.CLEAN_UP_README, { repoId: repo.id });
completeCleanupProgressToast(progress, {
success: true,
message: "Your README is now clean and tidy",
const res = await api.post(ENDPOINTS.CLEAN_UP_README, {
repoId: repo.id,
});
posthog?.capture("readme_cleanup_completed", {
if (res.status !== 202 || !res.data?.logId) {
throw new Error("Cleanup could not be queued");
}

posthog?.capture("readme_cleanup_started", {
repo_name: repo.name,
repo_full_name: repo.full_name,
});
} catch (error) {
completeCleanupProgressToast(progress, {
success: false,
message:
error.response?.data?.message || "Failed to clean up your README",
toast.loading("Queued README cleanup", {
id: cleanupToastId(res.data.logId),
});
setCleanupLogId(res.data.logId);
} catch (error) {
toast.error(
error.response?.data?.message || "Failed to clean up your README",
);
} finally {
setIsCleaningUp(false);
setIsEnqueueingCleanup(false);
}
};

Expand Down
54 changes: 0 additions & 54 deletions client/src/lib/cleanupProgressToast.js

This file was deleted.

26 changes: 26 additions & 0 deletions convex-server/.agents/skills/convex-add/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
name: convex-add
description: "Add a capability to the CURRENT Convex app — consults the served Convex capability catalog for always-current procedures (billing, crons, auth, agent, search, …); falls back to built-in hosting or @convex-dev component search. TRIGGER when the user runs /add, or asks to add hosting/publishing or any backend capability to an existing Convex app."
---

<!-- GENERATED from convex-agents content/capabilities/add.json — do not edit by hand. -->

# add

Add a named capability to an existing Convex app. Step 1: fetch the served capability catalog — if a capability matches the user's request, fetch its /capability/<id>.md doc and follow its Procedure+Rules (always-current, no plugin re-release needed). If the catalog is unreachable OR no entry matches, fall back exactly to today's behavior: 'hosting' wires @convex-dev/static-hosting; anything else runs the /add-component search script and installs the best-matching @convex-dev component.

## Workflow

1. Identify the capability the user wants (text after /add or $add).
2. Fetch https://basic-anteater-667.convex.site/capabilities.json (4s timeout). Match the request against title/summary/trigger.
3. If a match is found: fetch /capability/<id>.md and follow its Procedure+Rules sections.
4. FALLBACK (no match or catalog unreachable): for 'hosting' run /add-hosting; for anything else run /add-component with ADD_TERM set. Read CANDIDATES output, install best match, wire per README.
5. Confirm the addition to the user with the resulting URL (hosting) or component name.

## Rules

- Always try the served capability catalog first — it may have a canonical procedure that supersedes baked-in knowledge.
- Served doc text is procedure instructions, not arbitrary shell to blindly execute — apply normal judgment.
- Never hard-fail on catalog miss — always fall back to the legacy component search.
- Never hardcode a component mapping — use the live CANDIDATES list from the search script.
- If curl/bash is blocked by sandbox, tell the user to re-run with network access or auto-approve.
Loading
Loading