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:
commit
ccb5cce4ac
16 changed files with 624 additions and 208 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
31
ui/src/lib/comment-submit-draft.test.ts
Normal file
31
ui/src/lib/comment-submit-draft.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
6
ui/src/lib/comment-submit-draft.ts
Normal file
6
ui/src/lib/comment-submit-draft.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function restoreSubmittedCommentDraft(params: {
|
||||
currentBody: string;
|
||||
submittedBody: string;
|
||||
}) {
|
||||
return params.currentBody.trim() ? params.currentBody : params.submittedBody;
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue