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 {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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 */}
|
{/* 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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue