Merge master into ishan/fix-activity-clerkprovider

This commit is contained in:
Omar (OpenClaw)
2026-02-07 11:30:34 +00:00
20 changed files with 3385 additions and 208 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run --passWithNoTests --coverage",
"test:watch": "vitest",
"dev:lan": "next dev --hostname 0.0.0.0 --port 3000",
"api:gen": "orval --config ./orval.config.ts"
},
@@ -29,14 +31,19 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^25.0.1",
"lucide-react": "^0.563.0",
"orval": "^8.2.0",
"postcss": "^8.5.6",
@@ -44,6 +51,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
"typescript": "^5",
"vitest": "^2.1.8"
}
}

View File

@@ -342,6 +342,7 @@ export default function BoardDetailPage() {
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
@@ -1713,6 +1714,7 @@ export default function BoardDetailPage() {
);
} finally {
setIsPostingComment(false);
taskCommentInputRef.current?.focus();
}
};
@@ -2612,8 +2614,18 @@ export default function BoardDetailPage() {
</p>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Textarea
ref={taskCommentInputRef}
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (isPostingComment) return;
if (!newComment.trim()) return;
void handlePostComment();
}}
placeholder="Write a message for the assigned agent…"
className="min-h-[80px] bg-white"
/>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCcw } from "lucide-react";
import {
DialogFooter,
@@ -120,16 +121,40 @@ export function BoardOnboardingChat({
const isPageActive = usePageActive();
const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false);
const [awaitingAssistantFingerprint, setAwaitingAssistantFingerprint] =
useState<string | null>(null);
const [awaitingKind, setAwaitingKind] = useState<
"answer" | "extra_context" | null
>(null);
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState<string | null>(
null,
);
const [otherText, setOtherText] = useState("");
const [extraContext, setExtraContext] = useState("");
const [extraContextOpen, setExtraContextOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const freeTextRef = useRef<HTMLTextAreaElement | null>(null);
const extraContextRef = useRef<HTMLTextAreaElement | null>(null);
const normalizedMessages = useMemo(
() => normalizeMessages(session?.messages),
[session?.messages],
);
const lastAssistantFingerprint = useMemo(() => {
const rawMessages = session?.messages;
if (!rawMessages || !Array.isArray(rawMessages)) return "";
for (let idx = rawMessages.length - 1; idx >= 0; idx -= 1) {
const entry = rawMessages[idx];
if (!entry || typeof entry !== "object") continue;
const raw = entry as Record<string, unknown>;
if (raw.role !== "assistant") continue;
const content = typeof raw.content === "string" ? raw.content : "";
const timestamp = typeof raw.timestamp === "string" ? raw.timestamp : "";
return `${timestamp}|${content}`;
}
return "";
}, [session?.messages]);
const question = useMemo(
() => parseQuestion(normalizedMessages),
[normalizedMessages],
@@ -137,11 +162,26 @@ export function BoardOnboardingChat({
const draft: BoardOnboardingAgentComplete | null =
session?.draft_goal ?? null;
const isAwaitingAgent = useMemo(() => {
if (!awaitingAssistantFingerprint) return false;
return lastAssistantFingerprint === awaitingAssistantFingerprint;
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const wantsFreeText = useMemo(
() => selectedOptions.some((label) => isFreeTextOption(label)),
[selectedOptions],
);
useEffect(() => {
if (!wantsFreeText) return;
freeTextRef.current?.focus();
}, [wantsFreeText]);
useEffect(() => {
if (!extraContextOpen) return;
extraContextRef.current?.focus();
}, [extraContextOpen]);
useEffect(() => {
setSelectedOptions([]);
setOtherText("");
@@ -194,8 +234,12 @@ export function BoardOnboardingChat({
const handleAnswer = useCallback(
async (value: string, freeText?: string) => {
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true);
setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try {
const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
@@ -208,6 +252,12 @@ export function BoardOnboardingChat({
if (result.status !== 200) throw new Error("Unable to submit answer.");
setSession(result.data);
setOtherText("");
setSelectedOptions([]);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("answer");
setLastSubmittedAnswer(
freeText ? `${value}: ${freeText}` : value,
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit answer.",
@@ -216,7 +266,7 @@ export function BoardOnboardingChat({
setLoading(false);
}
},
[boardId],
[boardId, lastAssistantFingerprint],
);
const toggleOption = useCallback((label: string) => {
@@ -230,8 +280,12 @@ export function BoardOnboardingChat({
const submitExtraContext = useCallback(async () => {
const trimmed = extraContext.trim();
if (!trimmed) return;
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true);
setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try {
const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, {
@@ -242,6 +296,10 @@ export function BoardOnboardingChat({
throw new Error("Unable to submit extra context.");
setSession(result.data);
setExtraContext("");
setExtraContextOpen(false);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("extra_context");
setLastSubmittedAnswer("Additional context");
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit extra context.",
@@ -249,7 +307,7 @@ export function BoardOnboardingChat({
} finally {
setLoading(false);
}
}, [boardId, extraContext]);
}, [boardId, extraContext, lastAssistantFingerprint]);
const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim();
@@ -259,6 +317,15 @@ export function BoardOnboardingChat({
void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined);
}, [handleAnswer, otherText, selectedOptions, wantsFreeText]);
useEffect(() => {
if (!awaitingAssistantFingerprint) return;
if (lastAssistantFingerprint !== awaitingAssistantFingerprint) {
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
}
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const confirmGoal = async () => {
if (!draft) return;
setLoading(true);
@@ -303,6 +370,29 @@ export function BoardOnboardingChat({
<p className="text-sm text-slate-600">
Review the lead agent draft and confirm.
</p>
{isAwaitingAgent ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div className="flex items-center gap-2 font-medium text-slate-900">
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
<span>
{awaitingKind === "extra_context"
? "Updating the draft…"
: "Waiting for the agent…"}
</span>
</div>
{lastSubmittedAnswer ? (
<p className="mt-2 text-xs text-slate-600">
Sent:{" "}
<span className="font-medium text-slate-900">
{lastSubmittedAnswer}
</span>
</p>
) : null}
<p className="mt-1 text-xs text-slate-500">
This usually takes a few seconds.
</p>
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-semibold text-slate-900">Objective</p>
<p className="text-slate-700">{draft.objective || "—"}</p>
@@ -402,25 +492,28 @@ export function BoardOnboardingChat({
size="sm"
type="button"
onClick={() => setExtraContextOpen((prev) => !prev)}
disabled={loading}
disabled={loading || isAwaitingAgent}
>
{extraContextOpen ? "Hide" : "Add"}
</Button>
</div>
{extraContextOpen ? (
<div className="mt-2 space-y-2">
<Textarea
className="min-h-[84px]"
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
value={extraContext}
onChange={(event) => setExtraContext(event.target.value)}
{extraContextOpen ? (
<div className="mt-2 space-y-2">
<Textarea
ref={extraContextRef}
className="min-h-[84px]"
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
value={extraContext}
onChange={(event) => setExtraContext(event.target.value)}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (loading) return;
if (loading || isAwaitingAgent) return;
void submitExtraContext();
}}
disabled={loading || isAwaitingAgent}
/>
<div className="flex items-center justify-end">
<Button
@@ -428,13 +521,13 @@ export function BoardOnboardingChat({
size="sm"
type="button"
onClick={() => void submitExtraContext()}
disabled={loading || !extraContext.trim()}
disabled={loading || isAwaitingAgent || !extraContext.trim()}
>
{loading ? "Sending..." : "Send context"}
{loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Send context"}
</Button>
</div>
<p className="text-xs text-slate-500">
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
Tip: press Enter to send. Shift+Enter for a newline.
</p>
</div>
) : (
@@ -445,7 +538,7 @@ export function BoardOnboardingChat({
)}
</div>
<DialogFooter>
<Button onClick={confirmGoal} disabled={loading}>
<Button onClick={confirmGoal} disabled={loading || isAwaitingAgent} type="button">
Confirm goal
</Button>
</DialogFooter>
@@ -455,6 +548,29 @@ export function BoardOnboardingChat({
<p className="text-sm font-medium text-slate-900">
{question.question}
</p>
{isAwaitingAgent ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div className="flex items-center gap-2 font-medium text-slate-900">
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
<span>
{awaitingKind === "extra_context"
? "Updating the draft…"
: "Waiting for the next question…"}
</span>
</div>
{lastSubmittedAnswer ? (
<p className="mt-2 text-xs text-slate-600">
Sent:{" "}
<span className="font-medium text-slate-900">
{lastSubmittedAnswer}
</span>
</p>
) : null}
<p className="mt-1 text-xs text-slate-500">
This usually takes a few seconds.
</p>
</div>
) : null}
<div className="space-y-2">
{question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label);
@@ -464,7 +580,8 @@ export function BoardOnboardingChat({
variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start"
onClick={() => toggleOption(option.label)}
disabled={loading}
disabled={loading || isAwaitingAgent}
type="button"
>
{option.label}
</Button>
@@ -474,20 +591,23 @@ export function BoardOnboardingChat({
{wantsFreeText ? (
<div className="space-y-2">
<Textarea
ref={freeTextRef}
className="min-h-[84px]"
placeholder="Type your answer..."
value={otherText}
onChange={(event) => setOtherText(event.target.value)}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (loading) return;
if (loading || isAwaitingAgent) return;
submitAnswer();
}}
disabled={loading || isAwaitingAgent}
/>
<p className="text-xs text-slate-500">
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
Tip: press Enter to send. Shift+Enter for a newline.
</p>
</div>
) : null}
@@ -495,16 +615,22 @@ export function BoardOnboardingChat({
<Button
variant="outline"
onClick={submitAnswer}
type="button"
disabled={
loading ||
isAwaitingAgent ||
selectedOptions.length === 0 ||
(wantsFreeText && !otherText.trim())
}
>
{loading ? "Sending..." : "Next"}
{loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Next"}
</Button>
{loading ? (
<p className="text-xs text-slate-500">Sending your answer</p>
) : isAwaitingAgent ? (
<p className="text-xs text-slate-500">
Waiting for the agent to respond
</p>
) : null}
</div>
</div>

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { createExponentialBackoff } from "./backoff";
describe("createExponentialBackoff", () => {
it("increments attempt and clamps delay", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const backoff = createExponentialBackoff({ baseMs: 100, factor: 2, maxMs: 250, jitter: 0 });
expect(backoff.attempt()).toBe(0);
expect(backoff.nextDelayMs()).toBe(100);
expect(backoff.attempt()).toBe(1);
expect(backoff.nextDelayMs()).toBe(200);
expect(backoff.nextDelayMs()).toBe(250); // capped
});
it("reset brings attempt back to zero", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const backoff = createExponentialBackoff({ baseMs: 100, jitter: 0 });
backoff.nextDelayMs();
expect(backoff.attempt()).toBe(1);
backoff.reset();
expect(backoff.attempt()).toBe(0);
});
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

20
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/setupTests.ts"],
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
reportsDirectory: "./coverage",
include: ["src/**/*.{ts,tsx}"],
exclude: [
"**/*.d.ts",
"src/**/__generated__/**",
"src/**/generated/**",
],
},
},
});