mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
commit
e75960f284
38 changed files with 2306 additions and 621 deletions
19
packages/shared/src/execution-workspace-guards.ts
Normal file
19
packages/shared/src/execution-workspace-guards.ts
Normal 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.`;
|
||||
}
|
||||
|
|
@ -351,6 +351,11 @@ export {
|
|||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
} from "./types/feedback.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
} from "./execution-workspace-guards.js";
|
||||
|
||||
export {
|
||||
instanceGeneralSettingsSchema,
|
||||
patchInstanceGeneralSettingsSchema,
|
||||
|
|
@ -595,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
|
|||
export {
|
||||
AGENT_MENTION_SCHEME,
|
||||
PROJECT_MENTION_SCHEME,
|
||||
SKILL_MENTION_SCHEME,
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildSkillMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractSkillMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseSkillMentionHref,
|
||||
extractProjectMentionIds,
|
||||
type ParsedAgentMention,
|
||||
type ParsedProjectMention,
|
||||
type ParsedSkillMention,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildSkillMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
extractSkillMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseSkillMentionHref,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
describe("project-mentions", () => {
|
||||
|
|
@ -26,4 +29,13 @@ describe("project-mentions", () => {
|
|||
});
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export const PROJECT_MENTION_SCHEME = "project://";
|
||||
export const AGENT_MENTION_SCHEME = "agent://";
|
||||
export const SKILL_MENTION_SCHEME = "skill://";
|
||||
|
||||
const HEX_COLOR_RE = /^[0-9a-f]{6}$/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 PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\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 SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
|
||||
|
||||
export interface ParsedProjectMention {
|
||||
projectId: string;
|
||||
|
|
@ -19,6 +22,11 @@ export interface ParsedAgentMention {
|
|||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedSkillMention {
|
||||
skillId: string;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
function normalizeHexColor(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
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[] {
|
||||
if (!markdown) return [];
|
||||
const ids = new Set<string>();
|
||||
|
|
@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] {
|
|||
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 {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
issues,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
|
|
@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await db.delete(issues);
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
|
|
@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
|||
"git_branch_delete",
|
||||
]));
|
||||
}, 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
178
server/src/__tests__/issue-closed-workspace-routes.test.ts
Normal file
178
server/src/__tests__/issue-closed-workspace-routes.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
createDb,
|
||||
executionWorkspaces,
|
||||
heartbeatRuns,
|
||||
projectWorkspaces,
|
||||
projects,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
stopRuntimeServicesForExecutionWorkspace,
|
||||
type RealizedExecutionWorkspace,
|
||||
} from "../services/workspace-runtime.ts";
|
||||
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
|
||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||
|
|
@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
afterEach(async () => {
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(executionWorkspaces);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
|
|
@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
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 () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
||||
const companyId = randomUUID();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import {
|
|||
updateIssueWorkProductSchema,
|
||||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
type ExecutionWorkspace,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
|
@ -234,6 +237,23 @@ export function issueRoutes(
|
|||
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> {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await svc.getByIdentifier(rawId);
|
||||
|
|
@ -1083,6 +1103,13 @@ export function issueRoutes(
|
|||
...updateFields
|
||||
} = req.body;
|
||||
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 (!commentBody) {
|
||||
|
|
@ -1389,6 +1416,12 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||
if (closedExecutionWorkspace) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkoutRunId = requireAgentRunId(req, res);
|
||||
if (req.actor.type === "agent" && !checkoutRunId) return;
|
||||
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
|
||||
|
|
@ -1607,6 +1640,11 @@ export function issueRoutes(
|
|||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||
if (closedExecutionWorkspace) {
|
||||
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const reopenRequested = req.body.reopen === true;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ import type {
|
|||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import {
|
||||
listCurrentRuntimeServicesForExecutionWorkspaces,
|
||||
listCurrentRuntimeServicesForProjectWorkspaces,
|
||||
} from "./workspace-runtime-read-model.js";
|
||||
|
||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$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) {
|
||||
return {
|
||||
list: async (companyId: string, filters?: {
|
||||
|
|
@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
|
|||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.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) => {
|
||||
|
|
@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
|
|||
.where(eq(executionWorkspaces.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) return null;
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
|
||||
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
|
||||
return toExecutionWorkspace(
|
||||
row,
|
||||
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||
);
|
||||
},
|
||||
|
||||
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
||||
|
|
@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
if (!workspace) return null;
|
||||
|
||||
const runtimeServiceRows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
|
||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
||||
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
|
||||
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
|
||||
const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
|
||||
|
||||
const linkedIssues = await db
|
||||
.select({
|
||||
|
|
|
|||
|
|
@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
|
|||
const records = await listLocalServiceRegistryRecords(
|
||||
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) {
|
||||
|
|
@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
|||
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
||||
const commandLine = stdout.trim();
|
||||
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 {
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
type ProjectWorkspace,
|
||||
type WorkspaceRuntimeService,
|
||||
} 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 { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||
|
|
@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
|||
.from(projectWorkspaces)
|
||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
rows[0]!.companyId,
|
||||
workspaceRows.map((workspace) => workspace.id),
|
||||
|
|
@ -541,7 +541,7 @@ export function projectService(db: Db) {
|
|||
.where(eq(projectWorkspaces.projectId, projectId))
|
||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||
if (rows.length === 0) return [];
|
||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
||||
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||
db,
|
||||
rows[0]!.companyId,
|
||||
rows.map((workspace) => workspace.id),
|
||||
|
|
|
|||
96
server/src/services/workspace-runtime-read-model.ts
Normal file
96
server/src/services/workspace-runtime-read-model.ts
Normal 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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
@ -1081,6 +1081,16 @@ async function waitForReadiness(input: {
|
|||
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 {
|
||||
return {
|
||||
id: record.id,
|
||||
|
|
@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
|||
profileKind: "workspace-runtime",
|
||||
});
|
||||
if (adoptedRecord) {
|
||||
const record: RuntimeServiceRecord = {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
serviceName: row.serviceName,
|
||||
status: "running",
|
||||
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
||||
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
port: adoptedRecord.port ?? row.port ?? null,
|
||||
url: adoptedRecord.url ?? row.url ?? null,
|
||||
provider: "local_process",
|
||||
providerRef: String(adoptedRecord.pid),
|
||||
ownerAgentId: row.ownerAgentId ?? null,
|
||||
startedByRunId: row.startedByRunId ?? null,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
stoppedAt: null,
|
||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||
healthStatus: "healthy",
|
||||
reused: true,
|
||||
db,
|
||||
child: null,
|
||||
leaseRunIds: new Set(),
|
||||
idleTimer: null,
|
||||
envFingerprint: row.reuseKey ?? "",
|
||||
serviceKey: adoptedRecord.serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||
};
|
||||
registerRuntimeService(db, record);
|
||||
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
||||
runtimeServiceId: row.id,
|
||||
lastSeenAt: record.lastUsedAt,
|
||||
});
|
||||
await persistRuntimeServiceRecord(db, record);
|
||||
adopted += 1;
|
||||
continue;
|
||||
const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
|
||||
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
|
||||
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
||||
} else {
|
||||
const record: RuntimeServiceRecord = {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
projectId: row.projectId ?? null,
|
||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||
issueId: row.issueId ?? null,
|
||||
serviceName: row.serviceName,
|
||||
status: "running",
|
||||
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
||||
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
||||
scopeId: row.scopeId ?? null,
|
||||
reuseKey: row.reuseKey ?? null,
|
||||
command: row.command ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
port: adoptedRecord.port ?? row.port ?? null,
|
||||
url: adoptedRecord.url ?? row.url ?? null,
|
||||
provider: "local_process",
|
||||
providerRef: String(adoptedRecord.pid),
|
||||
ownerAgentId: row.ownerAgentId ?? null,
|
||||
startedByRunId: row.startedByRunId ?? null,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
stoppedAt: null,
|
||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||
healthStatus: "healthy",
|
||||
reused: true,
|
||||
db,
|
||||
child: null,
|
||||
leaseRunIds: new Set(),
|
||||
idleTimer: null,
|
||||
envFingerprint: row.reuseKey ?? "",
|
||||
serviceKey: adoptedRecord.serviceKey,
|
||||
profileKind: "workspace-runtime",
|
||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||
};
|
||||
registerRuntimeService(db, record);
|
||||
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
||||
runtimeServiceId: row.id,
|
||||
lastSeenAt: record.lastUsedAt,
|
||||
});
|
||||
await persistRuntimeServiceRecord(db, record);
|
||||
adopted += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
|
|
|||
|
|
@ -120,4 +120,28 @@ describe("CommentThread", () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ interface CommentThreadProps {
|
|||
mentions?: MentionOption[];
|
||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||
interruptingQueuedRunId?: string | null;
|
||||
composerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
|
@ -569,6 +570,7 @@ export function CommentThread({
|
|||
mentions: providedMentions,
|
||||
onInterruptQueued,
|
||||
interruptingQueuedRunId = null,
|
||||
composerDisabledReason = null,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
|
|
@ -796,90 +798,96 @@ export function CommentThread({
|
|||
</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>
|
||||
{composerDisabledReason ? (
|
||||
<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">
|
||||
{composerDisabledReason}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
<Dialog open={open} 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>
|
||||
<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
|
||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{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">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<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 shrink-0" />
|
||||
Checking whether this workspace is safe to close...
|
||||
</div>
|
||||
) : 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."}
|
||||
</div>
|
||||
) : readiness ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
||||
<div className="min-w-0 space-y-3 sm:space-y-4">
|
||||
<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">
|
||||
{readiness.state === "blocked"
|
||||
? "Close is blocked"
|
||||
|
|
@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{blockingIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
{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">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
|
|
@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.blockingReasons.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{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}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.warnings.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Warnings</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
|
||||
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||
{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}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.git ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Git status</h3>
|
||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
|
||||
<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 grid-cols-2 gap-2">
|
||||
<div className="min-w-0">
|
||||
<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 className="min-w-0">
|
||||
<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 className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||
|
|
@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{otherLinkedIssues.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
{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">
|
||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||
{issue.identifier ?? issue.id} · {issue.title}
|
||||
|
|
@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
|
||||
{readiness.runtimeServices.length > 0 ? (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
{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">
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||
|
|
@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
) : null}
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
{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="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||
{action.command ? (
|
||||
|
|
@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
|
|||
</section>
|
||||
|
||||
{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
|
||||
workspace status if it succeeds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
{readiness.git.workspacePath ? (
|
||||
<>
|
||||
|
|
|
|||
84
ui/src/components/InlineEditor.test.tsx
Normal file
84
ui/src/components/InlineEditor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,23 @@ const pad = "px-1 -mx-1";
|
|||
const markdownPad = "px-1";
|
||||
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({
|
||||
value,
|
||||
onSave,
|
||||
|
|
@ -35,6 +52,7 @@ export function InlineEditor({
|
|||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
||||
const {
|
||||
state: autosaveState,
|
||||
markDirty,
|
||||
|
|
@ -52,6 +70,10 @@ export function InlineEditor({
|
|||
if (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]);
|
||||
|
||||
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) {
|
||||
if (e.key === "Enter" && !multiline) {
|
||||
e.preventDefault();
|
||||
|
|
@ -146,20 +192,13 @@ export function InlineEditor({
|
|||
"rounded transition-colors",
|
||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||
)}
|
||||
onFocusCapture={() => setMultilineFocused(true)}
|
||||
onFocusCapture={() => {
|
||||
cancelPendingBlurCommit();
|
||||
setMultilineFocused(true);
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
if (autosaveDebounceRef.current) {
|
||||
clearTimeout(autosaveDebounceRef.current);
|
||||
}
|
||||
setMultilineFocused(false);
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed || trimmed === value) {
|
||||
reset();
|
||||
void commit();
|
||||
return;
|
||||
}
|
||||
void runSave(() => commit());
|
||||
scheduleBlurCommit(event.currentTarget);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 { MarkdownBody } from "./MarkdownBody";
|
||||
|
||||
|
|
@ -30,11 +30,11 @@ describe("MarkdownBody", () => {
|
|||
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(
|
||||
<ThemeProvider>
|
||||
<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>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
|
@ -45,5 +45,7 @@ describe("MarkdownBody", () => {
|
|||
expect(html).toContain('href="/projects/project-456"');
|
||||
expect(html).toContain('data-mention-kind="project"');
|
||||
expect(html).toContain("--paperclip-mention-project-color:#336699");
|
||||
expect(html).toContain('href="/skills/skill-789"');
|
||||
expect(html).toContain('data-mention-kind="skill"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
|||
if (parsed) {
|
||||
const targetHref = parsed.kind === "project"
|
||||
? `/projects/${parsed.projectId}`
|
||||
: `/agents/${parsed.agentId}`;
|
||||
: parsed.kind === "skill"
|
||||
? `/skills/${parsed.skillId}`
|
||||
: `/agents/${parsed.agentId}`;
|
||||
return (
|
||||
<a
|
||||
href={targetHref}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
|
||||
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyReset: false,
|
||||
|
|
@ -162,4 +162,28 @@ describe("MarkdownEditor", () => {
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
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 { pasteNormalizationPlugin } from "../lib/paste-normalization";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
|
||||
|
||||
/* ---- Mention types ---- */
|
||||
|
||||
|
|
@ -84,6 +86,8 @@ function isSafeMarkdownLinkUrl(url: string): boolean {
|
|||
/* ---- Mention detection helpers ---- */
|
||||
|
||||
interface MentionState {
|
||||
trigger: "mention" | "skill";
|
||||
marker: "@" | "/";
|
||||
query: string;
|
||||
top: number;
|
||||
left: number;
|
||||
|
|
@ -95,6 +99,19 @@ interface MentionState {
|
|||
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> = {
|
||||
txt: "Text",
|
||||
md: "Markdown",
|
||||
|
|
@ -135,13 +152,17 @@ function detectMention(container: HTMLElement): MentionState | null {
|
|||
const text = textNode.textContent ?? "";
|
||||
const offset = range.startOffset;
|
||||
|
||||
// Walk backwards from cursor to find @
|
||||
// Walk backwards from cursor to find an autocomplete trigger.
|
||||
let atPos = -1;
|
||||
let trigger: MentionState["trigger"] | null = null;
|
||||
let marker: MentionState["marker"] | null = null;
|
||||
for (let i = offset - 1; i >= 0; i--) {
|
||||
const ch = text[i];
|
||||
if (ch === "@") {
|
||||
if (ch === "@" || ch === "/") {
|
||||
if (i === 0 || /\s/.test(text[i - 1])) {
|
||||
atPos = i;
|
||||
trigger = ch === "@" ? "mention" : "skill";
|
||||
marker = ch;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -160,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null {
|
|||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
trigger: trigger ?? "mention",
|
||||
marker: marker ?? "@",
|
||||
query,
|
||||
top: rect.bottom - containerRect.top,
|
||||
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 {
|
||||
if (!node || !container.contains(node)) return false;
|
||||
const el = node.nodeType === Node.ELEMENT_NODE
|
||||
|
|
@ -197,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string {
|
|||
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
|
||||
}
|
||||
|
||||
/** Replace `@<query>` in the markdown string with the selected mention token. */
|
||||
function applyMention(markdown: string, query: string, option: MentionOption): string {
|
||||
const search = `@${query}`;
|
||||
const replacement = mentionMarkdown(option);
|
||||
function skillMarkdown(option: SkillCommandOption): string {
|
||||
return `[/${option.slug}](${option.href}) `;
|
||||
}
|
||||
|
||||
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);
|
||||
if (idx === -1) return markdown;
|
||||
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
|
||||
|
|
@ -220,6 +285,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
mentions,
|
||||
onSubmit,
|
||||
}: MarkdownEditorProps, forwardedRef) {
|
||||
const { slashCommands } = useEditorAutocomplete();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const valueRef = useRef(value);
|
||||
|
|
@ -244,7 +310,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
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 map = new Map<string, MentionOption>();
|
||||
for (const mention of mentions ?? []) {
|
||||
|
|
@ -259,11 +328,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
return map;
|
||||
}, [mentions]);
|
||||
|
||||
const filteredMentions = useMemo(() => {
|
||||
if (!mentionState || !mentions) return [];
|
||||
const q = mentionState.query.toLowerCase();
|
||||
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
||||
if (!mentionState) return [];
|
||||
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);
|
||||
}, [mentionState?.query, mentions]);
|
||||
}, [mentionState, mentions, slashCommands]);
|
||||
|
||||
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||
ref.current = instance;
|
||||
|
|
@ -375,6 +453,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
continue;
|
||||
}
|
||||
|
||||
if (parsed.kind === "skill") {
|
||||
applyMentionChipDecoration(link, parsed);
|
||||
continue;
|
||||
}
|
||||
|
||||
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
|
||||
applyMentionChipDecoration(link, {
|
||||
...parsed,
|
||||
|
|
@ -385,12 +468,30 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
|
||||
// Mention detection: listen for selection changes and input events
|
||||
const checkMention = useCallback(() => {
|
||||
if (!mentions || mentions.length === 0 || !containerRef.current) {
|
||||
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
|
||||
mentionStateRef.current = null;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (result) {
|
||||
setMentionState(result);
|
||||
|
|
@ -398,10 +499,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
} else {
|
||||
setMentionState(null);
|
||||
}
|
||||
}, [mentions]);
|
||||
}, [mentions, slashCommands.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mentions || mentions.length === 0) return;
|
||||
if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return;
|
||||
|
||||
const el = containerRef.current;
|
||||
// Listen for input events on the container so mention detection
|
||||
|
|
@ -414,7 +515,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
document.removeEventListener("selectionchange", checkMention);
|
||||
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(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
|
|
@ -432,13 +552,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
}, [decorateProjectMentions, value]);
|
||||
|
||||
const selectMention = useCallback(
|
||||
(option: MentionOption) => {
|
||||
(option: AutocompleteOption) => {
|
||||
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||
// update state between the last render and this callback firing).
|
||||
const state = mentionStateRef.current;
|
||||
if (!state) return;
|
||||
const current = latestValueRef.current;
|
||||
const next = applyMention(current, state.query, option);
|
||||
const next = applyMention(current, state, option);
|
||||
if (next !== current) {
|
||||
latestValueRef.current = next;
|
||||
echoIgnoreMarkdownRef.current = next;
|
||||
|
|
@ -453,17 +573,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
decorateProjectMentions();
|
||||
editable.focus();
|
||||
|
||||
const mentionHref = option.kind === "project" && option.projectId
|
||||
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
|
||||
: buildAgentMentionHref(
|
||||
option.agentId ?? option.id.replace(/^agent:/, ""),
|
||||
option.agentIcon ?? null,
|
||||
);
|
||||
const mentionHref = option.kind === "skill"
|
||||
? option.href
|
||||
: option.kind === "project" && option.projectId
|
||||
? buildProjectMentionHref(option.projectId, option.projectColor ?? 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"))
|
||||
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
||||
.filter((link) => {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
return href === mentionHref && link.textContent === `@${option.name}`;
|
||||
return href === mentionHref && link.textContent === expectedLabel;
|
||||
});
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
const target = matchingMentions.sort((a, b) => {
|
||||
|
|
@ -526,6 +649,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
ref.current.insertMarkdown(normalizeMarkdown(rawText));
|
||||
}, []);
|
||||
|
||||
const mentionMenuPosition = mentionState
|
||||
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -645,25 +772,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
createPortal(
|
||||
<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"
|
||||
style={{
|
||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
||||
}}
|
||||
style={mentionMenuPosition ?? undefined}
|
||||
>
|
||||
{filteredMentions.map((option, i) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
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
|
||||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
<span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span>
|
||||
{option.kind === "project" && option.projectId && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Project
|
||||
</span>
|
||||
)}
|
||||
{option.kind === "skill" && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Skill
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
DialogContent,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -1208,21 +1209,10 @@ export function NewIssueDialog() {
|
|||
{assigneeAdapterType === "claude_local" && (
|
||||
<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>
|
||||
<button
|
||||
data-slot="toggle"
|
||||
className={cn(
|
||||
"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>
|
||||
<ToggleSwitch
|
||||
checked={assigneeChrome}
|
||||
onCheckedChange={() => setAssigneeChrome((value) => !value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
|
||||
|
|
@ -886,26 +887,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
</div>
|
||||
</div>
|
||||
{onUpdate || onFieldUpdate ? (
|
||||
<button
|
||||
data-slot="toggle"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
<ToggleSwitch
|
||||
checked={executionWorkspacesEnabled}
|
||||
onCheckedChange={() =>
|
||||
commitField(
|
||||
"execution_workspace_enabled",
|
||||
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">
|
||||
{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.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-slot="toggle"
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
||||
)}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
<ToggleSwitch
|
||||
checked={executionWorkspaceDefaultMode === "isolated_workspace"}
|
||||
onCheckedChange={() =>
|
||||
commitField(
|
||||
"execution_workspace_default_mode",
|
||||
updateExecutionWorkspacePolicy({
|
||||
|
|
@ -942,16 +926,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
|||
: "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 className="border-t border-border/60 pt-2">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -111,23 +112,11 @@ export function ToggleField({
|
|||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{hint && <HintIcon text={hint} />}
|
||||
</div>
|
||||
<button
|
||||
data-slot="toggle"
|
||||
<ToggleSwitch
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,21 +151,10 @@ export function ToggleWithNumber({
|
|||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{hint && <HintIcon text={hint} />}
|
||||
</div>
|
||||
<button
|
||||
data-slot="toggle"
|
||||
className={cn(
|
||||
"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>
|
||||
<ToggleSwitch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
/>
|
||||
</div>
|
||||
{showNumber && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
|
|
|
|||
59
ui/src/components/ui/toggle-switch.tsx
Normal file
59
ui/src/components/ui/toggle-switch.tsx
Normal 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";
|
||||
61
ui/src/context/EditorAutocompleteContext.tsx
Normal file
61
ui/src/context/EditorAutocompleteContext.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
|
||||
import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared";
|
||||
import { getAgentIcon } from "./agent-icons";
|
||||
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
||||
|
||||
|
|
@ -13,6 +13,11 @@ export type ParsedMentionChip =
|
|||
kind: "project";
|
||||
projectId: string;
|
||||
color: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "skill";
|
||||
skillId: string;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
|
|||
"paperclip-mention-chip",
|
||||
"paperclip-mention-chip--agent",
|
||||
"paperclip-mention-chip--project",
|
||||
"paperclip-mention-chip--skill",
|
||||
"paperclip-project-mention-chip",
|
||||
);
|
||||
element.removeAttribute("contenteditable");
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
|||
import { PanelProvider } from "./context/PanelContext";
|
||||
import { SidebarProvider } from "./context/SidebarContext";
|
||||
import { DialogProvider } from "./context/DialogContext";
|
||||
import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
|
@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render(
|
|||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<CompanyProvider>
|
||||
<ToastProvider>
|
||||
<LiveUpdatesProvider>
|
||||
<TooltipProvider>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider>
|
||||
<PanelProvider>
|
||||
<PluginLauncherProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</PluginLauncherProvider>
|
||||
</PanelProvider>
|
||||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
</TooltipProvider>
|
||||
</LiveUpdatesProvider>
|
||||
</ToastProvider>
|
||||
<EditorAutocompleteProvider>
|
||||
<ToastProvider>
|
||||
<LiveUpdatesProvider>
|
||||
<TooltipProvider>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider>
|
||||
<PanelProvider>
|
||||
<PluginLauncherProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</PluginLauncherProvider>
|
||||
</PanelProvider>
|
||||
</SidebarProvider>
|
||||
</BreadcrumbProvider>
|
||||
</TooltipProvider>
|
||||
</LiveUpdatesProvider>
|
||||
</ToastProvider>
|
||||
</EditorAutocompleteProvider>
|
||||
</CompanyProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||
|
|
@ -1627,30 +1628,16 @@ function ConfigurationTab({
|
|||
Lets this agent create or hire agents and implicitly assign tasks.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
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={() =>
|
||||
<ToggleSwitch
|
||||
checked={canCreateAgents}
|
||||
onCheckedChange={() =>
|
||||
updatePermissions.mutate({
|
||||
canCreateAgents: !canCreateAgents,
|
||||
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
|
||||
})
|
||||
}
|
||||
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 className="flex items-center justify-between gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -1659,30 +1646,16 @@ function ConfigurationTab({
|
|||
{taskAssignHint}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
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={() =>
|
||||
<ToggleSwitch
|
||||
checked={canAssignTasks}
|
||||
onCheckedChange={() =>
|
||||
updatePermissions.mutate({
|
||||
canCreateAgents,
|
||||
canAssignTasks: !canAssignTasks,
|
||||
})
|
||||
}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ function readText(value: string | null | undefined) {
|
|||
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) {
|
||||
if (!value || Object.keys(value).length === 0) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
|
|
@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
||||
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { FlaskConical } from "lucide-react";
|
|||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
|
||||
export function InstanceExperimentalSettings() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
@ -82,24 +82,12 @@ export function InstanceExperimentalSettings() {
|
|||
and existing issue runs.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toggle"
|
||||
aria-label="Toggle isolated workspaces experimental setting"
|
||||
<ToggleSwitch
|
||||
checked={enableIsolatedWorkspaces}
|
||||
onCheckedChange={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
|
||||
disabled={toggleMutation.isPending}
|
||||
className={cn(
|
||||
"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>
|
||||
aria-label="Toggle isolated workspaces experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -112,26 +100,12 @@ export function InstanceExperimentalSettings() {
|
|||
automatically when backend changes or migrations make the current boot stale.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toggle"
|
||||
aria-label="Toggle guarded dev-server auto-restart"
|
||||
<ToggleSwitch
|
||||
checked={autoRestartDevServerWhenIdle}
|
||||
onCheckedChange={() => toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })}
|
||||
disabled={toggleMutation.isPending}
|
||||
className={cn(
|
||||
"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>
|
||||
aria-label="Toggle guarded dev-server auto-restart"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { Button } from "../components/ui/button";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
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 [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(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Instance Settings" },
|
||||
|
|
@ -83,28 +96,12 @@ export function InstanceGeneralSettings() {
|
|||
default.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toggle"
|
||||
aria-label="Toggle username log censoring"
|
||||
<ToggleSwitch
|
||||
checked={censorUsernameInLogs}
|
||||
onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
className={cn(
|
||||
"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>
|
||||
aria-label="Toggle username log censoring"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -117,24 +114,12 @@ export function InstanceGeneralSettings() {
|
|||
toggling panels. This is off by default.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toggle"
|
||||
aria-label="Toggle keyboard shortcuts"
|
||||
<ToggleSwitch
|
||||
checked={keyboardShortcuts}
|
||||
onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
className={cn(
|
||||
"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>
|
||||
aria-label="Toggle keyboard shortcuts"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -213,6 +198,26 @@ export function InstanceGeneralSettings() {
|
|||
</p>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,8 +71,16 @@ import {
|
|||
SlidersHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
||||
import {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
type Issue,
|
||||
type IssueAttachment,
|
||||
type IssueComment,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
|
|
@ -306,6 +314,12 @@ export function IssueDetail() {
|
|||
enabled: !!issueId,
|
||||
});
|
||||
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({
|
||||
queryKey: queryKeys.issues.comments(issueId!),
|
||||
|
|
@ -1522,6 +1536,7 @@ export function IssueDetail() {
|
|||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||
composerDisabledReason={commentComposerDisabledReason}
|
||||
onVote={async (commentId, vote, options) => {
|
||||
await feedbackVoteMutation.mutateAsync({
|
||||
targetType: "issue_comment",
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ function readText(value: string | null | undefined) {
|
|||
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) {
|
||||
if (!value || Object.keys(value).length === 0) return "";
|
||||
return JSON.stringify(value, null, 2);
|
||||
|
|
@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
||||
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||
>
|
||||
Stop
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
|
|
@ -710,24 +711,13 @@ export function RoutineDetail() {
|
|||
}}
|
||||
disabled={runRoutine.isPending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
data-slot="toggle"
|
||||
aria-checked={automationEnabled}
|
||||
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
||||
<ToggleSwitch
|
||||
size="lg"
|
||||
checked={automationEnabled}
|
||||
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
|
||||
disabled={automationToggleDisabled}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
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>
|
||||
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
||||
/>
|
||||
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
||||
{automationLabel}
|
||||
</span>
|
||||
|
|
|
|||
367
ui/src/pages/Routines.test.tsx
Normal file
367
ui/src/pages/Routines.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 { useNavigate } from "@/lib/router";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
|
||||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||
|
|
@ -33,6 +40,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -40,6 +48,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
||||
|
||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||
|
|
@ -70,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
|||
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() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { pushToast } = useToast();
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
|
@ -85,6 +286,7 @@ export function Routines() {
|
|||
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||
const [composerOpen, setComposerOpen] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines";
|
||||
const [draft, setDraft] = useState<{
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
@ -104,11 +306,19 @@ export function Routines() {
|
|||
catchUpPolicy: "skip_missed",
|
||||
variables: [],
|
||||
});
|
||||
const routineViewStateKey = selectedCompanyId
|
||||
? `paperclip:routines-view:${selectedCompanyId}`
|
||||
: "paperclip:routines-view";
|
||||
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Routines" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
useEffect(() => {
|
||||
setRoutineViewState(getRoutineViewState(routineViewStateKey));
|
||||
}, [routineViewStateKey]);
|
||||
|
||||
const { data: routines, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
||||
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||
|
|
@ -129,6 +339,17 @@ export function Routines() {
|
|||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
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(() => {
|
||||
autoResizeTextarea(titleInputRef.current);
|
||||
|
|
@ -162,6 +383,13 @@ export function Routines() {
|
|||
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({
|
||||
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])),
|
||||
[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 currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? 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) {
|
||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||
const needsConfiguration = routineRunNeedsConfiguration({
|
||||
|
|
@ -267,6 +530,20 @@ export function Routines() {
|
|||
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) {
|
||||
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||
}
|
||||
|
|
@ -293,6 +570,68 @@ export function Routines() {
|
|||
</Button>
|
||||
</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
|
||||
open={composerOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -560,165 +899,64 @@ export function Routines() {
|
|||
</Card>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
{(routines ?? []).length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground border-b border-border">
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Project</th>
|
||||
<th className="px-3 py-2 font-medium">Agent</th>
|
||||
<th className="px-3 py-2 font-medium">Last run</th>
|
||||
<th className="px-3 py-2 font-medium">Enabled</th>
|
||||
<th className="w-12 px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(routines ?? []).map((routine) => {
|
||||
const enabled = routine.status === "active";
|
||||
const isArchived = routine.status === "archived";
|
||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||
return (
|
||||
<tr
|
||||
key={routine.id}
|
||||
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
|
||||
onClick={() => navigate(`/routines/${routine.id}`)}
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="min-w-[180px]">
|
||||
<span className="font-medium">
|
||||
{routine.title}
|
||||
</span>
|
||||
{(isArchived || routine.status === "paused") && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{isArchived ? "archived" : "paused"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{routine.projectId ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span
|
||||
className="shrink-0 h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }}
|
||||
/>
|
||||
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{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>
|
||||
{activeTab === "routines" ? (
|
||||
<div>
|
||||
{(routines ?? []).length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border">
|
||||
{routineGroups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={!routineViewState.collapsedGroups.includes(group.key)}
|
||||
onOpenChange={(open) => {
|
||||
updateRoutineView({
|
||||
collapsedGroups: open
|
||||
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
|
||||
: [...routineViewState.collapsedGroups, group.key],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{group.label ? (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||
{group.label}
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<CollapsibleContent>
|
||||
{group.items.map((routine) => (
|
||||
<RoutineListRow
|
||||
key={routine.id}
|
||||
routine={routine}
|
||||
projectById={projectById}
|
||||
agentById={agentById}
|
||||
runningRoutineId={runningRoutineId}
|
||||
statusMutationRoutineId={statusMutationRoutineId}
|
||||
onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
|
||||
onRunNow={handleRunNow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RoutineRunVariablesDialog
|
||||
open={runDialogRoutine !== null}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue