Merge pull request #2204 from paperclipai/pap-1007-operator-polish

fix: apply operator polish across comments, invites, routines, and health
This commit is contained in:
Dotta 2026-03-30 14:48:24 -05:00 committed by GitHub
commit ccb5cce4ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 624 additions and 208 deletions

View file

@ -17,6 +17,27 @@ function asErrorText(value: unknown): string {
} }
} }
function printToolResult(block: Record<string, unknown>): void {
const isError = block.is_error === true;
let text = "";
if (typeof block.content === "string") {
text = block.content;
} else if (Array.isArray(block.content)) {
const parts: string[] = [];
for (const part of block.content) {
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
const record = part as Record<string, unknown>;
if (typeof record.text === "string") parts.push(record.text);
}
text = parts.join("\n");
}
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
if (text) {
console.log((isError ? pc.red : pc.gray)(text));
}
}
export function printClaudeStreamEvent(raw: string, debug: boolean): void { export function printClaudeStreamEvent(raw: string, debug: boolean): void {
const line = raw.trim(); const line = raw.trim();
if (!line) return; if (!line) return;
@ -51,6 +72,9 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
if (blockType === "text") { if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : ""; const text = typeof block.text === "string" ? block.text : "";
if (text) console.log(pc.green(`assistant: ${text}`)); if (text) console.log(pc.green(`assistant: ${text}`));
} else if (blockType === "thinking") {
const text = typeof block.thinking === "string" ? block.thinking : "";
if (text) console.log(pc.gray(`thinking: ${text}`));
} else if (blockType === "tool_use") { } else if (blockType === "tool_use") {
const name = typeof block.name === "string" ? block.name : "unknown"; const name = typeof block.name === "string" ? block.name : "unknown";
console.log(pc.yellow(`tool_call: ${name}`)); console.log(pc.yellow(`tool_call: ${name}`));
@ -62,6 +86,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
return; return;
} }
if (type === "user") {
const message =
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
? (parsed.message as Record<string, unknown>)
: {};
const content = Array.isArray(message.content) ? message.content : [];
for (const blockRaw of content) {
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
const block = blockRaw as Record<string, unknown>;
if (typeof block.type === "string" && block.type === "tool_result") {
printToolResult(block);
}
}
return;
}
if (type === "result") { if (type === "result") {
const usage = const usage =
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage) typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)

View file

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server"; import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server";
import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
describe("claude_local max-turn detection", () => { describe("claude_local max-turn detection", () => {
it("detects max-turn exhaustion by subtype", () => { it("detects max-turn exhaustion by subtype", () => {
@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("claude_local ui stdout parser", () => {
it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => {
const ts = "2026-03-29T00:00:00.000Z";
expect(
parseClaudeStdoutLine(
JSON.stringify({
type: "system",
subtype: "init",
model: "claude-sonnet-4-6",
session_id: "claude-session-1",
}),
ts,
),
).toEqual([
{
kind: "init",
ts,
model: "claude-sonnet-4-6",
sessionId: "claude-session-1",
},
]);
expect(
parseClaudeStdoutLine(
JSON.stringify({
type: "assistant",
session_id: "claude-session-1",
message: {
content: [
{ type: "text", text: "I will inspect the repo." },
{ type: "thinking", thinking: "Checking the adapter wiring" },
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
],
},
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I will inspect the repo." },
{ kind: "thinking", ts, text: "Checking the adapter wiring" },
{ kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } },
]);
expect(
parseClaudeStdoutLine(
JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
is_error: false,
},
],
},
}),
ts,
),
).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "tool_1",
content: "AGENTS.md\nREADME.md",
isError: false,
},
]);
});
});
function stripAnsi(value: string) {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("claude_local cli formatter", () => {
it("prints the user-visible and background transcript events from stream-json output", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
printClaudeStreamEvent(
JSON.stringify({
type: "system",
subtype: "init",
model: "claude-sonnet-4-6",
session_id: "claude-session-1",
}),
false,
);
printClaudeStreamEvent(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "text", text: "I will inspect the repo." },
{ type: "thinking", thinking: "Checking the adapter wiring" },
{ type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } },
],
},
}),
false,
);
printClaudeStreamEvent(
JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
content: [{ type: "text", text: "AGENTS.md\nREADME.md" }],
is_error: false,
},
],
},
}),
false,
);
printClaudeStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 },
total_cost_usd: 0.00042,
}),
false,
);
const lines = spy.mock.calls
.map((call) => call.map((value) => String(value)).join(" "))
.map(stripAnsi);
expect(lines).toEqual(
expect.arrayContaining([
"Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)",
"assistant: I will inspect the repo.",
"thinking: Checking the adapter wiring",
"tool_call: bash",
'{\n "command": "ls -1"\n}',
"tool_result",
"AGENTS.md\nREADME.md",
"result:",
"Done",
"tokens: in=10 out=5 cached=2 cost=$0.000420",
]),
);
} finally {
spy.mockRestore();
}
});
});

View file

@ -1,16 +1,56 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import type { Db } from "@paperclipai/db";
import { healthRoutes } from "../routes/health.js"; import { healthRoutes } from "../routes/health.js";
import * as devServerStatus from "../dev-server-status.js";
import { serverVersion } from "../version.js"; import { serverVersion } from "../version.js";
describe("GET /health", () => { describe("GET /health", () => {
const app = express(); beforeEach(() => {
app.use("/health", healthRoutes()); vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns 200 with status ok", async () => { it("returns 200 with status ok", async () => {
const app = express();
app.use("/health", healthRoutes());
const res = await request(app).get("/health"); const res = await request(app).get("/health");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ status: "ok", version: serverVersion }); expect(res.body).toEqual({ status: "ok", version: serverVersion });
}); });
it("returns 200 when the database probe succeeds", async () => {
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
} as unknown as Db;
const app = express();
app.use("/health", healthRoutes(db));
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ status: "ok", version: serverVersion });
});
it("returns 503 when the database probe fails", async () => {
const db = {
execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
} as unknown as Db;
const app = express();
app.use("/health", healthRoutes(db));
const res = await request(app).get("/health");
expect(res.status).toBe(503);
expect(res.body).toEqual({
status: "unhealthy",
version: serverVersion,
error: "database_unreachable",
});
});
}); });

View file

@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { companies, invites } from "@paperclipai/db";
import { accessRoutes } from "../routes/access.js"; import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js"; import { errorHandler } from "../middleware/index.js";
@ -51,19 +52,35 @@ function createDbStub() {
inviteType: "company_join", inviteType: "company_join",
allowedJoinTypes: "agent", allowedJoinTypes: "agent",
defaultsPayload: null, defaultsPayload: null,
expiresAt: new Date("2026-03-07T00:10:00.000Z"), expiresAt: new Date("2099-03-07T00:10:00.000Z"),
invitedByUserId: null, invitedByUserId: null,
tokenHash: "hash", tokenHash: "hash",
revokedAt: null, revokedAt: null,
acceptedAt: null, acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"), createdAt: new Date("2099-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"), updatedAt: new Date("2099-03-07T00:00:00.000Z"),
}; };
const returning = vi.fn().mockResolvedValue([createdInvite]); const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning }); const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values }); const insert = vi.fn().mockReturnValue({ values });
const select = vi.fn(() => ({
from(table: unknown) {
return {
where: vi.fn().mockImplementation(() => {
if (table === invites) {
return Promise.resolve([createdInvite]);
}
if (table === companies) {
return Promise.resolve([{ name: "Acme AI" }]);
}
return Promise.resolve([]);
}),
};
},
}));
return { return {
insert, insert,
select,
}; };
} }
@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent"); expect(res.body.allowedJoinTypes).toBe("agent");
expect(typeof res.body.token).toBe("string"); expect(typeof res.body.token).toBe("string");
expect(res.body.companyName).toBe("Acme AI");
expect(res.body.onboardingTextPath).toContain("/api/invites/"); expect(res.body.onboardingTextPath).toContain("/api/invites/");
}); });
it("includes companyName in invite summary responses", async () => {
const db = createDbStub();
const app = createApp(
{
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
db,
);
const res = await request(app).get("/api/invites/pcp_invite_test");
expect(res.status).toBe(200);
expect(res.body.companyId).toBe("company-1");
expect(res.body.companyName).toBe("Acme AI");
});
it("allows board callers with invite permission", async () => { it("allows board callers with invite permission", async () => {
const db = createDbStub(); const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true); mockAccessService.canUser.mockResolvedValue(true);

View file

@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db";
import { import {
agentApiKeys, agentApiKeys,
authUsers, authUsers,
companies,
invites, invites,
joinRequests joinRequests
} from "@paperclipai/db"; } from "@paperclipai/db";
@ -856,7 +857,8 @@ export function normalizeAgentDefaultsForJoin(input: {
function toInviteSummaryResponse( function toInviteSummaryResponse(
req: Request, req: Request,
token: string, token: string,
invite: typeof invites.$inferSelect invite: typeof invites.$inferSelect,
companyName: string | null = null
) { ) {
const baseUrl = requestBaseUrl(req); const baseUrl = requestBaseUrl(req);
const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingPath = `/api/invites/${token}/onboarding`;
@ -865,6 +867,7 @@ function toInviteSummaryResponse(
return { return {
id: invite.id, id: invite.id,
companyId: invite.companyId, companyId: invite.companyId,
companyName,
inviteType: invite.inviteType, inviteType: invite.inviteType,
allowedJoinTypes: invite.allowedJoinTypes, allowedJoinTypes: invite.allowedJoinTypes,
expiresAt: invite.expiresAt, expiresAt: invite.expiresAt,
@ -993,6 +996,7 @@ function buildInviteOnboardingManifest(
token: string, token: string,
invite: typeof invites.$inferSelect, invite: typeof invites.$inferSelect,
opts: { opts: {
companyName?: string | null;
deploymentMode: DeploymentMode; deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure; deploymentExposure: DeploymentExposure;
bindHost: string; bindHost: string;
@ -1024,7 +1028,12 @@ function buildInviteOnboardingManifest(
}); });
return { return {
invite: toInviteSummaryResponse(req, token, invite), invite: toInviteSummaryResponse(
req,
token,
invite,
opts.companyName ?? null
),
onboarding: { onboarding: {
instructions: instructions:
"Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).",
@ -1084,6 +1093,7 @@ export function buildInviteOnboardingTextDocument(
token: string, token: string,
invite: typeof invites.$inferSelect, invite: typeof invites.$inferSelect,
opts: { opts: {
companyName?: string | null;
deploymentMode: DeploymentMode; deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure; deploymentExposure: DeploymentExposure;
bindHost: string; bindHost: string;
@ -1133,6 +1143,10 @@ export function buildInviteOnboardingTextDocument(
- expiresAt: ${invite.expiresAt.toISOString()} - expiresAt: ${invite.expiresAt.toISOString()}
`); `);
if (manifest.invite.companyName) {
lines.push(`- companyName: ${manifest.invite.companyName}`);
}
if (onboarding.inviteMessage) { if (onboarding.inviteMessage) {
appendBlock(` appendBlock(`
## Message from inviter ## Message from inviter
@ -1882,6 +1896,16 @@ export function accessRoutes(
return { token, created, normalizedAgentMessage }; return { token, created, normalizedAgentMessage };
} }
async function getInviteCompanyName(companyId: string | null) {
if (!companyId) return null;
const company = await db
.select({ name: companies.name })
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
return company?.name ?? null;
}
router.get("/skills/available", (_req, res) => { router.get("/skills/available", (_req, res) => {
res.json({ skills: listAvailableSkills() }); res.json({ skills: listAvailableSkills() });
}); });
@ -1942,11 +1966,18 @@ export function accessRoutes(
} }
}); });
const inviteSummary = toInviteSummaryResponse(req, token, created); const companyName = await getInviteCompanyName(created.companyId);
const inviteSummary = toInviteSummaryResponse(
req,
token,
created,
companyName
);
res.status(201).json({ res.status(201).json({
...created, ...created,
token, token,
inviteUrl: `/invite/${token}`, inviteUrl: `/invite/${token}`,
companyName,
onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl, onboardingTextUrl: inviteSummary.onboardingTextUrl,
inviteMessage: inviteSummary.inviteMessage inviteMessage: inviteSummary.inviteMessage
@ -1987,11 +2018,18 @@ export function accessRoutes(
} }
}); });
const inviteSummary = toInviteSummaryResponse(req, token, created); const companyName = await getInviteCompanyName(created.companyId);
const inviteSummary = toInviteSummaryResponse(
req,
token,
created,
companyName
);
res.status(201).json({ res.status(201).json({
...created, ...created,
token, token,
inviteUrl: `/invite/${token}`, inviteUrl: `/invite/${token}`,
companyName,
onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl, onboardingTextUrl: inviteSummary.onboardingTextUrl,
inviteMessage: inviteSummary.inviteMessage inviteMessage: inviteSummary.inviteMessage
@ -2016,7 +2054,8 @@ export function accessRoutes(
throw notFound("Invite not found"); throw notFound("Invite not found");
} }
res.json(toInviteSummaryResponse(req, token, invite)); const companyName = await getInviteCompanyName(invite.companyId);
res.json(toInviteSummaryResponse(req, token, invite, companyName));
}); });
router.get("/invites/:token/onboarding", async (req, res) => { router.get("/invites/:token/onboarding", async (req, res) => {
@ -2031,7 +2070,11 @@ export function accessRoutes(
throw notFound("Invite not found"); throw notFound("Invite not found");
} }
res.json(buildInviteOnboardingManifest(req, token, invite, opts)); const companyName = await getInviteCompanyName(invite.companyId);
res.json(buildInviteOnboardingManifest(req, token, invite, {
...opts,
companyName
}));
}); });
router.get("/invites/:token/onboarding.txt", async (req, res) => { router.get("/invites/:token/onboarding.txt", async (req, res) => {
@ -2046,9 +2089,15 @@ export function accessRoutes(
throw notFound("Invite not found"); throw notFound("Invite not found");
} }
const companyName = await getInviteCompanyName(invite.companyId);
res res
.type("text/plain; charset=utf-8") .type("text/plain; charset=utf-8")
.send(buildInviteOnboardingTextDocument(req, token, invite, opts)); .send(
buildInviteOnboardingTextDocument(req, token, invite, {
...opts,
companyName
})
);
}); });
router.get("/invites/:token/test-resolution", async (req, res) => { router.get("/invites/:token/test-resolution", async (req, res) => {
@ -2458,11 +2507,15 @@ export function accessRoutes(
const response = toJoinRequestResponse(created); const response = toJoinRequestResponse(created);
if (claimSecret) { if (claimSecret) {
const companyName = await getInviteCompanyName(invite.companyId);
const onboardingManifest = buildInviteOnboardingManifest( const onboardingManifest = buildInviteOnboardingManifest(
req, req,
token, token,
invite, invite,
opts {
...opts,
companyName
}
); );
res.status(202).json({ res.status(202).json({
...response, ...response,

View file

@ -29,6 +29,17 @@ export function healthRoutes(
return; return;
} }
try {
await db.execute(sql`SELECT 1`);
} catch {
res.status(503).json({
status: "unhealthy",
version: serverVersion,
error: "database_unreachable",
});
return;
}
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
let bootstrapInviteActive = false; let bootstrapInviteActive = false;
if (opts.deploymentMode === "authenticated") { if (opts.deploymentMode === "authenticated") {

View file

@ -4,6 +4,7 @@ import { api } from "./client";
type InviteSummary = { type InviteSummary = {
id: string; id: string;
companyId: string | null; companyId: string | null;
companyName?: string | null;
inviteType: "company_join" | "bootstrap_ceo"; inviteType: "company_join" | "bootstrap_ceo";
allowedJoinTypes: "human" | "agent" | "both"; allowedJoinTypes: "human" | "agent" | "both";
expiresAt: string; expiresAt: string;
@ -87,6 +88,7 @@ type CompanyInviteCreated = {
inviteUrl: string; inviteUrl: string;
expiresAt: string; expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both"; allowedJoinTypes: "human" | "agent" | "both";
companyName?: string | null;
onboardingTextPath?: string; onboardingTextPath?: string;
onboardingTextUrl?: string; onboardingTextUrl?: string;
inviteMessage?: string | null; inviteMessage?: string | null;

View file

@ -10,6 +10,7 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils"; import { formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots"; import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment { interface CommentWithRunMeta extends IssueComment {
@ -420,17 +421,24 @@ export function CommentThread({
if (!trimmed) return; if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const submittedBody = trimmed;
setSubmitting(true); setSubmitting(true);
setBody("");
try { try {
// TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI. // TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI.
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
setBody("");
if (draftKey) clearDraft(draftKey); if (draftKey) clearDraft(draftKey);
setReopen(true); setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
} catch { } catch {
// Parent mutation handlers surface the failure and keep the draft intact. setBody((current) =>
restoreSubmittedCommentDraft({
currentBody: current,
submittedBody,
}),
);
// Parent mutation handlers surface the failure and the draft is restored for retry.
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View file

@ -136,7 +136,10 @@ describe("SwipeToArchive", () => {
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null; const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
expect(surface).not.toBeNull(); expect(surface).not.toBeNull();
expect(surface?.style.backgroundColor).toBe("hsl(var(--muted))"); expect(surface?.className).toContain("bg-zinc-100");
expect(surface?.className).toContain("dark:bg-zinc-800");
expect(surface?.className).not.toContain("bg-card");
expect(surface?.style.backgroundColor).toBe("");
expect(surface?.style.boxShadow).toBe(""); expect(surface?.style.boxShadow).toBe("");
act(() => { act(() => {

View file

@ -13,7 +13,6 @@ interface SwipeToArchiveProps {
const COMMIT_THRESHOLD = 0.32; const COMMIT_THRESHOLD = 0.32;
const MAX_SWIPE = 0.88; const MAX_SWIPE = 0.88;
const COMMIT_DELAY_MS = 140; const COMMIT_DELAY_MS = 140;
const SELECTED_ROW_BACKGROUND = "hsl(var(--muted))";
export function SwipeToArchive({ export function SwipeToArchive({
children, children,
@ -152,11 +151,13 @@ export function SwipeToArchive({
</div> </div>
<div <div
data-inbox-row-surface data-inbox-row-surface
className="relative bg-card will-change-transform" className={cn(
"relative will-change-transform",
selected ? "bg-zinc-100 dark:bg-zinc-800" : "bg-card",
)}
style={{ style={{
transform: `translate3d(${offsetX}px, 0, 0)`, transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out", transition: isDragging ? "none" : "transform 180ms ease-out",
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
}} }}
> >
{children} {children}

View file

@ -105,11 +105,13 @@ export function ToggleField({
hint, hint,
checked, checked,
onChange, onChange,
toggleTestId,
}: { }: {
label: string; label: string;
hint?: string; hint?: string;
checked: boolean; checked: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
toggleTestId?: string;
}) { }) {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -119,6 +121,8 @@ export function ToggleField({
</div> </div>
<button <button
data-slot="toggle" data-slot="toggle"
data-testid={toggleTestId}
type="button"
className={cn( className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors", "relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
checked ? "bg-green-600" : "bg-muted" checked ? "bg-green-600" : "bg-muted"

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { restoreSubmittedCommentDraft } from "./comment-submit-draft";
describe("restoreSubmittedCommentDraft", () => {
it("restores the submitted body when the editor is still empty after a failed request", () => {
expect(
restoreSubmittedCommentDraft({
currentBody: "",
submittedBody: "Retry me",
}),
).toBe("Retry me");
});
it("treats whitespace-only input as empty when restoring a failed draft", () => {
expect(
restoreSubmittedCommentDraft({
currentBody: " ",
submittedBody: "Retry me",
}),
).toBe("Retry me");
});
it("preserves newer input when the user has already typed again", () => {
expect(
restoreSubmittedCommentDraft({
currentBody: "new draft",
submittedBody: "Retry me",
}),
).toBe("new draft");
});
});

View file

@ -0,0 +1,6 @@
export function restoreSubmittedCommentDraft(params: {
currentBody: string;
submittedBody: string;
}) {
return params.currentBody.trim() ? params.currentBody : params.submittedBody;
}

View file

@ -377,7 +377,7 @@ export function CompanySettings() {
)} )}
{/* Hiring */} {/* Hiring */}
<div className="space-y-4"> <div className="space-y-4" data-testid="company-settings-team-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Hiring Hiring
</div> </div>
@ -387,12 +387,13 @@ export function CompanySettings() {
hint="New agent hires stay pending until approved by board." hint="New agent hires stay pending until approved by board."
checked={!!selectedCompany.requireBoardApprovalForNewAgents} checked={!!selectedCompany.requireBoardApprovalForNewAgents}
onChange={(v) => settingsMutation.mutate(v)} onChange={(v) => settingsMutation.mutate(v)}
toggleTestId="company-settings-team-approval-toggle"
/> />
</div> </div>
</div> </div>
{/* Invites */} {/* Invites */}
<div className="space-y-4"> <div className="space-y-4" data-testid="company-settings-invites-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Invites Invites
</div> </div>
@ -405,6 +406,7 @@ export function CompanySettings() {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
data-testid="company-settings-invites-generate-button"
size="sm" size="sm"
onClick={() => inviteMutation.mutate()} onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending} disabled={inviteMutation.isPending}
@ -418,7 +420,10 @@ export function CompanySettings() {
<p className="text-sm text-destructive">{inviteError}</p> <p className="text-sm text-destructive">{inviteError}</p>
)} )}
{inviteSnippet && ( {inviteSnippet && (
<div className="rounded-md border border-border bg-muted/30 p-2"> <div
className="rounded-md border border-border bg-muted/30 p-2"
data-testid="company-settings-invites-snippet"
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
OpenClaw Invite Prompt OpenClaw Invite Prompt
@ -435,12 +440,14 @@ export function CompanySettings() {
</div> </div>
<div className="mt-1 space-y-1.5"> <div className="mt-1 space-y-1.5">
<textarea <textarea
data-testid="company-settings-invites-snippet-textarea"
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none" className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={inviteSnippet} value={inviteSnippet}
readOnly readOnly
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
data-testid="company-settings-invites-copy-button"
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={async () => { onClick={async () => {

View file

@ -69,6 +69,7 @@ export function InviteLandingPage() {
}); });
const invite = inviteQuery.data; const invite = inviteQuery.data;
const companyName = invite?.companyName?.trim() || null;
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both"; const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
const availableJoinTypes = useMemo(() => { const availableJoinTypes = useMemo(() => {
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[]; if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
@ -227,9 +228,18 @@ export function InviteLandingPage() {
<div className="mx-auto max-w-xl py-10"> <div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6"> <div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Bootstrap your Paperclip instance" : "Join this Paperclip company"} {invite.inviteType === "bootstrap_ceo"
? "Bootstrap your Paperclip instance"
: companyName
? `Join ${companyName}`
: "Join this Paperclip company"}
</h1> </h1>
<p className="mt-2 text-sm text-muted-foreground">Invite expires {dateTime(invite.expiresAt)}.</p> <p className="mt-2 text-sm text-muted-foreground">
{invite.inviteType !== "bootstrap_ceo" && companyName
? `You were invited to join ${companyName}. `
: null}
Invite expires {dateTime(invite.expiresAt)}.
</p>
{invite.inviteType !== "bootstrap_ceo" && ( {invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-5 flex gap-2"> <div className="mt-5 flex gap-2">

View file

@ -251,8 +251,11 @@ export function Routines() {
} }
}} }}
> >
<DialogContent showCloseButton={false} className="max-w-3xl gap-0 overflow-hidden p-0"> <DialogContent
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3"> showCloseButton={false}
className="flex max-h-[calc(100dvh-2rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0"
>
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<div> <div>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p> <p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@ -272,197 +275,199 @@ export function Routines() {
</Button> </Button>
</div> </div>
<div className="px-5 pt-5 pb-3"> <div className="min-h-0 flex-1 overflow-y-auto">
<textarea <div className="px-5 pt-5 pb-3">
ref={titleInputRef} <textarea
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50" ref={titleInputRef}
placeholder="Routine title" className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
rows={1} placeholder="Routine title"
value={draft.title} rows={1}
onChange={(event) => { value={draft.title}
setDraft((current) => ({ ...current, title: event.target.value })); onChange={(event) => {
autoResizeTextarea(event.target); setDraft((current) => ({ ...current, title: event.target.value }));
}} autoResizeTextarea(event.target);
onKeyDown={(event) => { }}
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) { onKeyDown={(event) => {
event.preventDefault(); if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
descriptionEditorRef.current?.focus(); event.preventDefault();
return; descriptionEditorRef.current?.focus();
} return;
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (draft.assigneeAgentId) {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
} }
} if (event.key === "Tab" && !event.shiftKey) {
}} event.preventDefault();
autoFocus if (draft.assigneeAgentId) {
/> if (draft.projectId) {
</div> descriptionEditorRef.current?.focus();
} else {
<div className="px-5 pb-3"> projectSelectorRef.current?.focus();
<div className="overflow-x-auto overscroll-x-contain"> }
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={draft.assigneeAgentId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else { } else {
projectSelectorRef.current?.focus(); assigneeSelectorRef.current?.focus();
} }
}} }
renderTriggerValue={(option) => }}
option ? ( autoFocus
currentAssignee ? ( />
</div>
<div className="px-5 pb-3">
<div className="overflow-x-auto overscroll-x-contain">
<div className="inline-flex min-w-full flex-wrap items-center gap-2 text-sm text-muted-foreground sm:min-w-max sm:flex-nowrap">
<span>For</span>
<InlineEntitySelector
ref={assigneeSelectorRef}
value={draft.assigneeAgentId}
options={assigneeOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={(assigneeAgentId) => {
if (assigneeAgentId) trackRecentAssignee(assigneeAgentId);
setDraft((current) => ({ ...current, assigneeAgentId }));
}}
onConfirm={() => {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
<>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{option.label}</span>
</>
) : (
<span className="truncate">{option.label}</span>
)
) : (
<span className="text-muted-foreground">Assignee</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const assignee = agentById.get(option.id);
return (
<> <>
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> {assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
<span>in</span>
<InlineEntitySelector
ref={projectSelectorRef}
value={draft.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span> <span className="truncate">{option.label}</span>
</> </>
) : ( ) : (
<span className="truncate">{option.label}</span> <span className="text-muted-foreground">Project</span>
) )
) : ( }
<span className="text-muted-foreground">Assignee</span> renderOption={(option) => {
) if (!option.id) return <span className="truncate">{option.label}</span>;
} const project = projectById.get(option.id);
renderOption={(option) => { return (
if (!option.id) return <span className="truncate">{option.label}</span>; <>
const assignee = agentById.get(option.id); <span
return ( className="h-3.5 w-3.5 shrink-0 rounded-sm"
<> style={{ backgroundColor: project?.color ?? "#64748b" }}
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null} />
<span className="truncate">{option.label}</span> <span className="truncate">{option.label}</span>
</> </>
); );
}} }}
/> />
<span>in</span> </div>
<InlineEntitySelector
ref={projectSelectorRef}
value={draft.projectId}
options={projectOptions}
placeholder="Project"
noneLabel="No project"
searchPlaceholder="Search projects..."
emptyMessage="No projects found."
onChange={(projectId) => setDraft((current) => ({ ...current, projectId }))}
onConfirm={() => descriptionEditorRef.current?.focus()}
renderTriggerValue={(option) =>
option && currentProject ? (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: currentProject.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
) : (
<span className="text-muted-foreground">Project</span>
)
}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const project = projectById.get(option.id);
return (
<>
<span
className="h-3.5 w-3.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span className="truncate">{option.label}</span>
</>
);
}}
/>
</div> </div>
</div> </div>
<div className="border-t border-border/60 px-5 py-4">
<MarkdownEditor
ref={descriptionEditorRef}
value={draft.description}
onChange={(description) => setDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div> </div>
<div className="border-t border-border/60 px-5 py-4"> <div className="shrink-0 flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<MarkdownEditor
ref={descriptionEditorRef}
value={draft.description}
onChange={(description) => setDraft((current) => ({ ...current, description }))}
placeholder="Add instructions..."
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
</div>
<div className="border-t border-border/60 px-5 py-3">
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger className="flex w-full items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Advanced delivery settings</p>
<p className="text-sm text-muted-foreground">Keep policy controls secondary to the work definition.</p>
</div>
{advancedOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Concurrency</p>
<Select
value={draft.concurrencyPolicy}
onValueChange={(concurrencyPolicy) => setDraft((current) => ({ ...current, concurrencyPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{concurrencyPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{concurrencyPolicyDescriptions[draft.concurrencyPolicy]}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Catch-up</p>
<Select
value={draft.catchUpPolicy}
onValueChange={(catchUpPolicy) => setDraft((current) => ({ ...current, catchUpPolicy }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{catchUpPolicies.map((value) => (
<SelectItem key={value} value={value}>{value.replaceAll("_", " ")}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{catchUpPolicyDescriptions[draft.catchUpPolicy]}</p>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs. After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
</div> </div>