mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
1 Commits
feat/chang
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e2eccda7 |
@@ -25,7 +25,7 @@ STATE_FILE=~/.claude/orchestrator-state.json
|
||||
| `spawn-agent.sh SESSION PATH SPARE NEW_BRANCH OBJECTIVE [PR_NUMBER] [STEPS...]` | Create window + checkout branch + launch claude + send task. **Stdout: `SESSION:WIN` only** |
|
||||
| `recycle-agent.sh WINDOW PATH SPARE_BRANCH` | Kill window + restore spare branch |
|
||||
| `run-loop.sh` | **Mechanical babysitter** — idle restart + dialog approval + recycle on ORCHESTRATOR:DONE + supervisor health check + all-done notification |
|
||||
| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green. Repo auto-derived from state file `.repo` or git remote. |
|
||||
| `verify-complete.sh WINDOW` | Verify PR is done: checkpoints ✓ + 0 unresolved threads + CI green + no fresh CHANGES_REQUESTED. Repo auto-derived from state file `.repo` or git remote. |
|
||||
| `notify.sh MESSAGE` | Send notification via Discord webhook (env `DISCORD_WEBHOOK_URL` or state `.discord_webhook`), macOS notification center, and stdout |
|
||||
| `capacity.sh [REPO_ROOT]` | Print available + in-use worktrees |
|
||||
| `status.sh` | Print fleet status + live pane commands |
|
||||
@@ -64,7 +64,7 @@ spare/N branch → spawn-agent.sh (--session-id UUID) → window + feat/bran
|
||||
↓
|
||||
ORCHESTRATOR:DONE
|
||||
↓
|
||||
verify-complete.sh: checkpoints ✓ + 0 threads + CI green
|
||||
verify-complete.sh: checkpoints ✓ + 0 threads + CI green + no fresh CHANGES_REQUESTED
|
||||
↓
|
||||
state → "done", notify, window KEPT OPEN
|
||||
↓
|
||||
@@ -328,7 +328,9 @@ For each agent, decide:
|
||||
|
||||
### Strict ORCHESTRATOR:DONE gate
|
||||
|
||||
`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CHANGES_REQUESTED, CI green, spawned_at). Run it:
|
||||
`verify-complete.sh` handles the main checks automatically (checkpoints, threads, CI green, spawned_at, and CHANGES_REQUESTED). Run it:
|
||||
|
||||
**CHANGES_REQUESTED staleness rule**: a `CHANGES_REQUESTED` review only blocks if it was submitted *after* the latest commit. If the latest commit postdates the review, the review is considered stale (feedback already addressed) and does not block. This avoids false negatives when a bot reviewer hasn't re-reviewed after the agent's fixing commits.
|
||||
|
||||
```bash
|
||||
SKILLS_DIR=~/.claude/orchestrator/scripts
|
||||
@@ -412,6 +414,38 @@ Please verify: <specific behaviors to check>.
|
||||
|
||||
Only one `/pr-test` at a time — they share ports and DB.
|
||||
|
||||
### /pr-test result evaluation
|
||||
|
||||
**PARTIAL on any headline feature scenario is an immediate blocker.** Do not approve, do not mark done, do not let the agent output `ORCHESTRATOR:DONE`.
|
||||
|
||||
| `/pr-test` result | Action |
|
||||
|---|---|
|
||||
| All headline scenarios **PASS** | Proceed to evaluation step 2 |
|
||||
| Any headline scenario **PARTIAL** | Re-brief the agent immediately — see below |
|
||||
| Any headline scenario **FAIL** | Re-brief the agent immediately |
|
||||
|
||||
**What PARTIAL means**: the feature is only partly working. Example: the Apply button never appeared, or the AI returned no action blocks. The agent addressed part of the objective but not all of it.
|
||||
|
||||
**When any headline scenario is PARTIAL or FAIL:**
|
||||
|
||||
1. Do NOT mark the agent done or accept `ORCHESTRATOR:DONE`
|
||||
2. Re-brief the agent with the specific scenario that failed and what was missing:
|
||||
```bash
|
||||
tmux send-keys -t SESSION:WIN "PARTIAL result on /pr-test — S5 (Apply button) never appeared. The AI must output JSON action blocks for the Apply button to render. Fix this before re-running /pr-test."
|
||||
sleep 0.3
|
||||
tmux send-keys -t SESSION:WIN Enter
|
||||
```
|
||||
3. Set state back to `running`:
|
||||
```bash
|
||||
jq --arg w "SESSION:WIN" '(.agents[] | select(.window == $w)).state = "running"' \
|
||||
~/.claude/orchestrator-state.json > /tmp/orch.tmp && mv /tmp/orch.tmp ~/.claude/orchestrator-state.json
|
||||
```
|
||||
4. Wait for new `ORCHESTRATOR:DONE`, then re-run `/pr-test` from scratch
|
||||
|
||||
**Rule: only ALL-PASS qualifies for approval.** A mix of PASS + PARTIAL is a failure.
|
||||
|
||||
> **Why this matters**: PR #12699 was wrongly approved with S5 PARTIAL — the AI never output JSON action blocks so the Apply button never appeared. The fix was already in the agent's reach but slipped through because PARTIAL was not treated as blocking.
|
||||
|
||||
### 2. Do your own evaluation
|
||||
|
||||
1. **Read the PR diff and objective** — does the code actually implement what was asked? Is anything obviously missing or half-done?
|
||||
@@ -421,8 +455,9 @@ Only one `/pr-test` at a time — they share ports and DB.
|
||||
|
||||
### 3. Decide
|
||||
|
||||
- `/pr-test` passes + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed
|
||||
- `/pr-test` fails or evaluation finds gaps → re-brief the agent with specific failures, set state back to `running`
|
||||
- `/pr-test` all scenarios PASS + evaluation looks good → mark `done` in state, tell the user the PR is ready, ask if window should be closed
|
||||
- `/pr-test` any scenario PARTIAL or FAIL → re-brief the agent with the specific failing scenario, set state back to `running` (see `/pr-test result evaluation` above)
|
||||
- Evaluation finds gaps even with all PASS → re-brief the agent with specific gaps, set state back to `running`
|
||||
|
||||
**Never mark done based purely on script output.** You hold the full objective context; the script does not.
|
||||
|
||||
@@ -441,6 +476,7 @@ Stop the fleet (`active = false`) when **all** of the following are true:
|
||||
| All agents are `done` or `escalated` | `jq '[.agents[] | select(.state | test("running\|stuck\|idle\|waiting_approval"))] | length' ~/.claude/orchestrator-state.json` == 0 |
|
||||
| All PRs have 0 unresolved review threads | GraphQL `isResolved` check per PR |
|
||||
| All PRs have green CI **on a run triggered after the agent's last push** | `gh run list --branch BRANCH --limit 1` timestamp > `spawned_at` in state |
|
||||
| No fresh CHANGES_REQUESTED (after latest commit) | `verify-complete.sh` checks this — stale pre-commit reviews are ignored |
|
||||
| No agents are `escalated` without human review | If any are escalated, surface to user first |
|
||||
|
||||
**Do NOT stop just because agents output `ORCHESTRATOR:DONE`.** That is a signal to verify, not a signal to stop.
|
||||
|
||||
@@ -115,13 +115,64 @@ if [ "$UNRESOLVED" -gt 0 ]; then
|
||||
fi
|
||||
|
||||
# --- Check 6: no CHANGES_REQUESTED (checked AFTER CI — bots post reviews after their check) ---
|
||||
CHANGES_REQUESTED=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||
--json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED")] | length' 2>/dev/null || echo "0")
|
||||
# A CHANGES_REQUESTED review is stale if the latest commit was pushed AFTER the review was submitted.
|
||||
# Stale reviews (pre-dating the fixing commits) should not block verification.
|
||||
#
|
||||
# Fetch commits and latestReviews in a single call and fail closed — if gh fails,
|
||||
# treat that as NOT COMPLETE rather than silently passing.
|
||||
# Use latestReviews (not reviews) so each reviewer's latest state is used — superseded
|
||||
# CHANGES_REQUESTED entries are automatically excluded when the reviewer later approved.
|
||||
# Note: we intentionally use committedDate (not PR updatedAt) because updatedAt changes on any
|
||||
# PR activity (bot comments, label changes) which would create false negatives.
|
||||
PR_REVIEW_METADATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||
--json commits,latestReviews 2>/dev/null) || {
|
||||
echo "NOT COMPLETE: unable to fetch PR review metadata for PR #$PR_NUMBER" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$CHANGES_REQUESTED" -gt 0 ]; then
|
||||
REQUESTERS=$(gh pr view "$PR_NUMBER" --repo "$REPO" \
|
||||
--json reviews --jq '[.reviews[] | select(.state == "CHANGES_REQUESTED") | .author.login] | join(", ")' 2>/dev/null || echo "unknown")
|
||||
echo "NOT COMPLETE: CHANGES_REQUESTED from ${REQUESTERS} on PR #$PR_NUMBER" >&2
|
||||
LATEST_COMMIT_DATE=$(jq -r '.commits[-1].committedDate // ""' <<< "$PR_REVIEW_METADATA")
|
||||
CHANGES_REQUESTED_REVIEWS=$(jq '[.latestReviews[]? | select(.state == "CHANGES_REQUESTED")]' <<< "$PR_REVIEW_METADATA")
|
||||
|
||||
BLOCKING_CHANGES_REQUESTED=0
|
||||
BLOCKING_REQUESTERS=""
|
||||
|
||||
if [ -n "$LATEST_COMMIT_DATE" ] && [ "$(echo "$CHANGES_REQUESTED_REVIEWS" | jq length)" -gt 0 ]; then
|
||||
if date --version >/dev/null 2>&1; then
|
||||
LATEST_COMMIT_EPOCH=$(date -d "$LATEST_COMMIT_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
else
|
||||
LATEST_COMMIT_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LATEST_COMMIT_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
fi
|
||||
|
||||
while IFS= read -r review; do
|
||||
[ -z "$review" ] && continue
|
||||
REVIEW_DATE=$(echo "$review" | jq -r '.submittedAt // ""')
|
||||
REVIEWER=$(echo "$review" | jq -r '.author.login // "unknown"')
|
||||
if [ -z "$REVIEW_DATE" ]; then
|
||||
# No submission date — treat as fresh (conservative: blocks verification)
|
||||
BLOCKING_CHANGES_REQUESTED=$(( BLOCKING_CHANGES_REQUESTED + 1 ))
|
||||
BLOCKING_REQUESTERS="${BLOCKING_REQUESTERS:+$BLOCKING_REQUESTERS, }${REVIEWER}"
|
||||
else
|
||||
if date --version >/dev/null 2>&1; then
|
||||
REVIEW_EPOCH=$(date -d "$REVIEW_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
else
|
||||
REVIEW_EPOCH=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$REVIEW_DATE" "+%s" 2>/dev/null || echo "0")
|
||||
fi
|
||||
if [ "$REVIEW_EPOCH" -gt "$LATEST_COMMIT_EPOCH" ]; then
|
||||
# Review was submitted AFTER latest commit — still fresh, blocks verification
|
||||
BLOCKING_CHANGES_REQUESTED=$(( BLOCKING_CHANGES_REQUESTED + 1 ))
|
||||
BLOCKING_REQUESTERS="${BLOCKING_REQUESTERS:+$BLOCKING_REQUESTERS, }${REVIEWER}"
|
||||
fi
|
||||
# Review submitted BEFORE latest commit — stale, skip
|
||||
fi
|
||||
done <<< "$(echo "$CHANGES_REQUESTED_REVIEWS" | jq -c '.[]')"
|
||||
else
|
||||
# No commit date or no changes_requested — check raw count as fallback
|
||||
BLOCKING_CHANGES_REQUESTED=$(echo "$CHANGES_REQUESTED_REVIEWS" | jq length 2>/dev/null || echo "0")
|
||||
BLOCKING_REQUESTERS=$(echo "$CHANGES_REQUESTED_REVIEWS" | jq -r '[.[].author.login] | join(", ")' 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
|
||||
if [ "$BLOCKING_CHANGES_REQUESTED" -gt 0 ]; then
|
||||
echo "NOT COMPLETE: CHANGES_REQUESTED (after latest commit) from ${BLOCKING_REQUESTERS} on PR #$PR_NUMBER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import "./globals.css";
|
||||
import { Providers } from "@/app/providers";
|
||||
import { CookieConsentBanner } from "@/components/molecules/CookieConsentBanner/CookieConsentBanner";
|
||||
import { ErrorBoundary } from "@/components/molecules/ErrorBoundary/ErrorBoundary";
|
||||
import { ChangelogPopup } from "@/components/molecules/ChangelogPopup/ChangelogPopup";
|
||||
import TallyPopupSimple from "@/components/molecules/TallyPoup/TallyPopup";
|
||||
import { Toaster } from "@/components/molecules/Toast/toaster";
|
||||
import { SetupAnalytics } from "@/services/analytics";
|
||||
@@ -66,7 +65,6 @@ export default async function RootLayout({
|
||||
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
|
||||
{children}
|
||||
<TallyPopupSimple />
|
||||
<ChangelogPopup />
|
||||
<VercelAnalyticsWrapper />
|
||||
|
||||
{/* React Query DevTools is only available in development */}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ArrowRight, ArrowSquareOut, Sparkle, X } from "@phosphor-icons/react";
|
||||
import { ChangelogModal } from "./components/ChangelogModal";
|
||||
import { useChangelog } from "./useChangelog";
|
||||
|
||||
export function ChangelogPopup() {
|
||||
const {
|
||||
isVisible,
|
||||
latestEntry,
|
||||
allEntries,
|
||||
isFading,
|
||||
dismiss,
|
||||
pauseAutoDismiss,
|
||||
resumeAutoDismiss,
|
||||
showFullChangelog,
|
||||
openFullChangelog,
|
||||
closeFullChangelog,
|
||||
selectedEntry,
|
||||
selectEntry,
|
||||
entryMarkdown,
|
||||
isLoadingMarkdown,
|
||||
} = useChangelog();
|
||||
|
||||
if (showFullChangelog) {
|
||||
return (
|
||||
<ChangelogModal
|
||||
entries={allEntries}
|
||||
selectedEntry={selectedEntry}
|
||||
entryMarkdown={entryMarkdown}
|
||||
isLoadingMarkdown={isLoadingMarkdown}
|
||||
onSelectEntry={selectEntry}
|
||||
onClose={closeFullChangelog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isVisible || !latestEntry) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 w-[400px] max-w-[calc(100vw-2rem)] transition-all duration-500 ease-out ${
|
||||
isFading
|
||||
? "pointer-events-none translate-y-2 opacity-0"
|
||||
: "translate-y-0 opacity-100"
|
||||
}`}
|
||||
onMouseEnter={pauseAutoDismiss}
|
||||
onMouseLeave={resumeAutoDismiss}
|
||||
role="dialog"
|
||||
aria-label="What's new"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background shadow-2xl shadow-black/10">
|
||||
<div className="bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkle className="h-4 w-4 text-white/90" weight="fill" />
|
||||
<Text
|
||||
variant="body-medium"
|
||||
as="span"
|
||||
className="text-sm font-semibold text-white"
|
||||
>
|
||||
What's New
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="rounded-md p-0.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Dismiss changelog"
|
||||
>
|
||||
<X className="h-4 w-4" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="text-[15px] font-semibold leading-snug text-foreground"
|
||||
>
|
||||
{latestEntry.highlights}
|
||||
</Text>
|
||||
<Text variant="body" className="mt-1 text-xs text-muted-foreground">
|
||||
{latestEntry.dateRange}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border bg-secondary/50 px-5 py-2.5">
|
||||
<button
|
||||
onClick={() => openFullChangelog(latestEntry)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Read more
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
<a
|
||||
href={latestEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs font-medium text-accent transition-colors hover:text-accent/80"
|
||||
>
|
||||
Open in docs
|
||||
<ArrowSquareOut className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const CHANGELOG_BASE_URL =
|
||||
"https://agpt.co/docs/platform/changelog/changelog";
|
||||
export const CHANGELOG_INDEX_MD_URL = `${CHANGELOG_BASE_URL}.md`;
|
||||
export const STORAGE_KEY = "autogpt-changelog-last-seen";
|
||||
export const AUTO_DISMISS_MS = 8000;
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function ChangelogMarkdownContent({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-foreground prose-img:rounded-lg prose-img:shadow-md"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="my-4 h-auto max-w-full rounded-lg shadow-md"
|
||||
loading="lazy"
|
||||
{...(props as React.ImgHTMLAttributes<HTMLImageElement>)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ArrowSquareOut, Sparkle, X } from "@phosphor-icons/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { CHANGELOG_BASE_URL } from "../changelog-constants";
|
||||
import { ChangelogEntry } from "../useChangelog";
|
||||
|
||||
const ChangelogMarkdownContent = dynamic(
|
||||
() =>
|
||||
import("./ChangelogMarkdownContent").then(
|
||||
(mod) => mod.ChangelogMarkdownContent,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
Loading…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface ChangelogModalProps {
|
||||
entries: ChangelogEntry[];
|
||||
selectedEntry: ChangelogEntry | null;
|
||||
entryMarkdown: string | null;
|
||||
isLoadingMarkdown: boolean;
|
||||
onSelectEntry: (entry: ChangelogEntry) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogModal({
|
||||
entries,
|
||||
selectedEntry,
|
||||
entryMarkdown,
|
||||
isLoadingMarkdown,
|
||||
onSelectEntry,
|
||||
onClose,
|
||||
}: ChangelogModalProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-4 z-50 flex overflow-hidden rounded-2xl border border-border bg-background shadow-2xl sm:inset-6 md:inset-10">
|
||||
<div className="hidden w-72 shrink-0 flex-col border-r border-border bg-secondary md:flex">
|
||||
<div className="flex items-center gap-2 bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 px-4 py-3">
|
||||
<Sparkle className="h-4 w-4 text-white" weight="fill" />
|
||||
<Text
|
||||
variant="body-medium"
|
||||
as="span"
|
||||
className="text-sm font-bold text-white"
|
||||
>
|
||||
Changelog
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.slug}
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
className={`mb-1 block w-full rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
selectedEntry?.slug === entry.slug
|
||||
? "bg-accent/10 ring-1 ring-accent/20"
|
||||
: "hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="text-[13px] font-medium leading-snug text-foreground"
|
||||
>
|
||||
{entry.highlights}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
className="mt-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{entry.dateRange}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<a
|
||||
href={CHANGELOG_BASE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 rounded-full bg-primary px-3 py-2 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
View on Docs
|
||||
<ArrowSquareOut className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border bg-background px-4 py-2">
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Sparkle className="h-4 w-4 text-accent" weight="fill" />
|
||||
{entries.length > 0 && (
|
||||
<select
|
||||
className="max-w-[200px] truncate rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground"
|
||||
value={selectedEntry?.slug || ""}
|
||||
onChange={(e) => {
|
||||
const entry = entries.find((en) => en.slug === e.target.value);
|
||||
if (entry) onSelectEntry(entry);
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<option key={entry.slug} value={entry.slug}>
|
||||
{entry.dateRange}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{selectedEntry && (
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground md:flex"
|
||||
>
|
||||
Open in docs
|
||||
<ArrowSquareOut className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close changelog"
|
||||
>
|
||||
<X className="h-5 w-5" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6 md:px-10">
|
||||
{isLoadingMarkdown && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{entryMarkdown && (
|
||||
<ChangelogMarkdownContent markdown={entryMarkdown} />
|
||||
)}
|
||||
{!isLoadingMarkdown && !entryMarkdown && selectedEntry && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Could not load changelog entry.{" "}
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline"
|
||||
>
|
||||
View on docs
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AUTO_DISMISS_MS,
|
||||
CHANGELOG_BASE_URL,
|
||||
CHANGELOG_INDEX_MD_URL,
|
||||
STORAGE_KEY,
|
||||
} from "./changelog-constants";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
slug: string;
|
||||
dateRange: string;
|
||||
highlights: string;
|
||||
url: string;
|
||||
mdUrl: string;
|
||||
}
|
||||
|
||||
function parseChangelogIndex(md: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
const rowPattern =
|
||||
/\|\s*\[([^\]]+)\]\((https?:\/\/[^)]+\/changelog\/changelog\/([a-z0-9-]+))\)\s*\|\s*([^|]+)\|/g;
|
||||
|
||||
let match;
|
||||
while ((match = rowPattern.exec(md)) !== null) {
|
||||
const [, dateRange, url, slug, highlights] = match;
|
||||
entries.push({
|
||||
slug,
|
||||
dateRange: dateRange.trim(),
|
||||
highlights: highlights.trim(),
|
||||
url,
|
||||
mdUrl: `${CHANGELOG_BASE_URL}/${slug}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function useChangelog() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isFading, setIsFading] = useState(false);
|
||||
const [showFullChangelog, setShowFullChangelog] = useState(false);
|
||||
const [latestEntry, setLatestEntry] = useState<ChangelogEntry | null>(null);
|
||||
const [allEntries, setAllEntries] = useState<ChangelogEntry[]>([]);
|
||||
const [selectedEntry, setSelectedEntry] = useState<ChangelogEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [entryMarkdown, setEntryMarkdown] = useState<string | null>(null);
|
||||
const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false);
|
||||
|
||||
const autoDismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isPaused = useRef(false);
|
||||
const isDismissing = useRef(false);
|
||||
const mdAbort = useRef<AbortController | null>(null);
|
||||
|
||||
function clearTimers() {
|
||||
if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current);
|
||||
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
||||
autoDismissTimer.current = null;
|
||||
fadeTimer.current = null;
|
||||
}
|
||||
|
||||
function markAsSeen(slug: string) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, slug);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (isDismissing.current) return;
|
||||
isDismissing.current = true;
|
||||
clearTimers();
|
||||
setIsFading(true);
|
||||
fadeTimer.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsFading(false);
|
||||
isDismissing.current = false;
|
||||
if (latestEntry) markAsSeen(latestEntry.slug);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function startAutoDismiss() {
|
||||
if (isPaused.current || showFullChangelog) return;
|
||||
clearTimers();
|
||||
autoDismissTimer.current = setTimeout(() => {
|
||||
if (!isPaused.current && !showFullChangelog) dismiss();
|
||||
}, AUTO_DISMISS_MS);
|
||||
}
|
||||
|
||||
function pauseAutoDismiss() {
|
||||
isPaused.current = true;
|
||||
if (autoDismissTimer.current) {
|
||||
clearTimeout(autoDismissTimer.current);
|
||||
autoDismissTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resumeAutoDismiss() {
|
||||
if (isDismissing.current) return;
|
||||
isPaused.current = false;
|
||||
startAutoDismiss();
|
||||
}
|
||||
|
||||
function fetchEntryMarkdown(entry: ChangelogEntry) {
|
||||
mdAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
mdAbort.current = controller;
|
||||
|
||||
setIsLoadingMarkdown(true);
|
||||
setEntryMarkdown(null);
|
||||
|
||||
fetch(entry.mdUrl, { signal: controller.signal })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.then((md) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const cleaned = md
|
||||
.replace(/\{%.*?%\}/gs, "")
|
||||
.replace(/<figure>|<\/figure>/g, "")
|
||||
.replace(/<figcaption>.*?<\/figcaption>/gs, "")
|
||||
.replace(/<details>/g, "\n---\n")
|
||||
.replace(/<\/details>/g, "")
|
||||
.replace(/<summary>(.*?)<\/summary>/g, "### $1");
|
||||
setEntryMarkdown(cleaned);
|
||||
})
|
||||
.catch(() => {
|
||||
/* abort or network error — non-critical */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoadingMarkdown(false);
|
||||
});
|
||||
}
|
||||
|
||||
function openFullChangelog(entry?: ChangelogEntry) {
|
||||
clearTimers();
|
||||
isPaused.current = true;
|
||||
setIsVisible(false);
|
||||
setIsFading(false);
|
||||
isDismissing.current = false;
|
||||
const target = entry || latestEntry;
|
||||
if (target) {
|
||||
setSelectedEntry(target);
|
||||
fetchEntryMarkdown(target);
|
||||
markAsSeen(target.slug);
|
||||
}
|
||||
setShowFullChangelog(true);
|
||||
}
|
||||
|
||||
function closeFullChangelog() {
|
||||
mdAbort.current?.abort();
|
||||
setShowFullChangelog(false);
|
||||
setEntryMarkdown(null);
|
||||
setSelectedEntry(null);
|
||||
}
|
||||
|
||||
function selectEntry(entry: ChangelogEntry) {
|
||||
setSelectedEntry(entry);
|
||||
fetchEntryMarkdown(entry);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(CHANGELOG_INDEX_MD_URL)
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.then((md) => {
|
||||
if (cancelled || !md) return;
|
||||
|
||||
const entries = parseChangelogIndex(md);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
setAllEntries(entries);
|
||||
setLatestEntry(entries[0]);
|
||||
|
||||
try {
|
||||
const lastSeen = localStorage.getItem(STORAGE_KEY);
|
||||
if (lastSeen === entries[0].slug) return;
|
||||
} catch {
|
||||
/* show anyway */
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!cancelled) setIsVisible(true);
|
||||
}, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
/* non-critical */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimers();
|
||||
mdAbort.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && !isFading && !showFullChangelog) startAutoDismiss();
|
||||
}, [isVisible, isFading, showFullChangelog]);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
isFading,
|
||||
latestEntry,
|
||||
allEntries,
|
||||
entryMarkdown,
|
||||
isLoadingMarkdown,
|
||||
dismiss,
|
||||
pauseAutoDismiss,
|
||||
resumeAutoDismiss,
|
||||
showFullChangelog,
|
||||
openFullChangelog,
|
||||
closeFullChangelog,
|
||||
selectedEntry,
|
||||
selectEntry,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user