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 {
const line = raw.trim();
if (!line) return;
@ -51,6 +72,9 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
if (blockType === "text") {
const text = typeof block.text === "string" ? block.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") {
const name = typeof block.name === "string" ? block.name : "unknown";
console.log(pc.yellow(`tool_call: ${name}`));
@ -62,6 +86,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void {
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") {
const 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 { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
describe("claude_local max-turn detection", () => {
it("detects max-turn exhaustion by subtype", () => {
@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => {
).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 request from "supertest";
import type { Db } from "@paperclipai/db";
import { healthRoutes } from "../routes/health.js";
import * as devServerStatus from "../dev-server-status.js";
import { serverVersion } from "../version.js";
describe("GET /health", () => {
const app = express();
app.use("/health", healthRoutes());
beforeEach(() => {
vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns 200 with status ok", async () => {
const app = express();
app.use("/health", healthRoutes());
const res = await request(app).get("/health");
expect(res.status).toBe(200);
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 request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companies, invites } from "@paperclipai/db";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
@ -51,19 +52,35 @@ function createDbStub() {
inviteType: "company_join",
allowedJoinTypes: "agent",
defaultsPayload: null,
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
expiresAt: new Date("2099-03-07T00:10:00.000Z"),
invitedByUserId: null,
tokenHash: "hash",
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
createdAt: new Date("2099-03-07T00:00:00.000Z"),
updatedAt: new Date("2099-03-07T00:00:00.000Z"),
};
const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning });
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 {
insert,
select,
};
}
@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent");
expect(typeof res.body.token).toBe("string");
expect(res.body.companyName).toBe("Acme AI");
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 () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);

View file

@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db";
import {
agentApiKeys,
authUsers,
companies,
invites,
joinRequests
} from "@paperclipai/db";
@ -856,7 +857,8 @@ export function normalizeAgentDefaultsForJoin(input: {
function toInviteSummaryResponse(
req: Request,
token: string,
invite: typeof invites.$inferSelect
invite: typeof invites.$inferSelect,
companyName: string | null = null
) {
const baseUrl = requestBaseUrl(req);
const onboardingPath = `/api/invites/${token}/onboarding`;
@ -865,6 +867,7 @@ function toInviteSummaryResponse(
return {
id: invite.id,
companyId: invite.companyId,
companyName,
inviteType: invite.inviteType,
allowedJoinTypes: invite.allowedJoinTypes,
expiresAt: invite.expiresAt,
@ -993,6 +996,7 @@ function buildInviteOnboardingManifest(
token: string,
invite: typeof invites.$inferSelect,
opts: {
companyName?: string | null;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bindHost: string;
@ -1024,7 +1028,12 @@ function buildInviteOnboardingManifest(
});
return {
invite: toInviteSummaryResponse(req, token, invite),
invite: toInviteSummaryResponse(
req,
token,
invite,
opts.companyName ?? null
),
onboarding: {
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).",
@ -1084,6 +1093,7 @@ export function buildInviteOnboardingTextDocument(
token: string,
invite: typeof invites.$inferSelect,
opts: {
companyName?: string | null;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
bindHost: string;
@ -1133,6 +1143,10 @@ export function buildInviteOnboardingTextDocument(
- expiresAt: ${invite.expiresAt.toISOString()}
`);
if (manifest.invite.companyName) {
lines.push(`- companyName: ${manifest.invite.companyName}`);
}
if (onboarding.inviteMessage) {
appendBlock(`
## Message from inviter
@ -1882,6 +1896,16 @@ export function accessRoutes(
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) => {
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({
...created,
token,
inviteUrl: `/invite/${token}`,
companyName,
onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl,
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({
...created,
token,
inviteUrl: `/invite/${token}`,
companyName,
onboardingTextPath: inviteSummary.onboardingTextPath,
onboardingTextUrl: inviteSummary.onboardingTextUrl,
inviteMessage: inviteSummary.inviteMessage
@ -2016,7 +2054,8 @@ export function accessRoutes(
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) => {
@ -2031,7 +2070,11 @@ export function accessRoutes(
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) => {
@ -2046,9 +2089,15 @@ export function accessRoutes(
throw notFound("Invite not found");
}
const companyName = await getInviteCompanyName(invite.companyId);
res
.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) => {
@ -2458,11 +2507,15 @@ export function accessRoutes(
const response = toJoinRequestResponse(created);
if (claimSecret) {
const companyName = await getInviteCompanyName(invite.companyId);
const onboardingManifest = buildInviteOnboardingManifest(
req,
token,
invite,
opts
{
...opts,
companyName
}
);
res.status(202).json({
...response,

View file

@ -29,6 +29,17 @@ export function healthRoutes(
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 bootstrapInviteActive = false;
if (opts.deploymentMode === "authenticated") {

View file

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

View file

@ -10,6 +10,7 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment {
@ -420,17 +421,24 @@ export function CommentThread({
if (!trimmed) return;
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
const submittedBody = trimmed;
setSubmitting(true);
setBody("");
try {
// 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);
setBody("");
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue);
} 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 {
setSubmitting(false);
}

View file

@ -136,7 +136,10 @@ describe("SwipeToArchive", () => {
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
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("");
act(() => {

View file

@ -13,7 +13,6 @@ interface SwipeToArchiveProps {
const COMMIT_THRESHOLD = 0.32;
const MAX_SWIPE = 0.88;
const COMMIT_DELAY_MS = 140;
const SELECTED_ROW_BACKGROUND = "hsl(var(--muted))";
export function SwipeToArchive({
children,
@ -152,11 +151,13 @@ export function SwipeToArchive({
</div>
<div
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={{
transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out",
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
}}
>
{children}

View file

@ -105,11 +105,13 @@ export function ToggleField({
hint,
checked,
onChange,
toggleTestId,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
toggleTestId?: string;
}) {
return (
<div className="flex items-center justify-between">
@ -119,6 +121,8 @@ export function ToggleField({
</div>
<button
data-slot="toggle"
data-testid={toggleTestId}
type="button"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
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 */}
<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">
Hiring
</div>
@ -387,12 +387,13 @@ export function CompanySettings() {
hint="New agent hires stay pending until approved by board."
checked={!!selectedCompany.requireBoardApprovalForNewAgents}
onChange={(v) => settingsMutation.mutate(v)}
toggleTestId="company-settings-team-approval-toggle"
/>
</div>
</div>
{/* 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">
Invites
</div>
@ -405,6 +406,7 @@ export function CompanySettings() {
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="company-settings-invites-generate-button"
size="sm"
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
@ -418,7 +420,10 @@ export function CompanySettings() {
<p className="text-sm text-destructive">{inviteError}</p>
)}
{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="text-xs text-muted-foreground">
OpenClaw Invite Prompt
@ -435,12 +440,14 @@ export function CompanySettings() {
</div>
<div className="mt-1 space-y-1.5">
<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"
value={inviteSnippet}
readOnly
/>
<div className="flex justify-end">
<Button
data-testid="company-settings-invites-copy-button"
size="sm"
variant="ghost"
onClick={async () => {

View file

@ -69,6 +69,7 @@ export function InviteLandingPage() {
});
const invite = inviteQuery.data;
const companyName = invite?.companyName?.trim() || null;
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
const availableJoinTypes = useMemo(() => {
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="rounded-lg border border-border bg-card p-6">
<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>
<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" && (
<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">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-5 py-3">
<DialogContent
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>
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">New routine</p>
<p className="text-sm text-muted-foreground">
@ -272,197 +275,199 @@ export function Routines() {
</Button>
</div>
<div className="px-5 pt-5 pb-3">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={draft.title}
onChange={(event) => {
setDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
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();
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-5 pt-5 pb-3">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-semibold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={draft.title}
onChange={(event) => {
setDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
}
}}
autoFocus
/>
</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();
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (draft.assigneeAgentId) {
if (draft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
projectSelectorRef.current?.focus();
assigneeSelectorRef.current?.focus();
}
}}
renderTriggerValue={(option) =>
option ? (
currentAssignee ? (
}
}}
autoFocus
/>
</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="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 assignee = agentById.get(option.id);
return (
<>
{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="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>
</>
);
}}
/>
}
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 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 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 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="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">
<div className="text-sm text-muted-foreground">
After creation, Paperclip takes you straight to trigger setup for schedules, webhooks, or internal runs.
</div>