Merge pull request #2749 from paperclipai/fix/unified-toggle-mobile

Improve operator editing flows, mobile UI, and workspace runtime handling
This commit is contained in:
Dotta 2026-04-04 17:53:03 -05:00 committed by GitHub
commit e75960f284
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2306 additions and 621 deletions

View file

@ -0,0 +1,19 @@
import type { ExecutionWorkspace } from "./types/workspace-runtime.js";
type ExecutionWorkspaceGuardTarget = Pick<ExecutionWorkspace, "closedAt" | "mode" | "name" | "status">;
const CLOSED_EXECUTION_WORKSPACE_STATUSES = new Set<ExecutionWorkspace["status"]>(["archived", "cleanup_failed"]);
export function isClosedIsolatedExecutionWorkspace(
workspace: Pick<ExecutionWorkspaceGuardTarget, "closedAt" | "mode" | "status"> | null | undefined,
): boolean {
if (!workspace) return false;
if (workspace.mode !== "isolated_workspace") return false;
return workspace.closedAt != null || CLOSED_EXECUTION_WORKSPACE_STATUSES.has(workspace.status);
}
export function getClosedIsolatedExecutionWorkspaceMessage(
workspace: Pick<ExecutionWorkspaceGuardTarget, "name">,
): string {
return `This issue is linked to the closed workspace "${workspace.name}". Move it to an open workspace before adding comments or resuming work.`;
}

View file

@ -351,6 +351,11 @@ export {
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
} from "./types/feedback.js"; } from "./types/feedback.js";
export {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
} from "./execution-workspace-guards.js";
export { export {
instanceGeneralSettingsSchema, instanceGeneralSettingsSchema,
patchInstanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema,
@ -595,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
export { export {
AGENT_MENTION_SCHEME, AGENT_MENTION_SCHEME,
PROJECT_MENTION_SCHEME, PROJECT_MENTION_SCHEME,
SKILL_MENTION_SCHEME,
buildAgentMentionHref, buildAgentMentionHref,
buildProjectMentionHref, buildProjectMentionHref,
buildSkillMentionHref,
extractAgentMentionIds, extractAgentMentionIds,
extractSkillMentionIds,
parseAgentMentionHref, parseAgentMentionHref,
parseProjectMentionHref, parseProjectMentionHref,
parseSkillMentionHref,
extractProjectMentionIds, extractProjectMentionIds,
type ParsedAgentMention, type ParsedAgentMention,
type ParsedProjectMention, type ParsedProjectMention,
type ParsedSkillMention,
} from "./project-mentions.js"; } from "./project-mentions.js";
export { export {

View file

@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import { import {
buildAgentMentionHref, buildAgentMentionHref,
buildProjectMentionHref, buildProjectMentionHref,
buildSkillMentionHref,
extractAgentMentionIds, extractAgentMentionIds,
extractProjectMentionIds, extractProjectMentionIds,
extractSkillMentionIds,
parseAgentMentionHref, parseAgentMentionHref,
parseProjectMentionHref, parseProjectMentionHref,
parseSkillMentionHref,
} from "./project-mentions.js"; } from "./project-mentions.js";
describe("project-mentions", () => { describe("project-mentions", () => {
@ -26,4 +29,13 @@ describe("project-mentions", () => {
}); });
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
}); });
it("round-trips skill mentions with slug metadata", () => {
const href = buildSkillMentionHref("skill-123", "release-changelog");
expect(parseSkillMentionHref(href)).toEqual({
skillId: "skill-123",
slug: "release-changelog",
});
expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]);
});
}); });

View file

@ -1,5 +1,6 @@
export const PROJECT_MENTION_SCHEME = "project://"; export const PROJECT_MENTION_SCHEME = "project://";
export const AGENT_MENTION_SCHEME = "agent://"; export const AGENT_MENTION_SCHEME = "agent://";
export const SKILL_MENTION_SCHEME = "skill://";
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
@ -7,7 +8,9 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
export interface ParsedProjectMention { export interface ParsedProjectMention {
projectId: string; projectId: string;
@ -19,6 +22,11 @@ export interface ParsedAgentMention {
icon: string | null; icon: string | null;
} }
export interface ParsedSkillMention {
skillId: string;
slug: string | null;
}
function normalizeHexColor(input: string | null | undefined): string | null { function normalizeHexColor(input: string | null | undefined): string | null {
if (!input) return null; if (!input) return null;
const trimmed = input.trim(); const trimmed = input.trim();
@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
}; };
} }
export function buildSkillMentionHref(skillId: string, slug?: string | null): string {
const trimmedSkillId = skillId.trim();
const normalizedSlug = normalizeSkillSlug(slug ?? null);
if (!normalizedSlug) {
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`;
}
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`;
}
export function parseSkillMentionHref(href: string): ParsedSkillMention | null {
if (!href.startsWith(SKILL_MENTION_SCHEME)) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "skill:") return null;
const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
if (!skillId) return null;
return {
skillId,
slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")),
};
}
export function extractProjectMentionIds(markdown: string): string[] { export function extractProjectMentionIds(markdown: string): string[] {
if (!markdown) return []; if (!markdown) return [];
const ids = new Set<string>(); const ids = new Set<string>();
@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] {
return [...ids]; return [...ids];
} }
export function extractSkillMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
const re = new RegExp(SKILL_MENTION_LINK_RE);
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
const parsed = parseSkillMentionHref(match[1]);
if (parsed) ids.add(parsed.skillId);
}
return [...ids];
}
function normalizeAgentIcon(input: string | null | undefined): string | null { function normalizeAgentIcon(input: string | null | undefined): string | null {
if (!input) return null; if (!input) return null;
const trimmed = input.trim().toLowerCase(); const trimmed = input.trim().toLowerCase();
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null; if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
return trimmed; return trimmed;
} }
function normalizeSkillSlug(input: string | null | undefined): string | null {
if (!input) return null;
const trimmed = input.trim().toLowerCase();
if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null;
return trimmed;
}

View file

@ -12,6 +12,7 @@ import {
issues, issues,
projectWorkspaces, projectWorkspaces,
projects, projects,
workspaceRuntimeServices,
} from "@paperclipai/db"; } from "@paperclipai/db";
import { import {
getEmbeddedPostgresTestSupport, getEmbeddedPostgresTestSupport,
@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
afterEach(async () => { afterEach(async () => {
await db.delete(issues); await db.delete(issues);
await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces); await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces); await db.delete(projectWorkspaces);
await db.delete(projects); await db.delete(projects);
@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
"git_branch_delete", "git_branch_delete",
])); ]));
}, 20_000); }, 20_000);
it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const olderServiceId = randomUUID();
const currentServiceId = randomUUID();
const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`;
const startedAt = new Date("2026-04-04T17:00:00.000Z");
const stoppedAt = new Date("2026-04-04T17:05:00.000Z");
const runningAt = new Date("2026-04-04T17:10:00.000Z");
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
executionWorkspacePolicy: {
enabled: true,
},
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
isPrimary: true,
cwd: "/tmp/paperclip-primary",
metadata: {
runtimeConfig: {
desiredState: "running",
workspaceRuntime: {
services: [{ name: "paperclip-dev", command: "pnpm dev" }],
},
},
},
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared workspace",
status: "active",
providerType: "local_fs",
cwd: "/tmp/paperclip-primary",
});
await db.insert(workspaceRuntimeServices).values([
{
id: olderServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId: null,
issueId: null,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "paperclip-dev",
status: "stopped",
lifecycle: "shared",
reuseKey,
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
port: 49195,
url: "http://127.0.0.1:49195",
provider: "local_process",
providerRef: "11111",
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: stoppedAt,
startedAt,
stoppedAt,
stopPolicy: { type: "manual" },
healthStatus: "unknown",
createdAt: startedAt,
updatedAt: stoppedAt,
},
{
id: currentServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId: null,
issueId: null,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "paperclip-dev",
status: "running",
lifecycle: "shared",
reuseKey,
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
port: 49222,
url: "http://127.0.0.1:49222",
provider: "local_process",
providerRef: "22222",
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: runningAt,
startedAt: runningAt,
stoppedAt: null,
stopPolicy: { type: "manual" },
healthStatus: "healthy",
createdAt: runningAt,
updatedAt: runningAt,
},
]);
const workspace = await svc.getById(executionWorkspaceId);
const listed = await svc.list(companyId, { projectId });
expect(workspace?.runtimeServices).toHaveLength(1);
expect(workspace?.runtimeServices?.[0]).toMatchObject({
id: currentServiceId,
status: "running",
projectWorkspaceId,
executionWorkspaceId: null,
url: "http://127.0.0.1:49222",
});
expect(listed[0]?.runtimeServices).toHaveLength(1);
expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId);
});
}); });

View file

@ -0,0 +1,178 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const issueId = "11111111-1111-4111-8111-111111111111";
const closedWorkspaceId = "33333333-3333-4333-8333-333333333333";
const nextWorkspaceId = "44444444-4444-4444-8444-444444444444";
const agentId = "22222222-2222-4222-8222-222222222222";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
checkout: vi.fn(),
addComment: vi.fn(),
}));
const mockExecutionWorkspaceService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}));
const mockProjectService = vi.hoisted(() => ({
getById: vi.fn(async () => null),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(async () => null),
}),
documentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,
feedbackService: () => ({
listIssueVotesForUser: vi.fn(async () => []),
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}),
goalService: () => ({
getDefaultCompanyGoal: vi.fn(async () => null),
getById: vi.fn(async () => null),
}),
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => ({
get: vi.fn(async () => ({
id: "instance-settings-1",
general: {
censorUsernameInLogs: false,
feedbackDataSharingPreference: "prompt",
},
})),
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
function makeIssue() {
return {
id: issueId,
companyId: "company-1",
status: "todo",
priority: "medium",
assigneeAgentId: agentId,
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1085",
title: "Closed worktree issue",
projectId: null,
executionRunId: null,
checkoutRunId: null,
executionWorkspaceId: closedWorkspaceId,
};
}
function makeClosedWorkspace() {
return {
id: closedWorkspaceId,
name: "PAP-1085-fix-worktree-guard",
mode: "isolated_workspace",
status: "archived",
closedAt: new Date("2026-04-04T17:00:00.000Z"),
};
}
describe("closed isolated workspace issue routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIssueService.getById.mockResolvedValue(makeIssue());
mockExecutionWorkspaceService.getById.mockResolvedValue(makeClosedWorkspace());
});
it("rejects new issue comments when the linked isolated workspace is closed", async () => {
const res = await request(createApp())
.post(`/api/issues/${issueId}/comments`)
.send({ body: "hello" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("closed workspace");
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects comment updates when the linked isolated workspace is closed", async () => {
const res = await request(createApp())
.patch(`/api/issues/${issueId}`)
.send({ comment: "hello" });
expect(res.status).toBe(409);
expect(res.body.error).toContain("closed workspace");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("rejects checkout when the linked isolated workspace is closed", async () => {
const res = await request(createApp())
.post(`/api/issues/${issueId}/checkout`)
.send({
agentId,
expectedStatuses: ["todo", "backlog", "blocked"],
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("closed workspace");
expect(mockIssueService.checkout).not.toHaveBeenCalled();
});
it("still allows non-comment board updates so the issue can be moved to a new workspace", async () => {
mockIssueService.update.mockResolvedValue({
...makeIssue(),
executionWorkspaceId: nextWorkspaceId,
});
const res = await request(createApp())
.patch(`/api/issues/${issueId}`)
.send({ executionWorkspaceId: nextWorkspaceId });
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(issueId, { executionWorkspaceId: nextWorkspaceId });
});
});

View file

@ -13,6 +13,7 @@ import {
createDb, createDb,
executionWorkspaces, executionWorkspaces,
heartbeatRuns, heartbeatRuns,
projectWorkspaces,
projects, projects,
workspaceRuntimeServices, workspaceRuntimeServices,
} from "@paperclipai/db"; } from "@paperclipai/db";
@ -30,6 +31,7 @@ import {
stopRuntimeServicesForExecutionWorkspace, stopRuntimeServicesForExecutionWorkspace,
type RealizedExecutionWorkspace, type RealizedExecutionWorkspace,
} from "../services/workspace-runtime.ts"; } from "../services/workspace-runtime.ts";
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
import { resolvePaperclipConfigPath } from "../paths.ts"; import { resolvePaperclipConfigPath } from "../paths.ts";
import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperation } from "@paperclipai/shared";
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
afterEach(async () => { afterEach(async () => {
await db.delete(workspaceRuntimeServices); await db.delete(workspaceRuntimeServices);
await db.delete(executionWorkspaces); await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects); await db.delete(projects);
await db.delete(heartbeatRuns); await db.delete(heartbeatRuns);
await db.delete(agents); await db.delete(agents);
@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
await expect(fetch(service!.url!)).rejects.toThrow(); await expect(fetch(service!.url!)).rejects.toThrow();
}); });
it("marks persisted local services stopped when the registry pid is stale", async () => {
const companyId = randomUUID();
const runtimeServiceId = randomUUID();
const startedAt = new Date("2026-04-04T17:00:00.000Z");
const updatedAt = new Date("2026-04-04T17:10:00.000Z");
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Runtime reconcile test",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
sourceType: "local_path",
cwd: "/tmp/paperclip-primary",
isPrimary: true,
});
await db.insert(workspaceRuntimeServices).values({
id: runtimeServiceId,
companyId,
projectId,
projectWorkspaceId,
executionWorkspaceId: null,
issueId: null,
scopeType: "project_workspace",
scopeId: projectWorkspaceId,
serviceName: "paperclip-dev",
status: "running",
lifecycle: "shared",
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
port: 49195,
url: "http://127.0.0.1:49195",
provider: "local_process",
providerRef: "999999",
ownerAgentId: null,
startedByRunId: null,
lastUsedAt: updatedAt,
startedAt,
stoppedAt: null,
stopPolicy: { type: "manual" },
healthStatus: "healthy",
createdAt: startedAt,
updatedAt,
});
await writeLocalServiceRegistryRecord({
version: 1,
serviceKey: "workspace-runtime-paperclip-dev-stale",
profileKind: "workspace-runtime",
serviceName: "paperclip-dev",
command: "pnpm dev",
cwd: "/tmp/paperclip-primary",
envFingerprint: "fingerprint",
port: 49195,
url: "http://127.0.0.1:49195",
pid: 999999,
processGroupId: 999999,
provider: "local_process",
runtimeServiceId,
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
startedAt: startedAt.toISOString(),
lastSeenAt: updatedAt.toISOString(),
metadata: null,
});
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 });
const persisted = await db
.select()
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.id, runtimeServiceId))
.then((rows) => rows[0] ?? null);
expect(persisted?.status).toBe("stopped");
expect(persisted?.stoppedAt).not.toBeNull();
});
it("persists controlled execution workspace stops as stopped", async () => { it("persists controlled execution workspace stops as stopped", async () => {
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
const companyId = randomUUID(); const companyId = randomUUID();

View file

@ -19,6 +19,9 @@ import {
updateIssueWorkProductSchema, updateIssueWorkProductSchema,
upsertIssueDocumentSchema, upsertIssueDocumentSchema,
updateIssueSchema, updateIssueSchema,
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
type ExecutionWorkspace,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js"; import { getTelemetryClient } from "../telemetry.js";
@ -234,6 +237,23 @@ export function issueRoutes(
return runToInterrupt?.status === "running" ? runToInterrupt : null; return runToInterrupt?.status === "running" ? runToInterrupt : null;
} }
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
if (!issue.executionWorkspaceId) return null;
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null;
return workspace;
}
function respondClosedIssueExecutionWorkspace(
res: Response,
workspace: Pick<ExecutionWorkspace, "closedAt" | "id" | "mode" | "name" | "status">,
) {
res.status(409).json({
error: getClosedIsolatedExecutionWorkspaceMessage(workspace),
executionWorkspace: workspace,
});
}
async function normalizeIssueIdentifier(rawId: string): Promise<string> { async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) { if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId); const issue = await svc.getByIdentifier(rawId);
@ -1083,6 +1103,13 @@ export function issueRoutes(
...updateFields ...updateFields
} = req.body; } = req.body;
let interruptedRunId: string | null = null; let interruptedRunId: string | null = null;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
return;
}
if (interruptRequested) { if (interruptRequested) {
if (!commentBody) { if (!commentBody) {
@ -1389,6 +1416,12 @@ export function issueRoutes(
return; return;
} }
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
if (closedExecutionWorkspace) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
return;
}
const checkoutRunId = requireAgentRunId(req, res); const checkoutRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !checkoutRunId) return; if (req.actor.type === "agent" && !checkoutRunId) return;
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
@ -1607,6 +1640,11 @@ export function issueRoutes(
} }
assertCompanyAccess(req, issue.companyId); assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return; if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
if (closedExecutionWorkspace) {
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
return;
}
const actor = getActorInfo(req); const actor = getActorInfo(req);
const reopenRequested = req.body.reopen === true; const reopenRequested = req.body.reopen === true;

View file

@ -14,6 +14,10 @@ import type {
WorkspaceRuntimeService, WorkspaceRuntimeService,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import {
listCurrentRuntimeServicesForExecutionWorkspaces,
listCurrentRuntimeServicesForProjectWorkspaces,
} from "./workspace-runtime-read-model.js";
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
@ -317,6 +321,41 @@ function toExecutionWorkspace(
}; };
} }
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
}
async function loadEffectiveRuntimeServicesByExecutionWorkspace(
db: Db,
companyId: string,
rows: ExecutionWorkspaceRow[],
) {
const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces(
db,
companyId,
rows.map((row) => row.id),
);
const projectWorkspaceIds = rows
.filter((row) => usesInheritedProjectRuntimeServices(row))
.map((row) => row.projectWorkspaceId)
.filter((value): value is string => Boolean(value));
const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces(
db,
companyId,
[...new Set(projectWorkspaceIds)],
);
return new Map(
rows.map((row) => [
row.id,
usesInheritedProjectRuntimeServices(row)
? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? [])
: (executionRuntimeServices.get(row.id) ?? []),
]),
);
}
export function executionWorkspaceService(db: Db) { export function executionWorkspaceService(db: Db) {
return { return {
list: async (companyId: string, filters?: { list: async (companyId: string, filters?: {
@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
.from(executionWorkspaces) .from(executionWorkspaces)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
return rows.map((row) => toExecutionWorkspace(row)); const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows);
return rows.map((row) =>
toExecutionWorkspace(
row,
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
),
);
}, },
getById: async (id: string) => { getById: async (id: string) => {
@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
.where(eq(executionWorkspaces.id, id)) .where(eq(executionWorkspaces.id, id))
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (!row) return null; if (!row) return null;
const runtimeServiceRows = await db const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
.select() return toExecutionWorkspace(
.from(workspaceRuntimeServices) row,
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) (runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); );
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
}, },
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => { getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
.then((rows) => rows[0] ?? null); .then((rows) => rows[0] ?? null);
if (!workspace) return null; if (!workspace) return null;
const runtimeServiceRows = await db const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
.select() const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
.from(workspaceRuntimeServices)
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
const linkedIssues = await db const linkedIssues = await db
.select({ .select({

View file

@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
const records = await listLocalServiceRegistryRecords( const records = await listLocalServiceRegistryRecords(
input.profileKind ? { profileKind: input.profileKind } : undefined, input.profileKind ? { profileKind: input.profileKind } : undefined,
); );
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null; const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
if (!record) return null;
let candidate = record;
if (!isPidAlive(candidate.pid)) {
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
if (!ownerPid) {
await removeLocalServiceRegistryRecord(candidate.serviceKey);
return null;
}
candidate = {
...candidate,
pid: ownerPid,
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
lastSeenAt: new Date().toISOString(),
};
await writeLocalServiceRegistryRecord(candidate);
}
if (!(await isLikelyMatchingCommand(candidate))) {
await removeLocalServiceRegistryRecord(record.serviceKey);
return null;
}
return candidate;
} }
export function isPidAlive(pid: number) { export function isPidAlive(pid: number) {
@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
const commandLine = stdout.trim(); const commandLine = stdout.trim();
if (!commandLine) return false; if (!commandLine) return false;
return commandLine.includes(record.command) || commandLine.includes(record.serviceName); const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
const normalizedCommandLine = normalize(commandLine);
const normalizedRecordedCommand = normalize(record.command);
return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName);
} catch { } catch {
return true; return true;
} }

View file

@ -14,7 +14,7 @@ import {
type ProjectWorkspace, type ProjectWorkspace,
type WorkspaceRuntimeService, type WorkspaceRuntimeService,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
.from(projectWorkspaces) .from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds)) .where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
db, db,
rows[0]!.companyId, rows[0]!.companyId,
workspaceRows.map((workspace) => workspace.id), workspaceRows.map((workspace) => workspace.id),
@ -541,7 +541,7 @@ export function projectService(db: Db) {
.where(eq(projectWorkspaces.projectId, projectId)) .where(eq(projectWorkspaces.projectId, projectId))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id)); .orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
if (rows.length === 0) return []; if (rows.length === 0) return [];
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces( const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
db, db,
rows[0]!.companyId, rows[0]!.companyId,
rows.map((workspace) => workspace.id), rows.map((workspace) => workspace.id),

View file

@ -0,0 +1,96 @@
import type { Db } from "@paperclipai/db";
import { workspaceRuntimeServices } from "@paperclipai/db";
import { and, desc, eq, inArray } from "drizzle-orm";
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
function runtimeServiceIdentityKey(row: WorkspaceRuntimeServiceRow) {
if (row.reuseKey) return row.reuseKey;
return [
row.scopeType,
row.scopeId ?? "",
row.projectWorkspaceId ?? "",
row.executionWorkspaceId ?? "",
row.serviceName,
row.command ?? "",
row.cwd ?? "",
].join(":");
}
export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow[]) {
const current = new Map<string, WorkspaceRuntimeServiceRow>();
for (const row of rows) {
const identity = runtimeServiceIdentityKey(row);
if (!current.has(identity)) current.set(identity, row);
}
return [...current.values()];
}
export async function listCurrentRuntimeServicesForProjectWorkspaces(
db: Db,
companyId: string,
projectWorkspaceIds: string[],
) {
if (projectWorkspaceIds.length === 0) return new Map<string, WorkspaceRuntimeServiceRow[]>();
const rows = await db
.select()
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, companyId),
inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds),
eq(workspaceRuntimeServices.scopeType, "project_workspace"),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const grouped = new Map<string, WorkspaceRuntimeServiceRow[]>();
for (const row of rows) {
if (!row.projectWorkspaceId) continue;
const existing = grouped.get(row.projectWorkspaceId) ?? [];
existing.push(row);
grouped.set(row.projectWorkspaceId, existing);
}
return new Map(
Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [
workspaceId,
selectCurrentRuntimeServiceRows(workspaceRows),
]),
);
}
export async function listCurrentRuntimeServicesForExecutionWorkspaces(
db: Db,
companyId: string,
executionWorkspaceIds: string[],
) {
if (executionWorkspaceIds.length === 0) return new Map<string, WorkspaceRuntimeServiceRow[]>();
const rows = await db
.select()
.from(workspaceRuntimeServices)
.where(
and(
eq(workspaceRuntimeServices.companyId, companyId),
inArray(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceIds),
),
)
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
const grouped = new Map<string, WorkspaceRuntimeServiceRow[]>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
const existing = grouped.get(row.executionWorkspaceId) ?? [];
existing.push(row);
grouped.set(row.executionWorkspaceId, existing);
}
return new Map(
Array.from(grouped.entries()).map(([workspaceId, workspaceRows]) => [
workspaceId,
selectCurrentRuntimeServiceRows(workspaceRows),
]),
);
}

View file

@ -1081,6 +1081,16 @@ async function waitForReadiness(input: {
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
} }
async function isRuntimeServiceUrlHealthy(url: string | null) {
if (!url) return true;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
return response.ok;
} catch {
return false;
}
}
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert { function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
return { return {
id: record.id, id: record.id,
@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
profileKind: "workspace-runtime", profileKind: "workspace-runtime",
}); });
if (adoptedRecord) { if (adoptedRecord) {
const record: RuntimeServiceRecord = { const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
id: row.id, if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
companyId: row.companyId, await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
projectId: row.projectId ?? null, } else {
projectWorkspaceId: row.projectWorkspaceId ?? null, const record: RuntimeServiceRecord = {
executionWorkspaceId: row.executionWorkspaceId ?? null, id: row.id,
issueId: row.issueId ?? null, companyId: row.companyId,
serviceName: row.serviceName, projectId: row.projectId ?? null,
status: "running", projectWorkspaceId: row.projectWorkspaceId ?? null,
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], executionWorkspaceId: row.executionWorkspaceId ?? null,
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], issueId: row.issueId ?? null,
scopeId: row.scopeId ?? null, serviceName: row.serviceName,
reuseKey: row.reuseKey ?? null, status: "running",
command: row.command ?? null, lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
cwd: row.cwd ?? null, scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
port: adoptedRecord.port ?? row.port ?? null, scopeId: row.scopeId ?? null,
url: adoptedRecord.url ?? row.url ?? null, reuseKey: row.reuseKey ?? null,
provider: "local_process", command: row.command ?? null,
providerRef: String(adoptedRecord.pid), cwd: row.cwd ?? null,
ownerAgentId: row.ownerAgentId ?? null, port: adoptedRecord.port ?? row.port ?? null,
startedByRunId: row.startedByRunId ?? null, url: adoptedRecord.url ?? row.url ?? null,
lastUsedAt: new Date().toISOString(), provider: "local_process",
startedAt: row.startedAt.toISOString(), providerRef: String(adoptedRecord.pid),
stoppedAt: null, ownerAgentId: row.ownerAgentId ?? null,
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null, startedByRunId: row.startedByRunId ?? null,
healthStatus: "healthy", lastUsedAt: new Date().toISOString(),
reused: true, startedAt: row.startedAt.toISOString(),
db, stoppedAt: null,
child: null, stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
leaseRunIds: new Set(), healthStatus: "healthy",
idleTimer: null, reused: true,
envFingerprint: row.reuseKey ?? "", db,
serviceKey: adoptedRecord.serviceKey, child: null,
profileKind: "workspace-runtime", leaseRunIds: new Set(),
processGroupId: adoptedRecord.processGroupId ?? null, idleTimer: null,
}; envFingerprint: row.reuseKey ?? "",
registerRuntimeService(db, record); serviceKey: adoptedRecord.serviceKey,
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { profileKind: "workspace-runtime",
runtimeServiceId: row.id, processGroupId: adoptedRecord.processGroupId ?? null,
lastSeenAt: record.lastUsedAt, };
}); registerRuntimeService(db, record);
await persistRuntimeServiceRecord(db, record); await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
adopted += 1; runtimeServiceId: row.id,
continue; lastSeenAt: record.lastUsedAt,
});
await persistRuntimeServiceRecord(db, record);
adopted += 1;
continue;
}
} }
const now = new Date(); const now = new Date();

View file

@ -120,4 +120,28 @@ describe("CommentThread", () => {
root.unmount(); root.unmount();
}); });
}); });
it("replaces the composer with a warning when comments are disabled", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[]}
composerDisabledReason="Workspace is closed."
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Workspace is closed.");
expect(container.querySelector('textarea[aria-label="Comment editor"]')).toBeNull();
expect(container.textContent).not.toContain("Comment");
act(() => {
root.unmount();
});
});
}); });

View file

@ -78,6 +78,7 @@ interface CommentThreadProps {
mentions?: MentionOption[]; mentions?: MentionOption[];
onInterruptQueued?: (runId: string) => Promise<void>; onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null; interruptingQueuedRunId?: string | null;
composerDisabledReason?: string | null;
} }
const DRAFT_DEBOUNCE_MS = 800; const DRAFT_DEBOUNCE_MS = 800;
@ -569,6 +570,7 @@ export function CommentThread({
mentions: providedMentions, mentions: providedMentions,
onInterruptQueued, onInterruptQueued,
interruptingQueuedRunId = null, interruptingQueuedRunId = null,
composerDisabledReason = null,
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true); const [reopen, setReopen] = useState(true);
@ -796,90 +798,96 @@ export function CommentThread({
</div> </div>
)} )}
<div className="space-y-2"> {composerDisabledReason ? (
<MarkdownEditor <div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
ref={editorRef} {composerDisabledReason}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Assignee</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
</div> </div>
</div> ) : (
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Assignee</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
</div>
</div>
)}
</div> </div>
); );

View file

@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
<Dialog open={open} onOpenChange={(nextOpen) => { <Dialog open={open} onOpenChange={(nextOpen) => {
if (!closeWorkspace.isPending) onOpenChange(nextOpen); if (!closeWorkspace.isPending) onOpenChange(nextOpen);
}}> }}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
<DialogHeader> <DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle> <DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription className="break-words"> <DialogDescription className="break-words text-xs sm:text-sm">
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{readinessQuery.isLoading ? ( {readinessQuery.isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground"> <div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin shrink-0" />
Checking whether this workspace is safe to close... Checking whether this workspace is safe to close...
</div> </div>
) : readinessQuery.error ? ( ) : readinessQuery.error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"> <div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."} {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
</div> </div>
) : readiness ? ( ) : readiness ? (
<div className="space-y-4"> <div className="min-w-0 space-y-3 sm:space-y-4">
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}> <div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
<div className="font-medium"> <div className="font-medium">
{readiness.state === "blocked" {readiness.state === "blocked"
? "Close is blocked" ? "Close is blocked"
@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
{blockingIssues.length > 0 ? ( {blockingIssues.length > 0 ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Blocking issues</h3> <h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
{blockingIssues.map((issue) => ( {blockingIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm"> <div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"> <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline"> <Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title} {issue.identifier ?? issue.id} · {issue.title}
@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.blockingReasons.length > 0 ? ( {readiness.blockingReasons.length > 0 ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Blocking reasons</h3> <h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
<ul className="space-y-2 text-sm text-muted-foreground"> <ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
{readiness.blockingReasons.map((reason, idx) => ( {readiness.blockingReasons.map((reason, idx) => (
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive"> <li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
{reason} {reason}
</li> </li>
))} ))}
@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.warnings.length > 0 ? ( {readiness.warnings.length > 0 ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Warnings</h3> <h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
<ul className="space-y-2 text-sm text-muted-foreground"> <ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
{readiness.warnings.map((warning, idx) => ( {readiness.warnings.map((warning, idx) => (
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2"> <li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
{warning} {warning}
</li> </li>
))} ))}
@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.git ? ( {readiness.git ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Git status</h3> <h3 className="text-xs font-medium sm:text-sm">Git status</h3>
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm"> <div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid grid-cols-2 gap-2">
<div> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div> <div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div> <div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
</div> </div>
<div> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div> <div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div> <div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
</div> </div>
<div> <div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div> <div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
{otherLinkedIssues.length > 0 ? ( {otherLinkedIssues.length > 0 ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Other linked issues</h3> <h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
{otherLinkedIssues.map((issue) => ( {otherLinkedIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm"> <div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"> <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline"> <Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title} {issue.identifier ?? issue.id} · {issue.title}
@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.runtimeServices.length > 0 ? ( {readiness.runtimeServices.length > 0 ? (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Attached runtime services</h3> <h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
{readiness.runtimeServices.map((service) => ( {readiness.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm"> <div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2"> <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="font-medium">{service.serviceName}</span> <span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span> <span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
) : null} ) : null}
<section className="space-y-2"> <section className="space-y-2">
<h3 className="text-sm font-medium">Cleanup actions</h3> <h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
{readiness.plannedActions.map((action, index) => ( {readiness.plannedActions.map((action, index) => (
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm"> <div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="font-medium">{action.label}</div> <div className="font-medium">{action.label}</div>
<div className="mt-1 break-words text-muted-foreground">{action.description}</div> <div className="mt-1 break-words text-muted-foreground">{action.description}</div>
{action.command ? ( {action.command ? (
@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
</section> </section>
{currentStatus === "cleanup_failed" ? ( {currentStatus === "cleanup_failed" ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
workspace status if it succeeds. workspace status if it succeeds.
</div> </div>
) : null} ) : null}
{currentStatus === "archived" ? ( {currentStatus === "archived" ? (
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
This workspace is already archived. This workspace is already archived.
</div> </div>
) : null} ) : null}
{readiness.git?.repoRoot ? ( {readiness.git?.repoRoot ? (
<div className="break-words text-xs text-muted-foreground"> <div className="overflow-hidden break-words text-xs text-muted-foreground">
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span> Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
{readiness.git.workspacePath ? ( {readiness.git.workspacePath ? (
<> <>

View file

@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queueContainedBlurCommit } from "./InlineEditor";
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => null,
}));
vi.mock("../hooks/useAutosaveIndicator", () => ({
useAutosaveIndicator: () => ({
state: "idle",
markDirty: () => {},
reset: () => {},
runSave: async (save: () => Promise<void>) => {
await save();
},
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("queueContainedBlurCommit", () => {
let container: HTMLDivElement;
let inside: HTMLTextAreaElement;
let outside: HTMLButtonElement;
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
let originalCancelAnimationFrame: typeof window.cancelAnimationFrame;
beforeEach(() => {
vi.useFakeTimers();
originalRequestAnimationFrame = window.requestAnimationFrame;
originalCancelAnimationFrame = window.cancelAnimationFrame;
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => window.clearTimeout(id)) as typeof window.cancelAnimationFrame;
container = document.createElement("div");
inside = document.createElement("textarea");
outside = document.createElement("button");
container.appendChild(inside);
document.body.append(container, outside);
});
afterEach(() => {
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
container.remove();
outside.remove();
vi.useRealTimers();
});
async function flushFrames() {
await act(async () => {
vi.runAllTimers();
await Promise.resolve();
});
}
it("commits when focus stays outside the editor container", async () => {
const onCommit = vi.fn();
const cancel = queueContainedBlurCommit(container, onCommit);
outside.focus();
await flushFrames();
expect(onCommit).toHaveBeenCalledTimes(1);
cancel();
});
it("skips the commit when focus returns inside before the delayed check completes", async () => {
const onCommit = vi.fn();
const cancel = queueContainedBlurCommit(container, onCommit);
outside.focus();
inside.focus();
await flushFrames();
expect(onCommit).not.toHaveBeenCalled();
cancel();
});
});

View file

@ -19,6 +19,23 @@ const pad = "px-1 -mx-1";
const markdownPad = "px-1"; const markdownPad = "px-1";
const AUTOSAVE_DEBOUNCE_MS = 900; const AUTOSAVE_DEBOUNCE_MS = 900;
export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) {
let frameId = requestAnimationFrame(() => {
frameId = requestAnimationFrame(() => {
frameId = 0;
const active = document.activeElement;
if (active instanceof Node && container.contains(active)) return;
onCommit();
});
});
return () => {
if (frameId === 0) return;
cancelAnimationFrame(frameId);
frameId = 0;
};
}
export function InlineEditor({ export function InlineEditor({
value, value,
onSave, onSave,
@ -35,6 +52,7 @@ export function InlineEditor({
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null); const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const blurCommitFrameRef = useRef<(() => void) | null>(null);
const { const {
state: autosaveState, state: autosaveState,
markDirty, markDirty,
@ -52,6 +70,10 @@ export function InlineEditor({
if (autosaveDebounceRef.current) { if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current); clearTimeout(autosaveDebounceRef.current);
} }
if (blurCommitFrameRef.current !== null) {
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}
}; };
}, []); }, []);
@ -91,6 +113,30 @@ export function InlineEditor({
} }
}, [draft, multiline, onSave, value]); }, [draft, multiline, onSave, value]);
const cancelPendingBlurCommit = useCallback(() => {
if (blurCommitFrameRef.current === null) return;
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}, []);
const scheduleBlurCommit = useCallback((container: HTMLDivElement) => {
cancelPendingBlurCommit();
blurCommitFrameRef.current = queueContainedBlurCommit(container, () => {
blurCommitFrameRef.current = null;
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
});
}, [cancelPendingBlurCommit, commit, draft, reset, runSave, value]);
function handleKeyDown(e: React.KeyboardEvent) { function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !multiline) { if (e.key === "Enter" && !multiline) {
e.preventDefault(); e.preventDefault();
@ -146,20 +192,13 @@ export function InlineEditor({
"rounded transition-colors", "rounded transition-colors",
multilineFocused ? "bg-transparent" : "hover:bg-accent/20", multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
)} )}
onFocusCapture={() => setMultilineFocused(true)} onFocusCapture={() => {
cancelPendingBlurCommit();
setMultilineFocused(true);
}}
onBlurCapture={(event) => { onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
if (autosaveDebounceRef.current) { scheduleBlurCommit(event.currentTarget);
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext"; import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody"; import { MarkdownBody } from "./MarkdownBody";
@ -30,11 +30,11 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"'); expect(html).toContain('alt="Org chart"');
}); });
it("renders agent and project mentions as chips", () => { it("renders agent, project, and skill mentions as chips", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<ThemeProvider> <ThemeProvider>
<MarkdownBody> <MarkdownBody>
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`} {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
</MarkdownBody> </MarkdownBody>
</ThemeProvider>, </ThemeProvider>,
); );
@ -45,5 +45,7 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/projects/project-456"'); expect(html).toContain('href="/projects/project-456"');
expect(html).toContain('data-mention-kind="project"'); expect(html).toContain('data-mention-kind="project"');
expect(html).toContain("--paperclip-mention-project-color:#336699"); expect(html).toContain("--paperclip-mention-project-color:#336699");
expect(html).toContain('href="/skills/skill-789"');
expect(html).toContain('data-mention-kind="skill"');
}); });
}); });

View file

@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
if (parsed) { if (parsed) {
const targetHref = parsed.kind === "project" const targetHref = parsed.kind === "project"
? `/projects/${parsed.projectId}` ? `/projects/${parsed.projectId}`
: `/agents/${parsed.agentId}`; : parsed.kind === "skill"
? `/skills/${parsed.skillId}`
: `/agents/${parsed.agentId}`;
return ( return (
<a <a
href={targetHref} href={targetHref}

View file

@ -3,7 +3,7 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MarkdownEditor } from "./MarkdownEditor"; import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({ const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false, emitMountEmptyReset: false,
@ -162,4 +162,28 @@ describe("MarkdownEditor", () => {
root.unmount(); root.unmount();
}); });
}); });
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 180, viewportLeft: 120 },
{ offsetLeft: 24, offsetTop: 320, width: 320, height: 260 },
),
).toEqual({
top: 372,
left: 144,
});
});
it("clamps the mention menu back into view near the viewport edges", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 260, viewportLeft: 240 },
{ offsetLeft: 0, offsetTop: 0, width: 280, height: 220 },
),
).toEqual({
top: 12,
left: 92,
});
});
}); });

View file

@ -29,6 +29,7 @@ import {
type RealmPlugin, type RealmPlugin,
} from "@mdxeditor/editor"; } from "@mdxeditor/editor";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { Boxes } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
@ -37,6 +38,7 @@ import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
import { normalizeMarkdown } from "../lib/normalize-markdown"; import { normalizeMarkdown } from "../lib/normalize-markdown";
import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
/* ---- Mention types ---- */ /* ---- Mention types ---- */
@ -84,6 +86,8 @@ function isSafeMarkdownLinkUrl(url: string): boolean {
/* ---- Mention detection helpers ---- */ /* ---- Mention detection helpers ---- */
interface MentionState { interface MentionState {
trigger: "mention" | "skill";
marker: "@" | "/";
query: string; query: string;
top: number; top: number;
left: number; left: number;
@ -95,6 +99,19 @@ interface MentionState {
endPos: number; endPos: number;
} }
type AutocompleteOption = MentionOption | SkillCommandOption;
interface MentionMenuViewport {
offsetLeft: number;
offsetTop: number;
width: number;
height: number;
}
const MENTION_MENU_WIDTH = 188;
const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const CODE_BLOCK_LANGUAGES: Record<string, string> = { const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text", txt: "Text",
md: "Markdown", md: "Markdown",
@ -135,13 +152,17 @@ function detectMention(container: HTMLElement): MentionState | null {
const text = textNode.textContent ?? ""; const text = textNode.textContent ?? "";
const offset = range.startOffset; const offset = range.startOffset;
// Walk backwards from cursor to find @ // Walk backwards from cursor to find an autocomplete trigger.
let atPos = -1; let atPos = -1;
let trigger: MentionState["trigger"] | null = null;
let marker: MentionState["marker"] | null = null;
for (let i = offset - 1; i >= 0; i--) { for (let i = offset - 1; i >= 0; i--) {
const ch = text[i]; const ch = text[i];
if (ch === "@") { if (ch === "@" || ch === "/") {
if (i === 0 || /\s/.test(text[i - 1])) { if (i === 0 || /\s/.test(text[i - 1])) {
atPos = i; atPos = i;
trigger = ch === "@" ? "mention" : "skill";
marker = ch;
} }
break; break;
} }
@ -160,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
return { return {
trigger: trigger ?? "mention",
marker: marker ?? "@",
query, query,
top: rect.bottom - containerRect.top, top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left, left: rect.left - containerRect.left,
@ -171,6 +194,40 @@ function detectMention(container: HTMLElement): MentionState | null {
}; };
} }
function getMentionMenuViewport(): MentionMenuViewport {
const viewport = window.visualViewport;
if (viewport) {
return {
offsetLeft: viewport.offsetLeft,
offsetTop: viewport.offsetTop,
width: viewport.width,
height: viewport.height,
};
}
return {
offsetLeft: 0,
offsetTop: 0,
width: window.innerWidth,
height: window.innerHeight,
};
}
export function computeMentionMenuPosition(
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
viewport: MentionMenuViewport,
) {
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH;
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
return {
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)),
};
}
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
if (!node || !container.contains(node)) return false; if (!node || !container.contains(node)) return false;
const el = node.nodeType === Node.ELEMENT_NODE const el = node.nodeType === Node.ELEMENT_NODE
@ -197,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string {
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
} }
/** Replace `@<query>` in the markdown string with the selected mention token. */ function skillMarkdown(option: SkillCommandOption): string {
function applyMention(markdown: string, query: string, option: MentionOption): string { return `[/${option.slug}](${option.href}) `;
const search = `@${query}`; }
const replacement = mentionMarkdown(option);
function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
}
/** Replace the active autocomplete token in the markdown string with the selected token. */
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
const search = `${state.marker}${state.query}`;
const replacement = autocompleteMarkdown(option);
const idx = markdown.lastIndexOf(search); const idx = markdown.lastIndexOf(search);
if (idx === -1) return markdown; if (idx === -1) return markdown;
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
@ -220,6 +285,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
mentions, mentions,
onSubmit, onSubmit,
}: MarkdownEditorProps, forwardedRef) { }: MarkdownEditorProps, forwardedRef) {
const { slashCommands } = useEditorAutocomplete();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<MDXEditorMethods>(null); const ref = useRef<MDXEditorMethods>(null);
const valueRef = useRef(value); const valueRef = useRef(value);
@ -244,7 +310,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null); const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null); const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0); const [mentionIndex, setMentionIndex] = useState(0);
const mentionActive = mentionState !== null && mentions && mentions.length > 0; const mentionActive = mentionState !== null && (
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
);
const mentionOptionByKey = useMemo(() => { const mentionOptionByKey = useMemo(() => {
const map = new Map<string, MentionOption>(); const map = new Map<string, MentionOption>();
for (const mention of mentions ?? []) { for (const mention of mentions ?? []) {
@ -259,11 +328,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return map; return map;
}, [mentions]); }, [mentions]);
const filteredMentions = useMemo(() => { const filteredMentions = useMemo<AutocompleteOption[]>(() => {
if (!mentionState || !mentions) return []; if (!mentionState) return [];
const q = mentionState.query.toLowerCase(); const q = mentionState.query.trim().toLowerCase();
if (mentionState.trigger === "skill") {
return slashCommands
.filter((command) => {
if (!q) return true;
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
})
.slice(0, 8);
}
if (!mentions) return [];
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
}, [mentionState?.query, mentions]); }, [mentionState, mentions, slashCommands]);
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
ref.current = instance; ref.current = instance;
@ -375,6 +453,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
continue; continue;
} }
if (parsed.kind === "skill") {
applyMentionChipDecoration(link, parsed);
continue;
}
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
applyMentionChipDecoration(link, { applyMentionChipDecoration(link, {
...parsed, ...parsed,
@ -385,12 +468,30 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention detection: listen for selection changes and input events // Mention detection: listen for selection changes and input events
const checkMention = useCallback(() => { const checkMention = useCallback(() => {
if (!mentions || mentions.length === 0 || !containerRef.current) { if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
mentionStateRef.current = null; mentionStateRef.current = null;
setMentionState(null); setMentionState(null);
return; return;
} }
const result = detectMention(containerRef.current); const result = detectMention(containerRef.current);
if (
result
&& result.trigger === "mention"
&& (!mentions || mentions.length === 0)
) {
mentionStateRef.current = null;
setMentionState(null);
return;
}
if (
result
&& result.trigger === "skill"
&& slashCommands.length === 0
) {
mentionStateRef.current = null;
setMentionState(null);
return;
}
mentionStateRef.current = result; mentionStateRef.current = result;
if (result) { if (result) {
setMentionState(result); setMentionState(result);
@ -398,10 +499,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
} else { } else {
setMentionState(null); setMentionState(null);
} }
}, [mentions]); }, [mentions, slashCommands.length]);
useEffect(() => { useEffect(() => {
if (!mentions || mentions.length === 0) return; if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return;
const el = containerRef.current; const el = containerRef.current;
// Listen for input events on the container so mention detection // Listen for input events on the container so mention detection
@ -414,7 +515,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
document.removeEventListener("selectionchange", checkMention); document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true); el?.removeEventListener("input", onInput, true);
}; };
}, [checkMention, mentions]); }, [checkMention, mentions, slashCommands.length]);
useEffect(() => {
if (!mentionActive) return;
const updatePosition = () => requestAnimationFrame(checkMention);
const viewport = window.visualViewport;
viewport?.addEventListener("resize", updatePosition);
viewport?.addEventListener("scroll", updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
viewport?.removeEventListener("resize", updatePosition);
viewport?.removeEventListener("scroll", updatePosition);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [checkMention, mentionActive]);
useEffect(() => { useEffect(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]'); const editable = containerRef.current?.querySelector('[contenteditable="true"]');
@ -432,13 +552,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}, [decorateProjectMentions, value]); }, [decorateProjectMentions, value]);
const selectMention = useCallback( const selectMention = useCallback(
(option: MentionOption) => { (option: AutocompleteOption) => {
// Read from ref to avoid stale-closure issues (selectionchange can // Read from ref to avoid stale-closure issues (selectionchange can
// update state between the last render and this callback firing). // update state between the last render and this callback firing).
const state = mentionStateRef.current; const state = mentionStateRef.current;
if (!state) return; if (!state) return;
const current = latestValueRef.current; const current = latestValueRef.current;
const next = applyMention(current, state.query, option); const next = applyMention(current, state, option);
if (next !== current) { if (next !== current) {
latestValueRef.current = next; latestValueRef.current = next;
echoIgnoreMarkdownRef.current = next; echoIgnoreMarkdownRef.current = next;
@ -453,17 +573,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
decorateProjectMentions(); decorateProjectMentions();
editable.focus(); editable.focus();
const mentionHref = option.kind === "project" && option.projectId const mentionHref = option.kind === "skill"
? buildProjectMentionHref(option.projectId, option.projectColor ?? null) ? option.href
: buildAgentMentionHref( : option.kind === "project" && option.projectId
option.agentId ?? option.id.replace(/^agent:/, ""), ? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
option.agentIcon ?? null, : buildAgentMentionHref(
); option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
const matchingMentions = Array.from(editable.querySelectorAll("a")) const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => { .filter((link) => {
const href = link.getAttribute("href") ?? ""; const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === `@${option.name}`; return href === mentionHref && link.textContent === expectedLabel;
}); });
const containerRect = containerRef.current?.getBoundingClientRect(); const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => { const target = matchingMentions.sort((a, b) => {
@ -526,6 +649,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
ref.current.insertMarkdown(normalizeMarkdown(rawText)); ref.current.insertMarkdown(normalizeMarkdown(rawText));
}, []); }, []);
const mentionMenuPosition = mentionState
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
: null;
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -645,25 +772,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
createPortal( createPortal(
<div <div
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md" className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{ style={mentionMenuPosition ?? undefined}
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
}}
> >
{filteredMentions.map((option, i) => ( {filteredMentions.map((option, i) => (
<button <button
key={option.id} key={option.id}
type="button"
className={cn( className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors", "flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent", i === mentionIndex && "bg-accent",
)} )}
onMouseDown={(e) => { onPointerDown={(e) => {
e.preventDefault(); // prevent blur e.preventDefault(); // prevent blur
selectMention(option); selectMention(option);
}} }}
onMouseEnter={() => setMentionIndex(i)} onMouseEnter={() => setMentionIndex(i)}
> >
{option.kind === "project" && option.projectId ? ( {option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "project" && option.projectId ? (
<span <span
className="inline-flex h-2 w-2 rounded-full border border-border/50" className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }} style={{ backgroundColor: option.projectColor ?? "#64748b" }}
@ -674,12 +801,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
className="h-3.5 w-3.5 shrink-0 text-muted-foreground" className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/> />
)} )}
<span>{option.name}</span> <span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span>
{option.kind === "project" && option.projectId && ( {option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground"> <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project Project
</span> </span>
)} )}
{option.kind === "skill" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Skill
</span>
)}
</button> </button>
))} ))}
</div>, </div>,

View file

@ -24,6 +24,7 @@ import {
DialogContent, DialogContent,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@ -1208,21 +1209,10 @@ export function NewIssueDialog() {
{assigneeAdapterType === "claude_local" && ( {assigneeAdapterType === "claude_local" && (
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5"> <div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div> <div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
<button <ToggleSwitch
data-slot="toggle" checked={assigneeChrome}
className={cn( onCheckedChange={() => setAssigneeChrome((value) => !value)}
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors", />
assigneeChrome ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeChrome((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div> </div>
)} )}
</div> </div>

View file

@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react"; import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal"; import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives"; import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor"; import { InlineEditor } from "./InlineEditor";
@ -886,26 +887,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div> </div>
</div> </div>
{onUpdate || onFieldUpdate ? ( {onUpdate || onFieldUpdate ? (
<button <ToggleSwitch
data-slot="toggle" checked={executionWorkspacesEnabled}
className={cn( onCheckedChange={() =>
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField( commitField(
"execution_workspace_enabled", "execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!, updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)} )}
> />
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
) : ( ) : (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"} {executionWorkspacesEnabled ? "Enabled" : "Disabled"}
@ -925,14 +914,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
If disabled, new issues stay on the project's primary checkout unless someone opts in. If disabled, new issues stay on the project's primary checkout unless someone opts in.
</div> </div>
</div> </div>
<button <ToggleSwitch
data-slot="toggle" checked={executionWorkspaceDefaultMode === "isolated_workspace"}
className={cn( onCheckedChange={() =>
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
commitField( commitField(
"execution_workspace_default_mode", "execution_workspace_default_mode",
updateExecutionWorkspacePolicy({ updateExecutionWorkspacePolicy({
@ -942,16 +926,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
: "isolated_workspace", : "isolated_workspace",
})!, })!,
)} )}
> />
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated_workspace"
? "translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
</div> </div>
<div className="border-t border-border/60 pt-2"> <div className="border-t border-border/60 pt-2">

View file

@ -4,6 +4,7 @@ import {
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -111,23 +112,11 @@ export function ToggleField({
<span className="text-xs text-muted-foreground">{label}</span> <span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />} {hint && <HintIcon text={hint} />}
</div> </div>
<button <ToggleSwitch
data-slot="toggle" checked={checked}
onCheckedChange={onChange}
data-testid={toggleTestId} 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"
)}
onClick={() => onChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div> </div>
); );
} }
@ -162,21 +151,10 @@ export function ToggleWithNumber({
<span className="text-xs text-muted-foreground">{label}</span> <span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />} {hint && <HintIcon text={hint} />}
</div> </div>
<button <ToggleSwitch
data-slot="toggle" checked={checked}
className={cn( onCheckedChange={onCheckedChange}
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0", />
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
</div> </div>
{showNumber && ( {showNumber && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">

View file

@ -0,0 +1,59 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ToggleSwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
size?: "default" | "lg";
}
export const ToggleSwitch = React.forwardRef<
HTMLButtonElement,
ToggleSwitchProps
>(
(
{ checked, onCheckedChange, size = "default", className, disabled, ...props },
ref,
) => {
const isLg = size === "lg";
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
data-slot="toggle"
disabled={disabled}
className={cn(
"relative inline-flex shrink-0 items-center rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
// Track: larger on mobile (<640px), standard on desktop
isLg ? "h-7 w-12 sm:h-6 sm:w-11" : "h-6 w-10 sm:h-5 sm:w-9",
checked ? "bg-green-600" : "bg-muted",
className,
)}
onClick={() => onCheckedChange(!checked)}
{...props}
>
<span
className={cn(
"pointer-events-none inline-block rounded-full bg-white shadow-sm transition-transform",
// Thumb
isLg ? "size-5.5 sm:size-5" : "size-4.5 sm:size-3.5",
// Slide position
checked
? isLg
? "translate-x-5 sm:translate-x-5"
: "translate-x-5 sm:translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
);
},
);
ToggleSwitch.displayName = "ToggleSwitch";

View file

@ -0,0 +1,61 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { buildSkillMentionHref } from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills";
import { useCompany } from "./CompanyContext";
import { queryKeys } from "../lib/queryKeys";
export interface SkillCommandOption {
id: string;
kind: "skill";
skillId: string;
key: string;
name: string;
slug: string;
description: string | null;
href: string;
aliases: string[];
}
interface EditorAutocompleteContextValue {
slashCommands: SkillCommandOption[];
}
const EditorAutocompleteContext = createContext<EditorAutocompleteContextValue>({
slashCommands: [],
});
export function EditorAutocompleteProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId } = useCompany();
const { data: companySkills = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.companySkills.list(selectedCompanyId)
: ["company-skills", "__none__"],
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const value = useMemo<EditorAutocompleteContextValue>(() => ({
slashCommands: companySkills.map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill",
skillId: skill.id,
key: skill.key,
name: skill.name,
slug: skill.slug,
description: skill.description ?? null,
href: buildSkillMentionHref(skill.id, skill.slug),
aliases: [skill.slug, skill.name, skill.key],
})),
}), [companySkills]);
return (
<EditorAutocompleteContext.Provider value={value}>
{children}
</EditorAutocompleteContext.Provider>
);
}
export function useEditorAutocomplete() {
return useContext(EditorAutocompleteContext);
}

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared";
import { getAgentIcon } from "./agent-icons"; import { getAgentIcon } from "./agent-icons";
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
@ -13,6 +13,11 @@ export type ParsedMentionChip =
kind: "project"; kind: "project";
projectId: string; projectId: string;
color: string | null; color: string | null;
}
| {
kind: "skill";
skillId: string;
slug: string | null;
}; };
const iconMaskCache = new Map<string, string>(); const iconMaskCache = new Map<string, string>();
@ -36,6 +41,15 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null {
}; };
} }
const skill = parseSkillMentionHref(href);
if (skill) {
return {
kind: "skill",
skillId: skill.skillId,
slug: skill.slug,
};
}
return null; return null;
} }
@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
"paperclip-mention-chip", "paperclip-mention-chip",
"paperclip-mention-chip--agent", "paperclip-mention-chip--agent",
"paperclip-mention-chip--project", "paperclip-mention-chip--project",
"paperclip-mention-chip--skill",
"paperclip-project-mention-chip", "paperclip-project-mention-chip",
); );
element.removeAttribute("contenteditable"); element.removeAttribute("contenteditable");

View file

@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext";
import { PanelProvider } from "./context/PanelContext"; import { PanelProvider } from "./context/PanelContext";
import { SidebarProvider } from "./context/SidebarContext"; import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext"; import { DialogProvider } from "./context/DialogContext";
import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext";
import { ToastProvider } from "./context/ToastContext"; import { ToastProvider } from "./context/ToastContext";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render(
<ThemeProvider> <ThemeProvider>
<BrowserRouter> <BrowserRouter>
<CompanyProvider> <CompanyProvider>
<ToastProvider> <EditorAutocompleteProvider>
<LiveUpdatesProvider> <ToastProvider>
<TooltipProvider> <LiveUpdatesProvider>
<BreadcrumbProvider> <TooltipProvider>
<SidebarProvider> <BreadcrumbProvider>
<PanelProvider> <SidebarProvider>
<PluginLauncherProvider> <PanelProvider>
<DialogProvider> <PluginLauncherProvider>
<App /> <DialogProvider>
</DialogProvider> <App />
</PluginLauncherProvider> </DialogProvider>
</PanelProvider> </PluginLauncherProvider>
</SidebarProvider> </PanelProvider>
</BreadcrumbProvider> </SidebarProvider>
</TooltipProvider> </BreadcrumbProvider>
</LiveUpdatesProvider> </TooltipProvider>
</ToastProvider> </LiveUpdatesProvider>
</ToastProvider>
</EditorAutocompleteProvider>
</CompanyProvider> </CompanyProvider>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>

View file

@ -25,6 +25,7 @@ import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm"; import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { MarkdownEditor } from "../components/MarkdownEditor"; import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets"; import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
@ -1627,30 +1628,16 @@ function ConfigurationTab({
Lets this agent create or hire agents and implicitly assign tasks. Lets this agent create or hire agents and implicitly assign tasks.
</p> </p>
</div> </div>
<button <ToggleSwitch
type="button" checked={canCreateAgents}
role="switch" onCheckedChange={() =>
data-slot="toggle"
aria-checked={canCreateAgents}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canCreateAgents ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updatePermissions.mutate({ updatePermissions.mutate({
canCreateAgents: !canCreateAgents, canCreateAgents: !canCreateAgents,
canAssignTasks: !canCreateAgents ? true : canAssignTasks, canAssignTasks: !canCreateAgents ? true : canAssignTasks,
}) })
} }
disabled={updatePermissions.isPending} disabled={updatePermissions.isPending}
> />
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
<div className="flex items-center justify-between gap-4 text-sm"> <div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1"> <div className="space-y-1">
@ -1659,30 +1646,16 @@ function ConfigurationTab({
{taskAssignHint} {taskAssignHint}
</p> </p>
</div> </div>
<button <ToggleSwitch
type="button" checked={canAssignTasks}
role="switch" onCheckedChange={() =>
data-slot="toggle"
aria-checked={canAssignTasks}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updatePermissions.mutate({ updatePermissions.mutate({
canCreateAgents, canCreateAgents,
canAssignTasks: !canAssignTasks, canAssignTasks: !canAssignTasks,
}) })
} }
disabled={updatePermissions.isPending || taskAssignLocked} disabled={updatePermissions.isPending || taskAssignLocked}
> />
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -43,6 +43,10 @@ function readText(value: string | null | undefined) {
return value ?? ""; return value ?? "";
} }
function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) {
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
}
function formatJson(value: Record<string, unknown> | null | undefined) { function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return ""; if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2); return JSON.stringify(value, null, 2);
@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() {
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full sm:w-auto" className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")} onClick={() => controlRuntimeServices.mutate("stop")}
> >
Stop Stop

View file

@ -4,7 +4,7 @@ import { FlaskConical } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings"; import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils"; import { ToggleSwitch } from "@/components/ui/toggle-switch";
export function InstanceExperimentalSettings() { export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
@ -82,24 +82,12 @@ export function InstanceExperimentalSettings() {
and existing issue runs. and existing issue runs.
</p> </p>
</div> </div>
<button <ToggleSwitch
type="button" checked={enableIsolatedWorkspaces}
data-slot="toggle" onCheckedChange={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
aria-label="Toggle isolated workspaces experimental setting"
disabled={toggleMutation.isPending} disabled={toggleMutation.isPending}
className={cn( aria-label="Toggle isolated workspaces experimental setting"
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60", />
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
</section> </section>
@ -112,26 +100,12 @@ export function InstanceExperimentalSettings() {
automatically when backend changes or migrations make the current boot stale. automatically when backend changes or migrations make the current boot stale.
</p> </p>
</div> </div>
<button <ToggleSwitch
type="button" checked={autoRestartDevServerWhenIdle}
data-slot="toggle" onCheckedChange={() => toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })}
aria-label="Toggle guarded dev-server auto-restart"
disabled={toggleMutation.isPending} disabled={toggleMutation.isPending}
className={cn( aria-label="Toggle guarded dev-server auto-restart"
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60", />
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
</section> </section>
</div> </div>

View file

@ -1,10 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared"; import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
import { SlidersHorizontal } from "lucide-react"; import { LogOut, SlidersHorizontal } from "lucide-react";
import { authApi } from "@/api/auth";
import { instanceSettingsApi } from "@/api/instanceSettings"; import { instanceSettingsApi } from "@/api/instanceSettings";
import { Button } from "../components/ui/button";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos"; const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
@ -14,6 +17,16 @@ export function InstanceGeneralSettings() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const signOutMutation = useMutation({
mutationFn: () => authApi.signOut(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to sign out.");
},
});
useEffect(() => { useEffect(() => {
setBreadcrumbs([ setBreadcrumbs([
{ label: "Instance Settings" }, { label: "Instance Settings" },
@ -83,28 +96,12 @@ export function InstanceGeneralSettings() {
default. default.
</p> </p>
</div> </div>
<button <ToggleSwitch
type="button" checked={censorUsernameInLogs}
data-slot="toggle" onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
aria-label="Toggle username log censoring"
disabled={updateGeneralMutation.isPending} disabled={updateGeneralMutation.isPending}
className={cn( aria-label="Toggle username log censoring"
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60", />
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updateGeneralMutation.mutate({
censorUsernameInLogs: !censorUsernameInLogs,
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
</section> </section>
@ -117,24 +114,12 @@ export function InstanceGeneralSettings() {
toggling panels. This is off by default. toggling panels. This is off by default.
</p> </p>
</div> </div>
<button <ToggleSwitch
type="button" checked={keyboardShortcuts}
data-slot="toggle" onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
aria-label="Toggle keyboard shortcuts"
disabled={updateGeneralMutation.isPending} disabled={updateGeneralMutation.isPending}
className={cn( aria-label="Toggle keyboard shortcuts"
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60", />
keyboardShortcuts ? "bg-green-600" : "bg-muted",
)}
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div> </div>
</section> </section>
@ -213,6 +198,26 @@ export function InstanceGeneralSettings() {
</p> </p>
</div> </div>
</section> </section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Sign out</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Sign out of this Paperclip instance. You will be redirected to the login page.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={signOutMutation.isPending}
onClick={() => signOutMutation.mutate()}
>
<LogOut className="size-4" />
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</Button>
</div>
</section>
</div> </div>
); );
} }

View file

@ -71,8 +71,16 @@ import {
SlidersHorizontal, SlidersHorizontal,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared"; import {
import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared"; getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
type ActivityEvent,
type Agent,
type FeedbackVote,
type Issue,
type IssueAttachment,
type IssueComment,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment; type CommentReassignment = IssueCommentReassignment;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & { type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
@ -306,6 +314,12 @@ export function IssueDetail() {
enabled: !!issueId, enabled: !!issueId,
}); });
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const commentComposerDisabledReason = useMemo(() => {
if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) {
return null;
}
return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace);
}, [issue?.currentExecutionWorkspace]);
const { data: comments } = useQuery({ const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(issueId!), queryKey: queryKeys.issues.comments(issueId!),
@ -1522,6 +1536,7 @@ export function IssueDetail() {
await interruptQueuedComment.mutateAsync(runId); await interruptQueuedComment.mutateAsync(runId);
}} }}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null} interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={async (commentId, vote, options) => { onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({ await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment", targetType: "issue_comment",

View file

@ -61,6 +61,10 @@ function readText(value: string | null | undefined) {
return value ?? ""; return value ?? "";
} }
function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) {
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
}
function formatJson(value: Record<string, unknown> | null | undefined) { function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return ""; if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2); return JSON.stringify(value, null, 2);
@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() {
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full sm:w-auto" className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0} disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")} onClick={() => controlRuntimeServices.mutate("stop")}
> >
Stop Stop

View file

@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch"; import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker"; import { AgentIcon } from "../components/AgentIconPicker";
@ -710,24 +711,13 @@ export function RoutineDetail() {
}} }}
disabled={runRoutine.isPending} disabled={runRoutine.isPending}
/> />
<button <ToggleSwitch
type="button" size="lg"
role="switch" checked={automationEnabled}
data-slot="toggle" onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
aria-checked={automationEnabled}
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
disabled={automationToggleDisabled} disabled={automationToggleDisabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
automationEnabled ? "bg-emerald-500" : "bg-muted" />
} ${automationToggleDisabled ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
automationEnabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}> <span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
{automationLabel} {automationLabel}
</span> </span>

View file

@ -0,0 +1,367 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Routines, buildRoutineGroups } from "./Routines";
let currentSearch = "";
const navigateMock = vi.fn();
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
));
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }),
useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()],
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
vi.mock("../api/routines", () => ({
routinesApi: {
list: (companyId: string) => routinesListMock(companyId),
create: vi.fn(),
update: vi.fn(),
run: vi.fn(),
},
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
update: vi.fn(),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: {
list: vi.fn(async () => [
{
id: "agent-1",
companyId: "company-1",
name: "Agent One",
role: "engineer",
title: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
contextMode: "thin",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
icon: "code",
metadata: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
urlKey: "agent-one",
pauseReason: null,
pausedAt: null,
permissions: null,
},
{
id: "agent-2",
companyId: "company-1",
name: "Agent Two",
role: "engineer",
title: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
contextMode: "thin",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
icon: "code",
metadata: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
urlKey: "agent-two",
pauseReason: null,
pausedAt: null,
permissions: null,
},
]),
},
}));
vi.mock("../api/projects", () => ({
projectsApi: {
list: vi.fn(async () => [
{
id: "project-1",
companyId: "company-1",
urlKey: "project-alpha",
goalId: null,
goalIds: [],
goals: [],
name: "Project Alpha",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: null,
codebase: null,
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
{
id: "project-2",
companyId: "company-1",
urlKey: "project-beta",
goalId: null,
goalIds: [],
goals: [],
name: "Project Beta",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#38bdf8",
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: null,
codebase: null,
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
]),
},
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: {
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })),
},
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: {
liveRunsForCompany: vi.fn(async () => []),
},
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: (props: { issues: Issue[] }) => issuesListRenderMock(props),
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ label: string }> }) => (
<div>{items.map((item) => item.label).join(", ")}</div>
),
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: unknown }) => <div>{children as never}</div>,
TabsContent: ({ children }: { children: unknown }) => <div>{children as never}</div>,
}));
vi.mock("../components/MarkdownEditor", () => ({
MarkdownEditor: () => <div />,
}));
vi.mock("../components/InlineEntitySelector", () => ({
InlineEntitySelector: () => <button type="button">selector</button>,
}));
vi.mock("../components/RoutineRunVariablesDialog", () => ({
RoutineRunVariablesDialog: () => null,
routineRunNeedsConfiguration: () => false,
}));
vi.mock("../components/RoutineVariablesEditor", () => ({
RoutineVariablesEditor: () => null,
RoutineVariablesHint: () => null,
}));
vi.mock("../components/AgentIconPicker", () => ({
AgentIcon: () => <span data-testid="agent-icon" />,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
return {
id: "routine-1",
companyId: "company-1",
projectId: "project-1",
goalId: null,
parentIssueId: null,
title: "Routine title",
description: null,
assigneeAgentId: "agent-1",
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
triggers: [],
lastRun: null,
activeIssue: null,
...overrides,
};
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-1000",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Routine execution issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1000,
originKind: "routine_execution",
originId: "routine-1",
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: new Date("2026-04-01T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
};
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
describe("Routines page", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
currentSearch = "";
navigateMock.mockReset();
routinesListMock.mockReset();
issuesListMock.mockReset();
issuesListRenderMock.mockClear();
localStorage.clear();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("groups routines by project using project names for the section labels", () => {
const groups = buildRoutineGroups(
[
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }),
],
"project",
new Map([
["project-1", { name: "Project Alpha" }],
["project-2", { name: "Project Beta" }],
]),
new Map([
["agent-1", { name: "Agent One" }],
["agent-2", { name: "Agent Two" }],
]),
);
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]);
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]);
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
});
it("shows recent runs through the issues list scoped to routine execution issues", async () => {
currentSearch = "tab=runs";
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
issuesListMock.mockResolvedValue([
createIssue({ id: "issue-1", title: "Routine execution A" }),
createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }),
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" });
await act(async () => {
root.unmount();
});
});
});

View file

@ -1,18 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router"; import { useNavigate, useSearchParams } from "@/lib/router";
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react"; import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines"; import { routinesApi } from "../api/routines";
import { instanceSettingsApi } from "../api/instanceSettings"; import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees"; import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { AgentIcon } from "../components/AgentIconPicker"; import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector"; import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor"; import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
@ -33,6 +40,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -40,6 +48,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
@ -70,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
return enabled ? "active" : "paused"; return enabled ? "active" : "paused";
} }
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineViewState = {
groupBy: RoutineGroupBy;
collapsedGroups: string[];
};
type RoutineGroup = {
key: string;
label: string | null;
items: RoutineListItem[];
};
const defaultRoutineViewState: RoutineViewState = {
groupBy: "none",
collapsedGroups: [],
};
function getRoutineViewState(key: string): RoutineViewState {
try {
const raw = localStorage.getItem(key);
if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) };
} catch {
// Ignore malformed local state and fall back to defaults.
}
return { ...defaultRoutineViewState };
}
function saveRoutineViewState(key: string, state: RoutineViewState) {
localStorage.setItem(key, JSON.stringify(state));
}
function formatRoutineRunStatus(value: string | null | undefined) {
if (!value) return null;
return value.replaceAll("_", " ");
}
export function buildRoutineGroups(
routines: RoutineListItem[],
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
): RoutineGroup[] {
if (groupByValue === "none") {
return [{ key: "__all", label: null, items: routines }];
}
if (groupByValue === "project") {
const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project");
const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"),
items: groups[key]!,
}));
}
const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent");
const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"),
items: groups[key]!,
}));
}
function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
function RoutineListRow({
routine,
projectById,
agentById,
runningRoutineId,
statusMutationRoutineId,
onNavigate,
onRunNow,
onToggleEnabled,
onToggleArchived,
}: {
routine: RoutineListItem;
projectById: Map<string, { name: string; color?: string | null }>;
agentById: Map<string, { name: string; icon?: string | null }>;
runningRoutineId: string | null;
statusMutationRoutineId: string | null;
onNavigate: (routineId: string) => void;
onRunNow: (routine: RoutineListItem) => void;
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
onToggleArchived: (routine: RoutineListItem) => void;
}) {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
return (
<div
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center"
onClick={() => onNavigate(routine.id)}
>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{routine.title}</span>
{(isArchived || routine.status === "paused") ? (
<span className="text-xs text-muted-foreground">
{isArchived ? "archived" : "paused"}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span>{project?.name ?? "Unknown project"}</span>
</span>
<span className="flex items-center gap-2">
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
<span>{agent?.name ?? "Unknown agent"}</span>
</span>
<span>
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
</span>
</div>
</div>
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center gap-3">
<ToggleSwitch
size="lg"
checked={enabled}
onCheckedChange={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/>
<span className="w-12 text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onNavigate(routine.id)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => onRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onToggleArchived(routine)}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export function Routines() { export function Routines() {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { pushToast } = useToast(); const { pushToast } = useToast();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null); const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null); const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
@ -85,6 +286,7 @@ export function Routines() {
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null); const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [composerOpen, setComposerOpen] = useState(false); const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines";
const [draft, setDraft] = useState<{ const [draft, setDraft] = useState<{
title: string; title: string;
description: string; description: string;
@ -104,11 +306,19 @@ export function Routines() {
catchUpPolicy: "skip_missed", catchUpPolicy: "skip_missed",
variables: [], variables: [],
}); });
const routineViewStateKey = selectedCompanyId
? `paperclip:routines-view:${selectedCompanyId}`
: "paperclip:routines-view";
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
useEffect(() => { useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]); setBreadcrumbs([{ label: "Routines" }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
useEffect(() => {
setRoutineViewState(getRoutineViewState(routineViewStateKey));
}, [routineViewStateKey]);
const { data: routines, isLoading, error } = useQuery({ const { data: routines, isLoading, error } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!), queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!), queryFn: () => routinesApi.list(selectedCompanyId!),
@ -129,6 +339,17 @@ export function Routines() {
queryFn: () => instanceSettingsApi.getExperimental(), queryFn: () => instanceSettingsApi.getExperimental(),
retry: false, retry: false,
}); });
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
enabled: !!selectedCompanyId && activeTab === "runs",
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId && activeTab === "runs",
refetchInterval: 5000,
});
useEffect(() => { useEffect(() => {
autoResizeTextarea(titleInputRef.current); autoResizeTextarea(titleInputRef.current);
@ -162,6 +383,13 @@ export function Routines() {
navigate(`/routines/${routine.id}?tab=triggers`); navigate(`/routines/${routine.id}?tab=triggers`);
}, },
}); });
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] });
},
});
const updateRoutineStatus = useMutation({ const updateRoutineStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }), mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
@ -249,10 +477,45 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])), () => new Map((projects ?? []).map((project) => [project.id, project])),
[projects], [projects],
); );
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const routineGroups = useMemo(
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, routines],
);
const recentRunsIssueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Recent Runs",
buildRoutinesTabHref("runs"),
"issues",
),
[],
);
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null; const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null; const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null; const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
function updateRoutineView(patch: Partial<RoutineViewState>) {
setRoutineViewState((current) => {
const next = { ...current, ...patch };
saveRoutineViewState(routineViewStateKey, next);
return next;
});
}
function handleTabChange(tab: string) {
const nextTab = tab === "runs" ? "runs" : "routines";
startTransition(() => {
navigate(buildRoutinesTabHref(nextTab));
});
}
function handleRunNow(routine: RoutineListItem) { function handleRunNow(routine: RoutineListItem) {
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null; const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const needsConfiguration = routineRunNeedsConfiguration({ const needsConfiguration = routineRunNeedsConfiguration({
@ -267,6 +530,20 @@ export function Routines() {
runRoutine.mutate({ id: routine.id, data: {} }); runRoutine.mutate({ id: routine.id, data: {} });
} }
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
});
}
function handleToggleArchived(routine: RoutineListItem) {
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
});
}
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />; return <EmptyState icon={Repeat} message="Select a company to view routines." />;
} }
@ -293,6 +570,68 @@ export function Routines() {
</Button> </Button>
</div> </div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
<TabsContent value="routines" className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
</p>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="runs">
<IssuesList
issues={routineExecutionIssues ?? []}
isLoading={recentRunsLoading}
error={recentRunsError as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:routine-recent-runs-view"
issueLinkState={recentRunsIssueLinkState}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
</TabsContent>
</Tabs>
<Dialog <Dialog
open={composerOpen} open={composerOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -560,165 +899,64 @@ export function Routines() {
</Card> </Card>
) : null} ) : null}
<div> {activeTab === "routines" ? (
{(routines ?? []).length === 0 ? ( <div>
<div className="py-12"> {(routines ?? []).length === 0 ? (
<EmptyState <div className="py-12">
icon={Repeat} <EmptyState
message="No routines yet. Use Create routine to define the first recurring workflow." icon={Repeat}
/> message="No routines yet. Use Create routine to define the first recurring workflow."
</div> />
) : ( </div>
<div className="overflow-x-auto"> ) : (
<table className="min-w-full text-sm"> <div className="rounded-lg border border-border">
<thead> {routineGroups.map((group) => (
<tr className="text-left text-xs text-muted-foreground border-b border-border"> <Collapsible
<th className="px-3 py-2 font-medium">Name</th> key={group.key}
<th className="px-3 py-2 font-medium">Project</th> open={!routineViewState.collapsedGroups.includes(group.key)}
<th className="px-3 py-2 font-medium">Agent</th> onOpenChange={(open) => {
<th className="px-3 py-2 font-medium">Last run</th> updateRoutineView({
<th className="px-3 py-2 font-medium">Enabled</th> collapsedGroups: open
<th className="w-12 px-3 py-2" /> ? routineViewState.collapsedGroups.filter((item) => item !== group.key)
</tr> : [...routineViewState.collapsedGroups, group.key],
</thead> });
<tbody> }}
{(routines ?? []).map((routine) => { >
const enabled = routine.status === "active"; {group.label ? (
const isArchived = routine.status === "archived"; <div className="flex items-center gap-2 border-b border-border px-3 py-2">
const isStatusPending = statusMutationRoutineId === routine.id; <CollapsibleTrigger className="flex items-center gap-1.5">
return ( <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<tr <span className="text-sm font-semibold uppercase tracking-wide">
key={routine.id} {group.label}
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer" </span>
onClick={() => navigate(`/routines/${routine.id}`)} </CollapsibleTrigger>
> <span className="text-xs text-muted-foreground">
<td className="px-3 py-2.5"> {group.items.length}
<div className="min-w-[180px]"> </span>
<span className="font-medium"> </div>
{routine.title} ) : null}
</span> <CollapsibleContent>
{(isArchived || routine.status === "paused") && ( {group.items.map((routine) => (
<div className="mt-1 text-xs text-muted-foreground"> <RoutineListRow
{isArchived ? "archived" : "paused"} key={routine.id}
</div> routine={routine}
)} projectById={projectById}
</div> agentById={agentById}
</td> runningRoutineId={runningRoutineId}
<td className="px-3 py-2.5"> statusMutationRoutineId={statusMutationRoutineId}
{routine.projectId ? ( onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
<div className="flex items-center gap-2 text-sm text-muted-foreground"> onRunNow={handleRunNow}
<span onToggleEnabled={handleToggleEnabled}
className="shrink-0 h-3 w-3 rounded-sm" onToggleArchived={handleToggleArchived}
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }} />
/> ))}
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span> </CollapsibleContent>
</div> </Collapsible>
) : ( ))}
<span className="text-xs text-muted-foreground"></span> </div>
)} )}
</td> </div>
<td className="px-3 py-2.5"> ) : null}
{routine.assigneeAgentId ? (() => {
const agent = agentById.get(routine.assigneeAgentId);
return agent ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{agent.name}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
);
})() : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5 text-muted-foreground">
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
{routine.lastRun ? (
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
) : null}
</td>
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={enabled}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
disabled={isStatusPending || isArchived}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-foreground" : "bg-muted"
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
})
}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => handleRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: enabled ? "paused" : "active",
})
}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
})
}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<RoutineRunVariablesDialog <RoutineRunVariablesDialog
open={runDialogRoutine !== null} open={runDialogRoutine !== null}