mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10: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,
|
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||||
} from "./types/feedback.js";
|
} from "./types/feedback.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getClosedIsolatedExecutionWorkspaceMessage,
|
||||||
|
isClosedIsolatedExecutionWorkspace,
|
||||||
|
} from "./execution-workspace-guards.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
instanceGeneralSettingsSchema,
|
instanceGeneralSettingsSchema,
|
||||||
patchInstanceGeneralSettingsSchema,
|
patchInstanceGeneralSettingsSchema,
|
||||||
|
|
@ -595,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
|
||||||
export {
|
export {
|
||||||
AGENT_MENTION_SCHEME,
|
AGENT_MENTION_SCHEME,
|
||||||
PROJECT_MENTION_SCHEME,
|
PROJECT_MENTION_SCHEME,
|
||||||
|
SKILL_MENTION_SCHEME,
|
||||||
buildAgentMentionHref,
|
buildAgentMentionHref,
|
||||||
buildProjectMentionHref,
|
buildProjectMentionHref,
|
||||||
|
buildSkillMentionHref,
|
||||||
extractAgentMentionIds,
|
extractAgentMentionIds,
|
||||||
|
extractSkillMentionIds,
|
||||||
parseAgentMentionHref,
|
parseAgentMentionHref,
|
||||||
parseProjectMentionHref,
|
parseProjectMentionHref,
|
||||||
|
parseSkillMentionHref,
|
||||||
extractProjectMentionIds,
|
extractProjectMentionIds,
|
||||||
type ParsedAgentMention,
|
type ParsedAgentMention,
|
||||||
type ParsedProjectMention,
|
type ParsedProjectMention,
|
||||||
|
type ParsedSkillMention,
|
||||||
} from "./project-mentions.js";
|
} from "./project-mentions.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildAgentMentionHref,
|
buildAgentMentionHref,
|
||||||
buildProjectMentionHref,
|
buildProjectMentionHref,
|
||||||
|
buildSkillMentionHref,
|
||||||
extractAgentMentionIds,
|
extractAgentMentionIds,
|
||||||
extractProjectMentionIds,
|
extractProjectMentionIds,
|
||||||
|
extractSkillMentionIds,
|
||||||
parseAgentMentionHref,
|
parseAgentMentionHref,
|
||||||
parseProjectMentionHref,
|
parseProjectMentionHref,
|
||||||
|
parseSkillMentionHref,
|
||||||
} from "./project-mentions.js";
|
} from "./project-mentions.js";
|
||||||
|
|
||||||
describe("project-mentions", () => {
|
describe("project-mentions", () => {
|
||||||
|
|
@ -26,4 +29,13 @@ describe("project-mentions", () => {
|
||||||
});
|
});
|
||||||
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
|
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips skill mentions with slug metadata", () => {
|
||||||
|
const href = buildSkillMentionHref("skill-123", "release-changelog");
|
||||||
|
expect(parseSkillMentionHref(href)).toEqual({
|
||||||
|
skillId: "skill-123",
|
||||||
|
slug: "release-changelog",
|
||||||
|
});
|
||||||
|
expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const PROJECT_MENTION_SCHEME = "project://";
|
export const PROJECT_MENTION_SCHEME = "project://";
|
||||||
export const AGENT_MENTION_SCHEME = "agent://";
|
export const AGENT_MENTION_SCHEME = "agent://";
|
||||||
|
export const SKILL_MENTION_SCHEME = "skill://";
|
||||||
|
|
||||||
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
|
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
|
||||||
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
|
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
|
||||||
|
|
@ -7,7 +8,9 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
|
||||||
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
|
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
|
||||||
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
|
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
|
||||||
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
|
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
|
||||||
|
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
|
||||||
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
|
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
|
||||||
|
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
|
||||||
|
|
||||||
export interface ParsedProjectMention {
|
export interface ParsedProjectMention {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -19,6 +22,11 @@ export interface ParsedAgentMention {
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedSkillMention {
|
||||||
|
skillId: string;
|
||||||
|
slug: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeHexColor(input: string | null | undefined): string | null {
|
function normalizeHexColor(input: string | null | undefined): string | null {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
|
|
@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildSkillMentionHref(skillId: string, slug?: string | null): string {
|
||||||
|
const trimmedSkillId = skillId.trim();
|
||||||
|
const normalizedSlug = normalizeSkillSlug(slug ?? null);
|
||||||
|
if (!normalizedSlug) {
|
||||||
|
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`;
|
||||||
|
}
|
||||||
|
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSkillMentionHref(href: string): ParsedSkillMention | null {
|
||||||
|
if (!href.startsWith(SKILL_MENTION_SCHEME)) return null;
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(href);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== "skill:") return null;
|
||||||
|
|
||||||
|
const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
|
||||||
|
if (!skillId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId,
|
||||||
|
slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function extractProjectMentionIds(markdown: string): string[] {
|
export function extractProjectMentionIds(markdown: string): string[] {
|
||||||
if (!markdown) return [];
|
if (!markdown) return [];
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] {
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractSkillMentionIds(markdown: string): string[] {
|
||||||
|
if (!markdown) return [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const re = new RegExp(SKILL_MENTION_LINK_RE);
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(markdown)) !== null) {
|
||||||
|
const parsed = parseSkillMentionHref(match[1]);
|
||||||
|
if (parsed) ids.add(parsed.skillId);
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAgentIcon(input: string | null | undefined): string | null {
|
function normalizeAgentIcon(input: string | null | undefined): string | null {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
const trimmed = input.trim().toLowerCase();
|
const trimmed = input.trim().toLowerCase();
|
||||||
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
|
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSkillSlug(input: string | null | undefined): string | null {
|
||||||
|
if (!input) return null;
|
||||||
|
const trimmed = input.trim().toLowerCase();
|
||||||
|
if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
issues,
|
issues,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
|
workspaceRuntimeServices,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
getEmbeddedPostgresTestSupport,
|
getEmbeddedPostgresTestSupport,
|
||||||
|
|
@ -133,6 +134,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(issues);
|
await db.delete(issues);
|
||||||
|
await db.delete(workspaceRuntimeServices);
|
||||||
await db.delete(executionWorkspaces);
|
await db.delete(executionWorkspaces);
|
||||||
await db.delete(projectWorkspaces);
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
|
|
@ -322,4 +324,136 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => {
|
||||||
"git_branch_delete",
|
"git_branch_delete",
|
||||||
]));
|
]));
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("shows inherited shared project runtime services on shared execution workspaces without duplicating old history", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
const executionWorkspaceId = randomUUID();
|
||||||
|
const olderServiceId = randomUUID();
|
||||||
|
const currentServiceId = randomUUID();
|
||||||
|
const reuseKey = `project_workspace:${projectWorkspaceId}:paperclip-dev`;
|
||||||
|
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||||
|
const stoppedAt = new Date("2026-04-04T17:05:00.000Z");
|
||||||
|
const runningAt = new Date("2026-04-04T17:10:00.000Z");
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Workspaces",
|
||||||
|
status: "in_progress",
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary",
|
||||||
|
sourceType: "local_path",
|
||||||
|
isPrimary: true,
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
metadata: {
|
||||||
|
runtimeConfig: {
|
||||||
|
desiredState: "running",
|
||||||
|
workspaceRuntime: {
|
||||||
|
services: [{ name: "paperclip-dev", command: "pnpm dev" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.insert(executionWorkspaces).values({
|
||||||
|
id: executionWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
mode: "shared_workspace",
|
||||||
|
strategyType: "project_primary",
|
||||||
|
name: "Shared workspace",
|
||||||
|
status: "active",
|
||||||
|
providerType: "local_fs",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
});
|
||||||
|
await db.insert(workspaceRuntimeServices).values([
|
||||||
|
{
|
||||||
|
id: olderServiceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issueId: null,
|
||||||
|
scopeType: "project_workspace",
|
||||||
|
scopeId: projectWorkspaceId,
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
status: "stopped",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
port: 49195,
|
||||||
|
url: "http://127.0.0.1:49195",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: "11111",
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: stoppedAt,
|
||||||
|
startedAt,
|
||||||
|
stoppedAt,
|
||||||
|
stopPolicy: { type: "manual" },
|
||||||
|
healthStatus: "unknown",
|
||||||
|
createdAt: startedAt,
|
||||||
|
updatedAt: stoppedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: currentServiceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issueId: null,
|
||||||
|
scopeType: "project_workspace",
|
||||||
|
scopeId: projectWorkspaceId,
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
port: 49222,
|
||||||
|
url: "http://127.0.0.1:49222",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: "22222",
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: runningAt,
|
||||||
|
startedAt: runningAt,
|
||||||
|
stoppedAt: null,
|
||||||
|
stopPolicy: { type: "manual" },
|
||||||
|
healthStatus: "healthy",
|
||||||
|
createdAt: runningAt,
|
||||||
|
updatedAt: runningAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const workspace = await svc.getById(executionWorkspaceId);
|
||||||
|
const listed = await svc.list(companyId, { projectId });
|
||||||
|
|
||||||
|
expect(workspace?.runtimeServices).toHaveLength(1);
|
||||||
|
expect(workspace?.runtimeServices?.[0]).toMatchObject({
|
||||||
|
id: currentServiceId,
|
||||||
|
status: "running",
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
url: "http://127.0.0.1:49222",
|
||||||
|
});
|
||||||
|
expect(listed[0]?.runtimeServices).toHaveLength(1);
|
||||||
|
expect(listed[0]?.runtimeServices?.[0]?.id).toBe(currentServiceId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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,
|
createDb,
|
||||||
executionWorkspaces,
|
executionWorkspaces,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
|
projectWorkspaces,
|
||||||
projects,
|
projects,
|
||||||
workspaceRuntimeServices,
|
workspaceRuntimeServices,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
|
|
@ -30,6 +31,7 @@ import {
|
||||||
stopRuntimeServicesForExecutionWorkspace,
|
stopRuntimeServicesForExecutionWorkspace,
|
||||||
type RealizedExecutionWorkspace,
|
type RealizedExecutionWorkspace,
|
||||||
} from "../services/workspace-runtime.ts";
|
} from "../services/workspace-runtime.ts";
|
||||||
|
import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts";
|
||||||
import { resolvePaperclipConfigPath } from "../paths.ts";
|
import { resolvePaperclipConfigPath } from "../paths.ts";
|
||||||
import type { WorkspaceOperation } from "@paperclipai/shared";
|
import type { WorkspaceOperation } from "@paperclipai/shared";
|
||||||
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts";
|
||||||
|
|
@ -1416,6 +1418,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await db.delete(workspaceRuntimeServices);
|
await db.delete(workspaceRuntimeServices);
|
||||||
await db.delete(executionWorkspaces);
|
await db.delete(executionWorkspaces);
|
||||||
|
await db.delete(projectWorkspaces);
|
||||||
await db.delete(projects);
|
await db.delete(projects);
|
||||||
await db.delete(heartbeatRuns);
|
await db.delete(heartbeatRuns);
|
||||||
await db.delete(agents);
|
await db.delete(agents);
|
||||||
|
|
@ -1530,6 +1533,96 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
||||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks persisted local services stopped when the registry pid is stale", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const runtimeServiceId = randomUUID();
|
||||||
|
const startedAt = new Date("2026-04-04T17:00:00.000Z");
|
||||||
|
const updatedAt = new Date("2026-04-04T17:10:00.000Z");
|
||||||
|
const projectId = randomUUID();
|
||||||
|
const projectWorkspaceId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
await db.insert(projects).values({
|
||||||
|
id: projectId,
|
||||||
|
companyId,
|
||||||
|
name: "Runtime reconcile test",
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
await db.insert(projectWorkspaces).values({
|
||||||
|
id: projectWorkspaceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
name: "Primary",
|
||||||
|
sourceType: "local_path",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await db.insert(workspaceRuntimeServices).values({
|
||||||
|
id: runtimeServiceId,
|
||||||
|
companyId,
|
||||||
|
projectId,
|
||||||
|
projectWorkspaceId,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
issueId: null,
|
||||||
|
scopeType: "project_workspace",
|
||||||
|
scopeId: projectWorkspaceId,
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
status: "running",
|
||||||
|
lifecycle: "shared",
|
||||||
|
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
port: 49195,
|
||||||
|
url: "http://127.0.0.1:49195",
|
||||||
|
provider: "local_process",
|
||||||
|
providerRef: "999999",
|
||||||
|
ownerAgentId: null,
|
||||||
|
startedByRunId: null,
|
||||||
|
lastUsedAt: updatedAt,
|
||||||
|
startedAt,
|
||||||
|
stoppedAt: null,
|
||||||
|
stopPolicy: { type: "manual" },
|
||||||
|
healthStatus: "healthy",
|
||||||
|
createdAt: startedAt,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
await writeLocalServiceRegistryRecord({
|
||||||
|
version: 1,
|
||||||
|
serviceKey: "workspace-runtime-paperclip-dev-stale",
|
||||||
|
profileKind: "workspace-runtime",
|
||||||
|
serviceName: "paperclip-dev",
|
||||||
|
command: "pnpm dev",
|
||||||
|
cwd: "/tmp/paperclip-primary",
|
||||||
|
envFingerprint: "fingerprint",
|
||||||
|
port: 49195,
|
||||||
|
url: "http://127.0.0.1:49195",
|
||||||
|
pid: 999999,
|
||||||
|
processGroupId: 999999,
|
||||||
|
provider: "local_process",
|
||||||
|
runtimeServiceId,
|
||||||
|
reuseKey: `project_workspace:${projectWorkspaceId}:paperclip-dev`,
|
||||||
|
startedAt: startedAt.toISOString(),
|
||||||
|
lastSeenAt: updatedAt.toISOString(),
|
||||||
|
metadata: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ reconciled: 1, adopted: 0, stopped: 1 });
|
||||||
|
const persisted = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRuntimeServices)
|
||||||
|
.where(eq(workspaceRuntimeServices.id, runtimeServiceId))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
expect(persisted?.status).toBe("stopped");
|
||||||
|
expect(persisted?.stoppedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("persists controlled execution workspace stops as stopped", async () => {
|
it("persists controlled execution workspace stops as stopped", async () => {
|
||||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-"));
|
||||||
const companyId = randomUUID();
|
const companyId = randomUUID();
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import {
|
||||||
updateIssueWorkProductSchema,
|
updateIssueWorkProductSchema,
|
||||||
upsertIssueDocumentSchema,
|
upsertIssueDocumentSchema,
|
||||||
updateIssueSchema,
|
updateIssueSchema,
|
||||||
|
getClosedIsolatedExecutionWorkspaceMessage,
|
||||||
|
isClosedIsolatedExecutionWorkspace,
|
||||||
|
type ExecutionWorkspace,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||||
import { getTelemetryClient } from "../telemetry.js";
|
import { getTelemetryClient } from "../telemetry.js";
|
||||||
|
|
@ -234,6 +237,23 @@ export function issueRoutes(
|
||||||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
||||||
|
if (!issue.executionWorkspaceId) return null;
|
||||||
|
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
||||||
|
if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null;
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondClosedIssueExecutionWorkspace(
|
||||||
|
res: Response,
|
||||||
|
workspace: Pick<ExecutionWorkspace, "closedAt" | "id" | "mode" | "name" | "status">,
|
||||||
|
) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: getClosedIsolatedExecutionWorkspaceMessage(workspace),
|
||||||
|
executionWorkspace: workspace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
||||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||||
const issue = await svc.getByIdentifier(rawId);
|
const issue = await svc.getByIdentifier(rawId);
|
||||||
|
|
@ -1083,6 +1103,13 @@ export function issueRoutes(
|
||||||
...updateFields
|
...updateFields
|
||||||
} = req.body;
|
} = req.body;
|
||||||
let interruptedRunId: string | null = null;
|
let interruptedRunId: string | null = null;
|
||||||
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
||||||
|
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
||||||
|
|
||||||
|
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
|
||||||
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (interruptRequested) {
|
if (interruptRequested) {
|
||||||
if (!commentBody) {
|
if (!commentBody) {
|
||||||
|
|
@ -1389,6 +1416,12 @@ export function issueRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||||
|
if (closedExecutionWorkspace) {
|
||||||
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const checkoutRunId = requireAgentRunId(req, res);
|
const checkoutRunId = requireAgentRunId(req, res);
|
||||||
if (req.actor.type === "agent" && !checkoutRunId) return;
|
if (req.actor.type === "agent" && !checkoutRunId) return;
|
||||||
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
|
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
|
||||||
|
|
@ -1607,6 +1640,11 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, issue.companyId);
|
assertCompanyAccess(req, issue.companyId);
|
||||||
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
if (!(await assertAgentRunCheckoutOwnership(req, res, issue))) return;
|
||||||
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
||||||
|
if (closedExecutionWorkspace) {
|
||||||
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const reopenRequested = req.body.reopen === true;
|
const reopenRequested = req.body.reopen === true;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import type {
|
||||||
WorkspaceRuntimeService,
|
WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
|
import {
|
||||||
|
listCurrentRuntimeServicesForExecutionWorkspaces,
|
||||||
|
listCurrentRuntimeServicesForProjectWorkspaces,
|
||||||
|
} from "./workspace-runtime-read-model.js";
|
||||||
|
|
||||||
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect;
|
||||||
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect;
|
||||||
|
|
@ -317,6 +321,41 @@ function toExecutionWorkspace(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usesInheritedProjectRuntimeServices(row: ExecutionWorkspaceRow) {
|
||||||
|
if (row.mode !== "shared_workspace" || !row.projectWorkspaceId) return false;
|
||||||
|
return !readExecutionWorkspaceConfig((row.metadata as Record<string, unknown> | null) ?? null)?.workspaceRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEffectiveRuntimeServicesByExecutionWorkspace(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
rows: ExecutionWorkspaceRow[],
|
||||||
|
) {
|
||||||
|
const executionRuntimeServices = await listCurrentRuntimeServicesForExecutionWorkspaces(
|
||||||
|
db,
|
||||||
|
companyId,
|
||||||
|
rows.map((row) => row.id),
|
||||||
|
);
|
||||||
|
const projectWorkspaceIds = rows
|
||||||
|
.filter((row) => usesInheritedProjectRuntimeServices(row))
|
||||||
|
.map((row) => row.projectWorkspaceId)
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
const projectRuntimeServices = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
|
db,
|
||||||
|
companyId,
|
||||||
|
[...new Set(projectWorkspaceIds)],
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
usesInheritedProjectRuntimeServices(row)
|
||||||
|
? (projectRuntimeServices.get(row.projectWorkspaceId!) ?? [])
|
||||||
|
: (executionRuntimeServices.get(row.id) ?? []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function executionWorkspaceService(db: Db) {
|
export function executionWorkspaceService(db: Db) {
|
||||||
return {
|
return {
|
||||||
list: async (companyId: string, filters?: {
|
list: async (companyId: string, filters?: {
|
||||||
|
|
@ -346,7 +385,13 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.from(executionWorkspaces)
|
.from(executionWorkspaces)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||||
return rows.map((row) => toExecutionWorkspace(row));
|
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, companyId, rows);
|
||||||
|
return rows.map((row) =>
|
||||||
|
toExecutionWorkspace(
|
||||||
|
row,
|
||||||
|
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getById: async (id: string) => {
|
getById: async (id: string) => {
|
||||||
|
|
@ -356,12 +401,11 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.where(eq(executionWorkspaces.id, id))
|
.where(eq(executionWorkspaces.id, id))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const runtimeServiceRows = await db
|
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]);
|
||||||
.select()
|
return toExecutionWorkspace(
|
||||||
.from(workspaceRuntimeServices)
|
row,
|
||||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id))
|
(runtimeServicesByWorkspaceId.get(row.id) ?? []).map(toRuntimeService),
|
||||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
);
|
||||||
return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
getCloseReadiness: async (id: string): Promise<ExecutionWorkspaceCloseReadiness | null> => {
|
||||||
|
|
@ -372,12 +416,8 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
|
|
||||||
const runtimeServiceRows = await db
|
const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, workspace.companyId, [workspace]);
|
||||||
.select()
|
const runtimeServices = (runtimeServicesByWorkspaceId.get(workspace.id) ?? []).map(toRuntimeService);
|
||||||
.from(workspaceRuntimeServices)
|
|
||||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id))
|
|
||||||
.orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt));
|
|
||||||
const runtimeServices = runtimeServiceRows.map(toRuntimeService);
|
|
||||||
|
|
||||||
const linkedIssues = await db
|
const linkedIssues = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,31 @@ export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: {
|
||||||
const records = await listLocalServiceRegistryRecords(
|
const records = await listLocalServiceRegistryRecords(
|
||||||
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
input.profileKind ? { profileKind: input.profileKind } : undefined,
|
||||||
);
|
);
|
||||||
return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null;
|
const record = records.find((entry) => entry.runtimeServiceId === input.runtimeServiceId) ?? null;
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
let candidate = record;
|
||||||
|
if (!isPidAlive(candidate.pid)) {
|
||||||
|
const ownerPid = candidate.port ? await readLocalServicePortOwner(candidate.port) : null;
|
||||||
|
if (!ownerPid) {
|
||||||
|
await removeLocalServiceRegistryRecord(candidate.serviceKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
candidate = {
|
||||||
|
...candidate,
|
||||||
|
pid: ownerPid,
|
||||||
|
processGroupId: candidate.processGroupId && isPidAlive(candidate.processGroupId) ? candidate.processGroupId : ownerPid,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await writeLocalServiceRegistryRecord(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isLikelyMatchingCommand(candidate))) {
|
||||||
|
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPidAlive(pid: number) {
|
export function isPidAlive(pid: number) {
|
||||||
|
|
@ -203,7 +227,10 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||||
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
||||||
const commandLine = stdout.trim();
|
const commandLine = stdout.trim();
|
||||||
if (!commandLine) return false;
|
if (!commandLine) return false;
|
||||||
return commandLine.includes(record.command) || commandLine.includes(record.serviceName);
|
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
|
||||||
|
const normalizedCommandLine = normalize(commandLine);
|
||||||
|
const normalizedRecordedCommand = normalize(record.command);
|
||||||
|
return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName);
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
type ProjectWorkspace,
|
type ProjectWorkspace,
|
||||||
type WorkspaceRuntimeService,
|
type WorkspaceRuntimeService,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js";
|
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
|
||||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||||
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
||||||
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
|
||||||
|
|
@ -223,7 +223,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
||||||
.from(projectWorkspaces)
|
.from(projectWorkspaces)
|
||||||
.where(inArray(projectWorkspaces.projectId, projectIds))
|
.where(inArray(projectWorkspaces.projectId, projectIds))
|
||||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
db,
|
db,
|
||||||
rows[0]!.companyId,
|
rows[0]!.companyId,
|
||||||
workspaceRows.map((workspace) => workspace.id),
|
workspaceRows.map((workspace) => workspace.id),
|
||||||
|
|
@ -541,7 +541,7 @@ export function projectService(db: Db) {
|
||||||
.where(eq(projectWorkspaces.projectId, projectId))
|
.where(eq(projectWorkspaces.projectId, projectId))
|
||||||
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const runtimeServicesByWorkspaceId = await listWorkspaceRuntimeServicesForProjectWorkspaces(
|
const runtimeServicesByWorkspaceId = await listCurrentRuntimeServicesForProjectWorkspaces(
|
||||||
db,
|
db,
|
||||||
rows[0]!.companyId,
|
rows[0]!.companyId,
|
||||||
rows.map((workspace) => workspace.id),
|
rows.map((workspace) => workspace.id),
|
||||||
|
|
|
||||||
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}`);
|
throw new Error(`Readiness check failed for ${input.url}: ${lastError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isRuntimeServiceUrlHealthy(url: string | null) {
|
||||||
|
if (!url) return true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) });
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeof workspaceRuntimeServices.$inferInsert {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
|
|
@ -1847,50 +1857,55 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) {
|
||||||
profileKind: "workspace-runtime",
|
profileKind: "workspace-runtime",
|
||||||
});
|
});
|
||||||
if (adoptedRecord) {
|
if (adoptedRecord) {
|
||||||
const record: RuntimeServiceRecord = {
|
const adoptedUrl = adoptedRecord.url ?? row.url ?? null;
|
||||||
id: row.id,
|
if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) {
|
||||||
companyId: row.companyId,
|
await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey);
|
||||||
projectId: row.projectId ?? null,
|
} else {
|
||||||
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
const record: RuntimeServiceRecord = {
|
||||||
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
id: row.id,
|
||||||
issueId: row.issueId ?? null,
|
companyId: row.companyId,
|
||||||
serviceName: row.serviceName,
|
projectId: row.projectId ?? null,
|
||||||
status: "running",
|
projectWorkspaceId: row.projectWorkspaceId ?? null,
|
||||||
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
||||||
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
issueId: row.issueId ?? null,
|
||||||
scopeId: row.scopeId ?? null,
|
serviceName: row.serviceName,
|
||||||
reuseKey: row.reuseKey ?? null,
|
status: "running",
|
||||||
command: row.command ?? null,
|
lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"],
|
||||||
cwd: row.cwd ?? null,
|
scopeType: row.scopeType as RuntimeServiceRecord["scopeType"],
|
||||||
port: adoptedRecord.port ?? row.port ?? null,
|
scopeId: row.scopeId ?? null,
|
||||||
url: adoptedRecord.url ?? row.url ?? null,
|
reuseKey: row.reuseKey ?? null,
|
||||||
provider: "local_process",
|
command: row.command ?? null,
|
||||||
providerRef: String(adoptedRecord.pid),
|
cwd: row.cwd ?? null,
|
||||||
ownerAgentId: row.ownerAgentId ?? null,
|
port: adoptedRecord.port ?? row.port ?? null,
|
||||||
startedByRunId: row.startedByRunId ?? null,
|
url: adoptedRecord.url ?? row.url ?? null,
|
||||||
lastUsedAt: new Date().toISOString(),
|
provider: "local_process",
|
||||||
startedAt: row.startedAt.toISOString(),
|
providerRef: String(adoptedRecord.pid),
|
||||||
stoppedAt: null,
|
ownerAgentId: row.ownerAgentId ?? null,
|
||||||
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
startedByRunId: row.startedByRunId ?? null,
|
||||||
healthStatus: "healthy",
|
lastUsedAt: new Date().toISOString(),
|
||||||
reused: true,
|
startedAt: row.startedAt.toISOString(),
|
||||||
db,
|
stoppedAt: null,
|
||||||
child: null,
|
stopPolicy: (row.stopPolicy as Record<string, unknown> | null) ?? null,
|
||||||
leaseRunIds: new Set(),
|
healthStatus: "healthy",
|
||||||
idleTimer: null,
|
reused: true,
|
||||||
envFingerprint: row.reuseKey ?? "",
|
db,
|
||||||
serviceKey: adoptedRecord.serviceKey,
|
child: null,
|
||||||
profileKind: "workspace-runtime",
|
leaseRunIds: new Set(),
|
||||||
processGroupId: adoptedRecord.processGroupId ?? null,
|
idleTimer: null,
|
||||||
};
|
envFingerprint: row.reuseKey ?? "",
|
||||||
registerRuntimeService(db, record);
|
serviceKey: adoptedRecord.serviceKey,
|
||||||
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
profileKind: "workspace-runtime",
|
||||||
runtimeServiceId: row.id,
|
processGroupId: adoptedRecord.processGroupId ?? null,
|
||||||
lastSeenAt: record.lastUsedAt,
|
};
|
||||||
});
|
registerRuntimeService(db, record);
|
||||||
await persistRuntimeServiceRecord(db, record);
|
await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, {
|
||||||
adopted += 1;
|
runtimeServiceId: row.id,
|
||||||
continue;
|
lastSeenAt: record.lastUsedAt,
|
||||||
|
});
|
||||||
|
await persistRuntimeServiceRecord(db, record);
|
||||||
|
adopted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -120,4 +120,28 @@ describe("CommentThread", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces the composer with a warning when comments are disabled", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentThread
|
||||||
|
comments={[]}
|
||||||
|
composerDisabledReason="Workspace is closed."
|
||||||
|
onAdd={async () => {}}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Workspace is closed.");
|
||||||
|
expect(container.querySelector('textarea[aria-label="Comment editor"]')).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Comment");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ interface CommentThreadProps {
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
composerDisabledReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
const DRAFT_DEBOUNCE_MS = 800;
|
||||||
|
|
@ -569,6 +570,7 @@ export function CommentThread({
|
||||||
mentions: providedMentions,
|
mentions: providedMentions,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
|
composerDisabledReason = null,
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(true);
|
const [reopen, setReopen] = useState(true);
|
||||||
|
|
@ -796,90 +798,96 @@ export function CommentThread({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{composerDisabledReason ? (
|
||||||
<MarkdownEditor
|
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
|
||||||
ref={editorRef}
|
{composerDisabledReason}
|
||||||
value={body}
|
|
||||||
onChange={setBody}
|
|
||||||
placeholder="Leave a comment..."
|
|
||||||
mentions={mentions}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
imageUploadHandler={imageUploadHandler}
|
|
||||||
contentClassName="min-h-[60px] text-sm"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-end gap-3">
|
|
||||||
{(imageUploadHandler || onAttachImage) && (
|
|
||||||
<div className="mr-auto flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
ref={attachInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleAttachFile}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={() => attachInputRef.current?.click()}
|
|
||||||
disabled={attaching}
|
|
||||||
title="Attach image"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={reopen}
|
|
||||||
onChange={(e) => setReopen(e.target.checked)}
|
|
||||||
className="rounded border-border"
|
|
||||||
/>
|
|
||||||
Re-open
|
|
||||||
</label>
|
|
||||||
{enableReassign && reassignOptions.length > 0 && (
|
|
||||||
<InlineEntitySelector
|
|
||||||
value={reassignTarget}
|
|
||||||
options={reassignOptions}
|
|
||||||
placeholder="Assignee"
|
|
||||||
noneLabel="No assignee"
|
|
||||||
searchPlaceholder="Search assignees..."
|
|
||||||
emptyMessage="No assignees found."
|
|
||||||
onChange={setReassignTarget}
|
|
||||||
className="text-xs h-8"
|
|
||||||
renderTriggerValue={(option) => {
|
|
||||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
|
||||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
|
||||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{agent ? (
|
|
||||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : null}
|
|
||||||
<span className="truncate">{option.label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
renderOption={(option) => {
|
|
||||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
|
||||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
|
||||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{agent ? (
|
|
||||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
) : null}
|
|
||||||
<span className="truncate">{option.label}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
{submitting ? "Posting..." : "Comment"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<MarkdownEditor
|
||||||
|
ref={editorRef}
|
||||||
|
value={body}
|
||||||
|
onChange={setBody}
|
||||||
|
placeholder="Leave a comment..."
|
||||||
|
mentions={mentions}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
imageUploadHandler={imageUploadHandler}
|
||||||
|
contentClassName="min-h-[60px] text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
{(imageUploadHandler || onAttachImage) && (
|
||||||
|
<div className="mr-auto flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={attachInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAttachFile}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => attachInputRef.current?.click()}
|
||||||
|
disabled={attaching}
|
||||||
|
title="Attach image"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={reopen}
|
||||||
|
onChange={(e) => setReopen(e.target.checked)}
|
||||||
|
className="rounded border-border"
|
||||||
|
/>
|
||||||
|
Re-open
|
||||||
|
</label>
|
||||||
|
{enableReassign && reassignOptions.length > 0 && (
|
||||||
|
<InlineEntitySelector
|
||||||
|
value={reassignTarget}
|
||||||
|
options={reassignOptions}
|
||||||
|
placeholder="Assignee"
|
||||||
|
noneLabel="No assignee"
|
||||||
|
searchPlaceholder="Search assignees..."
|
||||||
|
emptyMessage="No assignees found."
|
||||||
|
onChange={setReassignTarget}
|
||||||
|
className="text-xs h-8"
|
||||||
|
renderTriggerValue={(option) => {
|
||||||
|
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||||
|
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||||
|
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{agent ? (
|
||||||
|
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderOption={(option) => {
|
||||||
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
|
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||||
|
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{agent ? (
|
||||||
|
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : null}
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||||
|
{submitting ? "Posting..." : "Comment"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => {
|
<Dialog open={open} onOpenChange={(nextOpen) => {
|
||||||
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{actionLabel}</DialogTitle>
|
<DialogTitle>{actionLabel}</DialogTitle>
|
||||||
<DialogDescription className="break-words">
|
<DialogDescription className="break-words text-xs sm:text-sm">
|
||||||
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
|
||||||
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{readinessQuery.isLoading ? (
|
{readinessQuery.isLoading ? (
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||||
Checking whether this workspace is safe to close...
|
Checking whether this workspace is safe to close...
|
||||||
</div>
|
</div>
|
||||||
) : readinessQuery.error ? (
|
) : readinessQuery.error ? (
|
||||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
|
||||||
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
|
||||||
</div>
|
</div>
|
||||||
) : readiness ? (
|
) : readiness ? (
|
||||||
<div className="space-y-4">
|
<div className="min-w-0 space-y-3 sm:space-y-4">
|
||||||
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
|
<div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{readiness.state === "blocked"
|
{readiness.state === "blocked"
|
||||||
? "Close is blocked"
|
? "Close is blocked"
|
||||||
|
|
@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
|
|
||||||
{blockingIssues.length > 0 ? (
|
{blockingIssues.length > 0 ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Blocking issues</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
{blockingIssues.map((issue) => (
|
{blockingIssues.map((issue) => (
|
||||||
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
|
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||||
{issue.identifier ?? issue.id} · {issue.title}
|
{issue.identifier ?? issue.id} · {issue.title}
|
||||||
|
|
@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
|
|
||||||
{readiness.blockingReasons.length > 0 ? (
|
{readiness.blockingReasons.length > 0 ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Blocking reasons</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||||
{readiness.blockingReasons.map((reason, idx) => (
|
{readiness.blockingReasons.map((reason, idx) => (
|
||||||
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
|
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
|
||||||
{reason}
|
{reason}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
|
|
||||||
{readiness.warnings.length > 0 ? (
|
{readiness.warnings.length > 0 ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Warnings</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
|
||||||
{readiness.warnings.map((warning, idx) => (
|
{readiness.warnings.map((warning, idx) => (
|
||||||
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
|
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
|
||||||
{warning}
|
{warning}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
|
|
||||||
{readiness.git ? (
|
{readiness.git ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Git status</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
|
||||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
|
||||||
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
<div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
|
||||||
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
<div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
|
||||||
|
|
@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
|
|
||||||
{otherLinkedIssues.length > 0 ? (
|
{otherLinkedIssues.length > 0 ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Other linked issues</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
{otherLinkedIssues.map((issue) => (
|
{otherLinkedIssues.map((issue) => (
|
||||||
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
|
||||||
{issue.identifier ?? issue.id} · {issue.title}
|
{issue.identifier ?? issue.id} · {issue.title}
|
||||||
|
|
@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
|
|
||||||
{readiness.runtimeServices.length > 0 ? (
|
{readiness.runtimeServices.length > 0 ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Attached runtime services</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
{readiness.runtimeServices.map((service) => (
|
{readiness.runtimeServices.map((service) => (
|
||||||
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-medium">{service.serviceName}</span>
|
<span className="font-medium">{service.serviceName}</span>
|
||||||
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
|
||||||
|
|
@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Cleanup actions</h3>
|
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
{readiness.plannedActions.map((action, index) => (
|
{readiness.plannedActions.map((action, index) => (
|
||||||
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
|
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
|
||||||
<div className="font-medium">{action.label}</div>
|
<div className="font-medium">{action.label}</div>
|
||||||
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
|
||||||
{action.command ? (
|
{action.command ? (
|
||||||
|
|
@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{currentStatus === "cleanup_failed" ? (
|
{currentStatus === "cleanup_failed" ? (
|
||||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||||
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
|
||||||
workspace status if it succeeds.
|
workspace status if it succeeds.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStatus === "archived" ? (
|
{currentStatus === "archived" ? (
|
||||||
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
|
||||||
This workspace is already archived.
|
This workspace is already archived.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{readiness.git?.repoRoot ? (
|
{readiness.git?.repoRoot ? (
|
||||||
<div className="break-words text-xs text-muted-foreground">
|
<div className="overflow-hidden break-words text-xs text-muted-foreground">
|
||||||
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
|
||||||
{readiness.git.workspacePath ? (
|
{readiness.git.workspacePath ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
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 markdownPad = "px-1";
|
||||||
const AUTOSAVE_DEBOUNCE_MS = 900;
|
const AUTOSAVE_DEBOUNCE_MS = 900;
|
||||||
|
|
||||||
|
export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) {
|
||||||
|
let frameId = requestAnimationFrame(() => {
|
||||||
|
frameId = requestAnimationFrame(() => {
|
||||||
|
frameId = 0;
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active instanceof Node && container.contains(active)) return;
|
||||||
|
onCommit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (frameId === 0) return;
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
frameId = 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function InlineEditor({
|
export function InlineEditor({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
|
|
@ -35,6 +52,7 @@ export function InlineEditor({
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const markdownRef = useRef<MarkdownEditorRef>(null);
|
const markdownRef = useRef<MarkdownEditorRef>(null);
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const blurCommitFrameRef = useRef<(() => void) | null>(null);
|
||||||
const {
|
const {
|
||||||
state: autosaveState,
|
state: autosaveState,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
|
@ -52,6 +70,10 @@ export function InlineEditor({
|
||||||
if (autosaveDebounceRef.current) {
|
if (autosaveDebounceRef.current) {
|
||||||
clearTimeout(autosaveDebounceRef.current);
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
}
|
}
|
||||||
|
if (blurCommitFrameRef.current !== null) {
|
||||||
|
blurCommitFrameRef.current();
|
||||||
|
blurCommitFrameRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -91,6 +113,30 @@ export function InlineEditor({
|
||||||
}
|
}
|
||||||
}, [draft, multiline, onSave, value]);
|
}, [draft, multiline, onSave, value]);
|
||||||
|
|
||||||
|
const cancelPendingBlurCommit = useCallback(() => {
|
||||||
|
if (blurCommitFrameRef.current === null) return;
|
||||||
|
blurCommitFrameRef.current();
|
||||||
|
blurCommitFrameRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleBlurCommit = useCallback((container: HTMLDivElement) => {
|
||||||
|
cancelPendingBlurCommit();
|
||||||
|
blurCommitFrameRef.current = queueContainedBlurCommit(container, () => {
|
||||||
|
blurCommitFrameRef.current = null;
|
||||||
|
if (autosaveDebounceRef.current) {
|
||||||
|
clearTimeout(autosaveDebounceRef.current);
|
||||||
|
}
|
||||||
|
setMultilineFocused(false);
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (!trimmed || trimmed === value) {
|
||||||
|
reset();
|
||||||
|
void commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runSave(() => commit());
|
||||||
|
});
|
||||||
|
}, [cancelPendingBlurCommit, commit, draft, reset, runSave, value]);
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
if (e.key === "Enter" && !multiline) {
|
if (e.key === "Enter" && !multiline) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -146,20 +192,13 @@ export function InlineEditor({
|
||||||
"rounded transition-colors",
|
"rounded transition-colors",
|
||||||
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
|
||||||
)}
|
)}
|
||||||
onFocusCapture={() => setMultilineFocused(true)}
|
onFocusCapture={() => {
|
||||||
|
cancelPendingBlurCommit();
|
||||||
|
setMultilineFocused(true);
|
||||||
|
}}
|
||||||
onBlurCapture={(event) => {
|
onBlurCapture={(event) => {
|
||||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||||
if (autosaveDebounceRef.current) {
|
scheduleBlurCommit(event.currentTarget);
|
||||||
clearTimeout(autosaveDebounceRef.current);
|
|
||||||
}
|
|
||||||
setMultilineFocused(false);
|
|
||||||
const trimmed = draft.trim();
|
|
||||||
if (!trimmed || trimmed === value) {
|
|
||||||
reset();
|
|
||||||
void commit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void runSave(() => commit());
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||||
import { ThemeProvider } from "../context/ThemeContext";
|
import { ThemeProvider } from "../context/ThemeContext";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
|
|
||||||
|
|
@ -30,11 +30,11 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('alt="Org chart"');
|
expect(html).toContain('alt="Org chart"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders agent and project mentions as chips", () => {
|
it("renders agent, project, and skill mentions as chips", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<MarkdownBody>
|
<MarkdownBody>
|
||||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`}
|
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|
@ -45,5 +45,7 @@ describe("MarkdownBody", () => {
|
||||||
expect(html).toContain('href="/projects/project-456"');
|
expect(html).toContain('href="/projects/project-456"');
|
||||||
expect(html).toContain('data-mention-kind="project"');
|
expect(html).toContain('data-mention-kind="project"');
|
||||||
expect(html).toContain("--paperclip-mention-project-color:#336699");
|
expect(html).toContain("--paperclip-mention-project-color:#336699");
|
||||||
|
expect(html).toContain('href="/skills/skill-789"');
|
||||||
|
expect(html).toContain('data-mention-kind="skill"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const targetHref = parsed.kind === "project"
|
const targetHref = parsed.kind === "project"
|
||||||
? `/projects/${parsed.projectId}`
|
? `/projects/${parsed.projectId}`
|
||||||
: `/agents/${parsed.agentId}`;
|
: parsed.kind === "skill"
|
||||||
|
? `/skills/${parsed.skillId}`
|
||||||
|
: `/agents/${parsed.agentId}`;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={targetHref}
|
href={targetHref}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
|
||||||
|
|
||||||
const mdxEditorMockState = vi.hoisted(() => ({
|
const mdxEditorMockState = vi.hoisted(() => ({
|
||||||
emitMountEmptyReset: false,
|
emitMountEmptyReset: false,
|
||||||
|
|
@ -162,4 +162,28 @@ describe("MarkdownEditor", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
|
||||||
|
expect(
|
||||||
|
computeMentionMenuPosition(
|
||||||
|
{ viewportTop: 180, viewportLeft: 120 },
|
||||||
|
{ offsetLeft: 24, offsetTop: 320, width: 320, height: 260 },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
top: 372,
|
||||||
|
left: 144,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the mention menu back into view near the viewport edges", () => {
|
||||||
|
expect(
|
||||||
|
computeMentionMenuPosition(
|
||||||
|
{ viewportTop: 260, viewportLeft: 240 },
|
||||||
|
{ offsetLeft: 0, offsetTop: 0, width: 280, height: 220 },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
top: 12,
|
||||||
|
left: 92,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
type RealmPlugin,
|
type RealmPlugin,
|
||||||
} from "@mdxeditor/editor";
|
} from "@mdxeditor/editor";
|
||||||
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
|
||||||
|
import { Boxes } from "lucide-react";
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||||
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||||
|
|
@ -37,6 +38,7 @@ import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
|
||||||
import { normalizeMarkdown } from "../lib/normalize-markdown";
|
import { normalizeMarkdown } from "../lib/normalize-markdown";
|
||||||
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
|
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
|
||||||
|
|
||||||
/* ---- Mention types ---- */
|
/* ---- Mention types ---- */
|
||||||
|
|
||||||
|
|
@ -84,6 +86,8 @@ function isSafeMarkdownLinkUrl(url: string): boolean {
|
||||||
/* ---- Mention detection helpers ---- */
|
/* ---- Mention detection helpers ---- */
|
||||||
|
|
||||||
interface MentionState {
|
interface MentionState {
|
||||||
|
trigger: "mention" | "skill";
|
||||||
|
marker: "@" | "/";
|
||||||
query: string;
|
query: string;
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
|
|
@ -95,6 +99,19 @@ interface MentionState {
|
||||||
endPos: number;
|
endPos: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutocompleteOption = MentionOption | SkillCommandOption;
|
||||||
|
|
||||||
|
interface MentionMenuViewport {
|
||||||
|
offsetLeft: number;
|
||||||
|
offsetTop: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENTION_MENU_WIDTH = 188;
|
||||||
|
const MENTION_MENU_HEIGHT = 208;
|
||||||
|
const MENTION_MENU_PADDING = 8;
|
||||||
|
|
||||||
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
|
||||||
txt: "Text",
|
txt: "Text",
|
||||||
md: "Markdown",
|
md: "Markdown",
|
||||||
|
|
@ -135,13 +152,17 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||||
const text = textNode.textContent ?? "";
|
const text = textNode.textContent ?? "";
|
||||||
const offset = range.startOffset;
|
const offset = range.startOffset;
|
||||||
|
|
||||||
// Walk backwards from cursor to find @
|
// Walk backwards from cursor to find an autocomplete trigger.
|
||||||
let atPos = -1;
|
let atPos = -1;
|
||||||
|
let trigger: MentionState["trigger"] | null = null;
|
||||||
|
let marker: MentionState["marker"] | null = null;
|
||||||
for (let i = offset - 1; i >= 0; i--) {
|
for (let i = offset - 1; i >= 0; i--) {
|
||||||
const ch = text[i];
|
const ch = text[i];
|
||||||
if (ch === "@") {
|
if (ch === "@" || ch === "/") {
|
||||||
if (i === 0 || /\s/.test(text[i - 1])) {
|
if (i === 0 || /\s/.test(text[i - 1])) {
|
||||||
atPos = i;
|
atPos = i;
|
||||||
|
trigger = ch === "@" ? "mention" : "skill";
|
||||||
|
marker = ch;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -160,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
trigger: trigger ?? "mention",
|
||||||
|
marker: marker ?? "@",
|
||||||
query,
|
query,
|
||||||
top: rect.bottom - containerRect.top,
|
top: rect.bottom - containerRect.top,
|
||||||
left: rect.left - containerRect.left,
|
left: rect.left - containerRect.left,
|
||||||
|
|
@ -171,6 +194,40 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMentionMenuViewport(): MentionMenuViewport {
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
if (viewport) {
|
||||||
|
return {
|
||||||
|
offsetLeft: viewport.offsetLeft,
|
||||||
|
offsetTop: viewport.offsetTop,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetLeft: 0,
|
||||||
|
offsetTop: 0,
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMentionMenuPosition(
|
||||||
|
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
|
||||||
|
viewport: MentionMenuViewport,
|
||||||
|
) {
|
||||||
|
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
|
||||||
|
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH;
|
||||||
|
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
|
||||||
|
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
|
||||||
|
left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
|
||||||
if (!node || !container.contains(node)) return false;
|
if (!node || !container.contains(node)) return false;
|
||||||
const el = node.nodeType === Node.ELEMENT_NODE
|
const el = node.nodeType === Node.ELEMENT_NODE
|
||||||
|
|
@ -197,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string {
|
||||||
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
|
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace `@<query>` in the markdown string with the selected mention token. */
|
function skillMarkdown(option: SkillCommandOption): string {
|
||||||
function applyMention(markdown: string, query: string, option: MentionOption): string {
|
return `[/${option.slug}](${option.href}) `;
|
||||||
const search = `@${query}`;
|
}
|
||||||
const replacement = mentionMarkdown(option);
|
|
||||||
|
function autocompleteMarkdown(option: AutocompleteOption): string {
|
||||||
|
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the active autocomplete token in the markdown string with the selected token. */
|
||||||
|
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
|
||||||
|
const search = `${state.marker}${state.query}`;
|
||||||
|
const replacement = autocompleteMarkdown(option);
|
||||||
const idx = markdown.lastIndexOf(search);
|
const idx = markdown.lastIndexOf(search);
|
||||||
if (idx === -1) return markdown;
|
if (idx === -1) return markdown;
|
||||||
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
|
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
|
||||||
|
|
@ -220,6 +285,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
mentions,
|
mentions,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: MarkdownEditorProps, forwardedRef) {
|
}: MarkdownEditorProps, forwardedRef) {
|
||||||
|
const { slashCommands } = useEditorAutocomplete();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const ref = useRef<MDXEditorMethods>(null);
|
const ref = useRef<MDXEditorMethods>(null);
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
|
|
@ -244,7 +310,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||||
const mentionStateRef = useRef<MentionState | null>(null);
|
const mentionStateRef = useRef<MentionState | null>(null);
|
||||||
const [mentionIndex, setMentionIndex] = useState(0);
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
|
const mentionActive = mentionState !== null && (
|
||||||
|
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||||
|
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||||
|
);
|
||||||
const mentionOptionByKey = useMemo(() => {
|
const mentionOptionByKey = useMemo(() => {
|
||||||
const map = new Map<string, MentionOption>();
|
const map = new Map<string, MentionOption>();
|
||||||
for (const mention of mentions ?? []) {
|
for (const mention of mentions ?? []) {
|
||||||
|
|
@ -259,11 +328,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
return map;
|
return map;
|
||||||
}, [mentions]);
|
}, [mentions]);
|
||||||
|
|
||||||
const filteredMentions = useMemo(() => {
|
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
||||||
if (!mentionState || !mentions) return [];
|
if (!mentionState) return [];
|
||||||
const q = mentionState.query.toLowerCase();
|
const q = mentionState.query.trim().toLowerCase();
|
||||||
|
if (mentionState.trigger === "skill") {
|
||||||
|
return slashCommands
|
||||||
|
.filter((command) => {
|
||||||
|
if (!q) return true;
|
||||||
|
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
if (!mentions) return [];
|
||||||
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||||
}, [mentionState?.query, mentions]);
|
}, [mentionState, mentions, slashCommands]);
|
||||||
|
|
||||||
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||||
ref.current = instance;
|
ref.current = instance;
|
||||||
|
|
@ -375,6 +453,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.kind === "skill") {
|
||||||
|
applyMentionChipDecoration(link, parsed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
|
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
|
||||||
applyMentionChipDecoration(link, {
|
applyMentionChipDecoration(link, {
|
||||||
...parsed,
|
...parsed,
|
||||||
|
|
@ -385,12 +468,30 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
|
|
||||||
// Mention detection: listen for selection changes and input events
|
// Mention detection: listen for selection changes and input events
|
||||||
const checkMention = useCallback(() => {
|
const checkMention = useCallback(() => {
|
||||||
if (!mentions || mentions.length === 0 || !containerRef.current) {
|
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
|
||||||
mentionStateRef.current = null;
|
mentionStateRef.current = null;
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = detectMention(containerRef.current);
|
const result = detectMention(containerRef.current);
|
||||||
|
if (
|
||||||
|
result
|
||||||
|
&& result.trigger === "mention"
|
||||||
|
&& (!mentions || mentions.length === 0)
|
||||||
|
) {
|
||||||
|
mentionStateRef.current = null;
|
||||||
|
setMentionState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result
|
||||||
|
&& result.trigger === "skill"
|
||||||
|
&& slashCommands.length === 0
|
||||||
|
) {
|
||||||
|
mentionStateRef.current = null;
|
||||||
|
setMentionState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
mentionStateRef.current = result;
|
mentionStateRef.current = result;
|
||||||
if (result) {
|
if (result) {
|
||||||
setMentionState(result);
|
setMentionState(result);
|
||||||
|
|
@ -398,10 +499,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
} else {
|
} else {
|
||||||
setMentionState(null);
|
setMentionState(null);
|
||||||
}
|
}
|
||||||
}, [mentions]);
|
}, [mentions, slashCommands.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mentions || mentions.length === 0) return;
|
if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return;
|
||||||
|
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
// Listen for input events on the container so mention detection
|
// Listen for input events on the container so mention detection
|
||||||
|
|
@ -414,7 +515,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
document.removeEventListener("selectionchange", checkMention);
|
document.removeEventListener("selectionchange", checkMention);
|
||||||
el?.removeEventListener("input", onInput, true);
|
el?.removeEventListener("input", onInput, true);
|
||||||
};
|
};
|
||||||
}, [checkMention, mentions]);
|
}, [checkMention, mentions, slashCommands.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mentionActive) return;
|
||||||
|
|
||||||
|
const updatePosition = () => requestAnimationFrame(checkMention);
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
|
||||||
|
viewport?.addEventListener("resize", updatePosition);
|
||||||
|
viewport?.addEventListener("scroll", updatePosition);
|
||||||
|
window.addEventListener("resize", updatePosition);
|
||||||
|
window.addEventListener("scroll", updatePosition, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
viewport?.removeEventListener("resize", updatePosition);
|
||||||
|
viewport?.removeEventListener("scroll", updatePosition);
|
||||||
|
window.removeEventListener("resize", updatePosition);
|
||||||
|
window.removeEventListener("scroll", updatePosition, true);
|
||||||
|
};
|
||||||
|
}, [checkMention, mentionActive]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||||
|
|
@ -432,13 +552,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
}, [decorateProjectMentions, value]);
|
}, [decorateProjectMentions, value]);
|
||||||
|
|
||||||
const selectMention = useCallback(
|
const selectMention = useCallback(
|
||||||
(option: MentionOption) => {
|
(option: AutocompleteOption) => {
|
||||||
// Read from ref to avoid stale-closure issues (selectionchange can
|
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||||
// update state between the last render and this callback firing).
|
// update state between the last render and this callback firing).
|
||||||
const state = mentionStateRef.current;
|
const state = mentionStateRef.current;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const current = latestValueRef.current;
|
const current = latestValueRef.current;
|
||||||
const next = applyMention(current, state.query, option);
|
const next = applyMention(current, state, option);
|
||||||
if (next !== current) {
|
if (next !== current) {
|
||||||
latestValueRef.current = next;
|
latestValueRef.current = next;
|
||||||
echoIgnoreMarkdownRef.current = next;
|
echoIgnoreMarkdownRef.current = next;
|
||||||
|
|
@ -453,17 +573,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
decorateProjectMentions();
|
decorateProjectMentions();
|
||||||
editable.focus();
|
editable.focus();
|
||||||
|
|
||||||
const mentionHref = option.kind === "project" && option.projectId
|
const mentionHref = option.kind === "skill"
|
||||||
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
|
? option.href
|
||||||
: buildAgentMentionHref(
|
: option.kind === "project" && option.projectId
|
||||||
option.agentId ?? option.id.replace(/^agent:/, ""),
|
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
|
||||||
option.agentIcon ?? null,
|
: buildAgentMentionHref(
|
||||||
);
|
option.agentId ?? option.id.replace(/^agent:/, ""),
|
||||||
|
option.agentIcon ?? null,
|
||||||
|
);
|
||||||
|
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
|
||||||
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
||||||
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
||||||
.filter((link) => {
|
.filter((link) => {
|
||||||
const href = link.getAttribute("href") ?? "";
|
const href = link.getAttribute("href") ?? "";
|
||||||
return href === mentionHref && link.textContent === `@${option.name}`;
|
return href === mentionHref && link.textContent === expectedLabel;
|
||||||
});
|
});
|
||||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||||
const target = matchingMentions.sort((a, b) => {
|
const target = matchingMentions.sort((a, b) => {
|
||||||
|
|
@ -526,6 +649,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
ref.current.insertMarkdown(normalizeMarkdown(rawText));
|
ref.current.insertMarkdown(normalizeMarkdown(rawText));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const mentionMenuPosition = mentionState
|
||||||
|
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -645,25 +772,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||||
style={{
|
style={mentionMenuPosition ?? undefined}
|
||||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
|
||||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{filteredMentions.map((option, i) => (
|
{filteredMentions.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
|
||||||
i === mentionIndex && "bg-accent",
|
i === mentionIndex && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.preventDefault(); // prevent blur
|
e.preventDefault(); // prevent blur
|
||||||
selectMention(option);
|
selectMention(option);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setMentionIndex(i)}
|
onMouseEnter={() => setMentionIndex(i)}
|
||||||
>
|
>
|
||||||
{option.kind === "project" && option.projectId ? (
|
{option.kind === "skill" ? (
|
||||||
|
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : option.kind === "project" && option.projectId ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||||
|
|
@ -674,12 +801,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span>{option.name}</span>
|
<span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span>
|
||||||
{option.kind === "project" && option.projectId && (
|
{option.kind === "project" && option.projectId && (
|
||||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
Project
|
Project
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{option.kind === "skill" && (
|
||||||
|
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Skill
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>,
|
</div>,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
|
|
@ -1208,21 +1209,10 @@ export function NewIssueDialog() {
|
||||||
{assigneeAdapterType === "claude_local" && (
|
{assigneeAdapterType === "claude_local" && (
|
||||||
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
|
||||||
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
data-slot="toggle"
|
checked={assigneeChrome}
|
||||||
className={cn(
|
onCheckedChange={() => setAssigneeChrome((value) => !value)}
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
/>
|
||||||
assigneeChrome ? "bg-green-600" : "bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => setAssigneeChrome((value) => !value)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { DraftInput } from "./agent-config-primitives";
|
import { DraftInput } from "./agent-config-primitives";
|
||||||
import { InlineEditor } from "./InlineEditor";
|
import { InlineEditor } from "./InlineEditor";
|
||||||
|
|
||||||
|
|
@ -886,26 +887,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{onUpdate || onFieldUpdate ? (
|
{onUpdate || onFieldUpdate ? (
|
||||||
<button
|
<ToggleSwitch
|
||||||
data-slot="toggle"
|
checked={executionWorkspacesEnabled}
|
||||||
className={cn(
|
onCheckedChange={() =>
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
commitField(
|
commitField(
|
||||||
"execution_workspace_enabled",
|
"execution_workspace_enabled",
|
||||||
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
|
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
|
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
|
||||||
|
|
@ -925,14 +914,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
If disabled, new issues stay on the project's primary checkout unless someone opts in.
|
If disabled, new issues stay on the project's primary checkout unless someone opts in.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
data-slot="toggle"
|
checked={executionWorkspaceDefaultMode === "isolated_workspace"}
|
||||||
className={cn(
|
onCheckedChange={() =>
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
commitField(
|
commitField(
|
||||||
"execution_workspace_default_mode",
|
"execution_workspace_default_mode",
|
||||||
updateExecutionWorkspacePolicy({
|
updateExecutionWorkspacePolicy({
|
||||||
|
|
@ -942,16 +926,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
: "isolated_workspace",
|
: "isolated_workspace",
|
||||||
})!,
|
})!,
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
executionWorkspaceDefaultMode === "isolated_workspace"
|
|
||||||
? "translate-x-4.5"
|
|
||||||
: "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-2">
|
<div className="border-t border-border/60 pt-2">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -111,23 +112,11 @@ export function ToggleField({
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
{hint && <HintIcon text={hint} />}
|
{hint && <HintIcon text={hint} />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
data-slot="toggle"
|
checked={checked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
data-testid={toggleTestId}
|
data-testid={toggleTestId}
|
||||||
type="button"
|
/>
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
||||||
checked ? "bg-green-600" : "bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -162,21 +151,10 @@ export function ToggleWithNumber({
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
{hint && <HintIcon text={hint} />}
|
{hint && <HintIcon text={hint} />}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
data-slot="toggle"
|
checked={checked}
|
||||||
className={cn(
|
onCheckedChange={onCheckedChange}
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
|
/>
|
||||||
checked ? "bg-green-600" : "bg-muted"
|
|
||||||
)}
|
|
||||||
onClick={() => onCheckedChange(!checked)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{showNumber && (
|
{showNumber && (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
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 type { CSSProperties } from "react";
|
||||||
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
|
import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared";
|
||||||
import { getAgentIcon } from "./agent-icons";
|
import { getAgentIcon } from "./agent-icons";
|
||||||
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
|
||||||
|
|
||||||
|
|
@ -13,6 +13,11 @@ export type ParsedMentionChip =
|
||||||
kind: "project";
|
kind: "project";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "skill";
|
||||||
|
skillId: string;
|
||||||
|
slug: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconMaskCache = new Map<string, string>();
|
const iconMaskCache = new Map<string, string>();
|
||||||
|
|
@ -36,6 +41,15 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skill = parseSkillMentionHref(href);
|
||||||
|
if (skill) {
|
||||||
|
return {
|
||||||
|
kind: "skill",
|
||||||
|
skillId: skill.skillId,
|
||||||
|
slug: skill.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
|
||||||
"paperclip-mention-chip",
|
"paperclip-mention-chip",
|
||||||
"paperclip-mention-chip--agent",
|
"paperclip-mention-chip--agent",
|
||||||
"paperclip-mention-chip--project",
|
"paperclip-mention-chip--project",
|
||||||
|
"paperclip-mention-chip--skill",
|
||||||
"paperclip-project-mention-chip",
|
"paperclip-project-mention-chip",
|
||||||
);
|
);
|
||||||
element.removeAttribute("contenteditable");
|
element.removeAttribute("contenteditable");
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext";
|
||||||
import { PanelProvider } from "./context/PanelContext";
|
import { PanelProvider } from "./context/PanelContext";
|
||||||
import { SidebarProvider } from "./context/SidebarContext";
|
import { SidebarProvider } from "./context/SidebarContext";
|
||||||
import { DialogProvider } from "./context/DialogContext";
|
import { DialogProvider } from "./context/DialogContext";
|
||||||
|
import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
import { ThemeProvider } from "./context/ThemeContext";
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<CompanyProvider>
|
<CompanyProvider>
|
||||||
<ToastProvider>
|
<EditorAutocompleteProvider>
|
||||||
<LiveUpdatesProvider>
|
<ToastProvider>
|
||||||
<TooltipProvider>
|
<LiveUpdatesProvider>
|
||||||
<BreadcrumbProvider>
|
<TooltipProvider>
|
||||||
<SidebarProvider>
|
<BreadcrumbProvider>
|
||||||
<PanelProvider>
|
<SidebarProvider>
|
||||||
<PluginLauncherProvider>
|
<PanelProvider>
|
||||||
<DialogProvider>
|
<PluginLauncherProvider>
|
||||||
<App />
|
<DialogProvider>
|
||||||
</DialogProvider>
|
<App />
|
||||||
</PluginLauncherProvider>
|
</DialogProvider>
|
||||||
</PanelProvider>
|
</PluginLauncherProvider>
|
||||||
</SidebarProvider>
|
</PanelProvider>
|
||||||
</BreadcrumbProvider>
|
</SidebarProvider>
|
||||||
</TooltipProvider>
|
</BreadcrumbProvider>
|
||||||
</LiveUpdatesProvider>
|
</TooltipProvider>
|
||||||
</ToastProvider>
|
</LiveUpdatesProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</EditorAutocompleteProvider>
|
||||||
</CompanyProvider>
|
</CompanyProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||||
|
|
@ -1627,30 +1628,16 @@ function ConfigurationTab({
|
||||||
Lets this agent create or hire agents and implicitly assign tasks.
|
Lets this agent create or hire agents and implicitly assign tasks.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
checked={canCreateAgents}
|
||||||
role="switch"
|
onCheckedChange={() =>
|
||||||
data-slot="toggle"
|
|
||||||
aria-checked={canCreateAgents}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
canCreateAgents ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
updatePermissions.mutate({
|
updatePermissions.mutate({
|
||||||
canCreateAgents: !canCreateAgents,
|
canCreateAgents: !canCreateAgents,
|
||||||
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
|
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={updatePermissions.isPending}
|
disabled={updatePermissions.isPending}
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4 text-sm">
|
<div className="flex items-center justify-between gap-4 text-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -1659,30 +1646,16 @@ function ConfigurationTab({
|
||||||
{taskAssignHint}
|
{taskAssignHint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
checked={canAssignTasks}
|
||||||
role="switch"
|
onCheckedChange={() =>
|
||||||
data-slot="toggle"
|
|
||||||
aria-checked={canAssignTasks}
|
|
||||||
className={cn(
|
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
canAssignTasks ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
updatePermissions.mutate({
|
updatePermissions.mutate({
|
||||||
canCreateAgents,
|
canCreateAgents,
|
||||||
canAssignTasks: !canAssignTasks,
|
canAssignTasks: !canAssignTasks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={updatePermissions.isPending || taskAssignLocked}
|
disabled={updatePermissions.isPending || taskAssignLocked}
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ function readText(value: string | null | undefined) {
|
||||||
return value ?? "";
|
return value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) {
|
||||||
|
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
|
||||||
|
}
|
||||||
|
|
||||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||||
if (!value || Object.keys(value).length === 0) return "";
|
if (!value || Object.keys(value).length === 0) return "";
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
|
|
@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { FlaskConical } from "lucide-react";
|
||||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn } from "../lib/utils";
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
|
|
||||||
export function InstanceExperimentalSettings() {
|
export function InstanceExperimentalSettings() {
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
|
@ -82,24 +82,12 @@ export function InstanceExperimentalSettings() {
|
||||||
and existing issue runs.
|
and existing issue runs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
checked={enableIsolatedWorkspaces}
|
||||||
data-slot="toggle"
|
onCheckedChange={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
|
||||||
aria-label="Toggle isolated workspaces experimental setting"
|
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
className={cn(
|
aria-label="Toggle isolated workspaces experimental setting"
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
/>
|
||||||
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -112,26 +100,12 @@ export function InstanceExperimentalSettings() {
|
||||||
automatically when backend changes or migrations make the current boot stale.
|
automatically when backend changes or migrations make the current boot stale.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
checked={autoRestartDevServerWhenIdle}
|
||||||
data-slot="toggle"
|
onCheckedChange={() => toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })}
|
||||||
aria-label="Toggle guarded dev-server auto-restart"
|
|
||||||
disabled={toggleMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
className={cn(
|
aria-label="Toggle guarded dev-server auto-restart"
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
/>
|
||||||
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
|
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
|
||||||
import { SlidersHorizontal } from "lucide-react";
|
import { LogOut, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { authApi } from "@/api/auth";
|
||||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||||
|
|
@ -14,6 +17,16 @@ export function InstanceGeneralSettings() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const signOutMutation = useMutation({
|
||||||
|
mutationFn: () => authApi.signOut(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setActionError(error instanceof Error ? error.message : "Failed to sign out.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
{ label: "Instance Settings" },
|
{ label: "Instance Settings" },
|
||||||
|
|
@ -83,28 +96,12 @@ export function InstanceGeneralSettings() {
|
||||||
default.
|
default.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
checked={censorUsernameInLogs}
|
||||||
data-slot="toggle"
|
onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
|
||||||
aria-label="Toggle username log censoring"
|
|
||||||
disabled={updateGeneralMutation.isPending}
|
disabled={updateGeneralMutation.isPending}
|
||||||
className={cn(
|
aria-label="Toggle username log censoring"
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
/>
|
||||||
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
updateGeneralMutation.mutate({
|
|
||||||
censorUsernameInLogs: !censorUsernameInLogs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -117,24 +114,12 @@ export function InstanceGeneralSettings() {
|
||||||
toggling panels. This is off by default.
|
toggling panels. This is off by default.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
checked={keyboardShortcuts}
|
||||||
data-slot="toggle"
|
onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
|
||||||
aria-label="Toggle keyboard shortcuts"
|
|
||||||
disabled={updateGeneralMutation.isPending}
|
disabled={updateGeneralMutation.isPending}
|
||||||
className={cn(
|
aria-label="Toggle keyboard shortcuts"
|
||||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
/>
|
||||||
keyboardShortcuts ? "bg-green-600" : "bg-muted",
|
|
||||||
)}
|
|
||||||
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
||||||
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -213,6 +198,26 @@ export function InstanceGeneralSettings() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Sign out</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Sign out of this Paperclip instance. You will be redirected to the login page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={signOutMutation.isPending}
|
||||||
|
onClick={() => signOutMutation.mutate()}
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,16 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ActivityEvent } from "@paperclipai/shared";
|
import {
|
||||||
import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
getClosedIsolatedExecutionWorkspaceMessage,
|
||||||
|
isClosedIsolatedExecutionWorkspace,
|
||||||
|
type ActivityEvent,
|
||||||
|
type Agent,
|
||||||
|
type FeedbackVote,
|
||||||
|
type Issue,
|
||||||
|
type IssueAttachment,
|
||||||
|
type IssueComment,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
type CommentReassignment = IssueCommentReassignment;
|
type CommentReassignment = IssueCommentReassignment;
|
||||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||||
|
|
@ -306,6 +314,12 @@ export function IssueDetail() {
|
||||||
enabled: !!issueId,
|
enabled: !!issueId,
|
||||||
});
|
});
|
||||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||||
|
const commentComposerDisabledReason = useMemo(() => {
|
||||||
|
if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace);
|
||||||
|
}, [issue?.currentExecutionWorkspace]);
|
||||||
|
|
||||||
const { data: comments } = useQuery({
|
const { data: comments } = useQuery({
|
||||||
queryKey: queryKeys.issues.comments(issueId!),
|
queryKey: queryKeys.issues.comments(issueId!),
|
||||||
|
|
@ -1522,6 +1536,7 @@ export function IssueDetail() {
|
||||||
await interruptQueuedComment.mutateAsync(runId);
|
await interruptQueuedComment.mutateAsync(runId);
|
||||||
}}
|
}}
|
||||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||||
|
composerDisabledReason={commentComposerDisabledReason}
|
||||||
onVote={async (commentId, vote, options) => {
|
onVote={async (commentId, vote, options) => {
|
||||||
await feedbackVoteMutation.mutateAsync({
|
await feedbackVoteMutation.mutateAsync({
|
||||||
targetType: "issue_comment",
|
targetType: "issue_comment",
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ function readText(value: string | null | undefined) {
|
||||||
return value ?? "";
|
return value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) {
|
||||||
|
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
|
||||||
|
}
|
||||||
|
|
||||||
function formatJson(value: Record<string, unknown> | null | undefined) {
|
function formatJson(value: Record<string, unknown> | null | undefined) {
|
||||||
if (!value || Object.keys(value).length === 0) return "";
|
if (!value || Object.keys(value).length === 0) return "";
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
|
|
@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
|
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
|
||||||
onClick={() => controlRuntimeServices.mutate("stop")}
|
onClick={() => controlRuntimeServices.mutate("stop")}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
|
|
@ -710,24 +711,13 @@ export function RoutineDetail() {
|
||||||
}}
|
}}
|
||||||
disabled={runRoutine.isPending}
|
disabled={runRoutine.isPending}
|
||||||
/>
|
/>
|
||||||
<button
|
<ToggleSwitch
|
||||||
type="button"
|
size="lg"
|
||||||
role="switch"
|
checked={automationEnabled}
|
||||||
data-slot="toggle"
|
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
|
||||||
aria-checked={automationEnabled}
|
|
||||||
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
|
||||||
disabled={automationToggleDisabled}
|
disabled={automationToggleDisabled}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
|
||||||
automationEnabled ? "bg-emerald-500" : "bg-muted"
|
/>
|
||||||
} ${automationToggleDisabled ? "cursor-not-allowed opacity-50" : ""}`}
|
|
||||||
onClick={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
|
|
||||||
automationEnabled ? "translate-x-5" : "translate-x-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
||||||
{automationLabel}
|
{automationLabel}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate, useSearchParams } from "@/lib/router";
|
||||||
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
|
||||||
import { routinesApi } from "../api/routines";
|
import { routinesApi } from "../api/routines";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { groupBy } from "../lib/groupBy";
|
||||||
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { AgentIcon } from "../components/AgentIconPicker";
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
|
||||||
|
|
@ -33,6 +40,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -40,6 +48,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
||||||
|
|
||||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
|
|
@ -70,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
|
||||||
return enabled ? "active" : "paused";
|
return enabled ? "active" : "paused";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RoutinesTab = "routines" | "runs";
|
||||||
|
type RoutineGroupBy = "none" | "project" | "assignee";
|
||||||
|
|
||||||
|
type RoutineViewState = {
|
||||||
|
groupBy: RoutineGroupBy;
|
||||||
|
collapsedGroups: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoutineGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string | null;
|
||||||
|
items: RoutineListItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultRoutineViewState: RoutineViewState = {
|
||||||
|
groupBy: "none",
|
||||||
|
collapsedGroups: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRoutineViewState(key: string): RoutineViewState {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) };
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed local state and fall back to defaults.
|
||||||
|
}
|
||||||
|
return { ...defaultRoutineViewState };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRoutineViewState(key: string, state: RoutineViewState) {
|
||||||
|
localStorage.setItem(key, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRoutineRunStatus(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
return value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoutineGroups(
|
||||||
|
routines: RoutineListItem[],
|
||||||
|
groupByValue: RoutineGroupBy,
|
||||||
|
projectById: Map<string, { name: string }>,
|
||||||
|
agentById: Map<string, { name: string }>,
|
||||||
|
): RoutineGroup[] {
|
||||||
|
if (groupByValue === "none") {
|
||||||
|
return [{ key: "__all", label: null, items: routines }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupByValue === "project") {
|
||||||
|
const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project");
|
||||||
|
return Object.keys(groups)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project");
|
||||||
|
const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project");
|
||||||
|
return leftLabel.localeCompare(rightLabel);
|
||||||
|
})
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"),
|
||||||
|
items: groups[key]!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned");
|
||||||
|
return Object.keys(groups)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent");
|
||||||
|
const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent");
|
||||||
|
return leftLabel.localeCompare(rightLabel);
|
||||||
|
})
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"),
|
||||||
|
items: groups[key]!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoutinesTabHref(tab: RoutinesTab) {
|
||||||
|
return tab === "runs" ? "/routines?tab=runs" : "/routines";
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutineListRow({
|
||||||
|
routine,
|
||||||
|
projectById,
|
||||||
|
agentById,
|
||||||
|
runningRoutineId,
|
||||||
|
statusMutationRoutineId,
|
||||||
|
onNavigate,
|
||||||
|
onRunNow,
|
||||||
|
onToggleEnabled,
|
||||||
|
onToggleArchived,
|
||||||
|
}: {
|
||||||
|
routine: RoutineListItem;
|
||||||
|
projectById: Map<string, { name: string; color?: string | null }>;
|
||||||
|
agentById: Map<string, { name: string; icon?: string | null }>;
|
||||||
|
runningRoutineId: string | null;
|
||||||
|
statusMutationRoutineId: string | null;
|
||||||
|
onNavigate: (routineId: string) => void;
|
||||||
|
onRunNow: (routine: RoutineListItem) => void;
|
||||||
|
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
|
||||||
|
onToggleArchived: (routine: RoutineListItem) => void;
|
||||||
|
}) {
|
||||||
|
const enabled = routine.status === "active";
|
||||||
|
const isArchived = routine.status === "archived";
|
||||||
|
const isStatusPending = statusMutationRoutineId === routine.id;
|
||||||
|
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||||
|
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center"
|
||||||
|
onClick={() => onNavigate(routine.id)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||||
|
{(isArchived || routine.status === "paused") ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "archived" : "paused"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||||
|
style={{ backgroundColor: project?.color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<span>{project?.name ?? "Unknown project"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||||
|
<span>{agent?.name ?? "Unknown agent"}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
|
||||||
|
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
size="lg"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={() => onToggleEnabled(routine, enabled)}
|
||||||
|
disabled={isStatusPending || isArchived}
|
||||||
|
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
||||||
|
/>
|
||||||
|
<span className="w-12 text-xs text-muted-foreground">
|
||||||
|
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onNavigate(routine.id)}>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={runningRoutineId === routine.id || isArchived}
|
||||||
|
onClick={() => onRunNow(routine)}
|
||||||
|
>
|
||||||
|
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleEnabled(routine, enabled)}
|
||||||
|
disabled={isStatusPending || isArchived}
|
||||||
|
>
|
||||||
|
{enabled ? "Pause" : "Enable"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onToggleArchived(routine)}
|
||||||
|
disabled={isStatusPending}
|
||||||
|
>
|
||||||
|
{routine.status === "archived" ? "Restore" : "Archive"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Routines() {
|
export function Routines() {
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
@ -85,6 +286,7 @@ export function Routines() {
|
||||||
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||||
const [composerOpen, setComposerOpen] = useState(false);
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
|
const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines";
|
||||||
const [draft, setDraft] = useState<{
|
const [draft, setDraft] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -104,11 +306,19 @@ export function Routines() {
|
||||||
catchUpPolicy: "skip_missed",
|
catchUpPolicy: "skip_missed",
|
||||||
variables: [],
|
variables: [],
|
||||||
});
|
});
|
||||||
|
const routineViewStateKey = selectedCompanyId
|
||||||
|
? `paperclip:routines-view:${selectedCompanyId}`
|
||||||
|
: "paperclip:routines-view";
|
||||||
|
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Routines" }]);
|
setBreadcrumbs([{ label: "Routines" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRoutineViewState(getRoutineViewState(routineViewStateKey));
|
||||||
|
}, [routineViewStateKey]);
|
||||||
|
|
||||||
const { data: routines, isLoading, error } = useQuery({
|
const { data: routines, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
queryKey: queryKeys.routines.list(selectedCompanyId!),
|
||||||
queryFn: () => routinesApi.list(selectedCompanyId!),
|
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||||
|
|
@ -129,6 +339,17 @@ export function Routines() {
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
|
||||||
|
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
|
||||||
|
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
|
||||||
|
enabled: !!selectedCompanyId && activeTab === "runs",
|
||||||
|
});
|
||||||
|
const { data: liveRuns } = useQuery({
|
||||||
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId && activeTab === "runs",
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
autoResizeTextarea(titleInputRef.current);
|
autoResizeTextarea(titleInputRef.current);
|
||||||
|
|
@ -162,6 +383,13 @@ export function Routines() {
|
||||||
navigate(`/routines/${routine.id}?tab=triggers`);
|
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const updateIssue = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||||
|
issuesApi.update(id, data),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const updateRoutineStatus = useMutation({
|
const updateRoutineStatus = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
|
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
|
||||||
|
|
@ -249,10 +477,45 @@ export function Routines() {
|
||||||
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
() => new Map((projects ?? []).map((project) => [project.id, project])),
|
||||||
[projects],
|
[projects],
|
||||||
);
|
);
|
||||||
|
const liveIssueIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const run of liveRuns ?? []) {
|
||||||
|
if (run.issueId) ids.add(run.issueId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [liveRuns]);
|
||||||
|
const routineGroups = useMemo(
|
||||||
|
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
|
||||||
|
[agentById, projectById, routineViewState.groupBy, routines],
|
||||||
|
);
|
||||||
|
const recentRunsIssueLinkState = useMemo(
|
||||||
|
() =>
|
||||||
|
createIssueDetailLocationState(
|
||||||
|
"Recent Runs",
|
||||||
|
buildRoutinesTabHref("runs"),
|
||||||
|
"issues",
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
|
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
|
||||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||||
|
|
||||||
|
function updateRoutineView(patch: Partial<RoutineViewState>) {
|
||||||
|
setRoutineViewState((current) => {
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
saveRoutineViewState(routineViewStateKey, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabChange(tab: string) {
|
||||||
|
const nextTab = tab === "runs" ? "runs" : "routines";
|
||||||
|
startTransition(() => {
|
||||||
|
navigate(buildRoutinesTabHref(nextTab));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleRunNow(routine: RoutineListItem) {
|
function handleRunNow(routine: RoutineListItem) {
|
||||||
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
|
||||||
const needsConfiguration = routineRunNeedsConfiguration({
|
const needsConfiguration = routineRunNeedsConfiguration({
|
||||||
|
|
@ -267,6 +530,20 @@ export function Routines() {
|
||||||
runRoutine.mutate({ id: routine.id, data: {} });
|
runRoutine.mutate({ id: routine.id, data: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
|
||||||
|
updateRoutineStatus.mutate({
|
||||||
|
id: routine.id,
|
||||||
|
status: nextRoutineStatus(routine.status, !enabled),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleArchived(routine: RoutineListItem) {
|
||||||
|
updateRoutineStatus.mutate({
|
||||||
|
id: routine.id,
|
||||||
|
status: routine.status === "archived" ? "active" : "archived",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||||
}
|
}
|
||||||
|
|
@ -293,6 +570,68 @@ export function Routines() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||||
|
<PageTabBar
|
||||||
|
align="start"
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
items={[
|
||||||
|
{ value: "routines", label: "Routines" },
|
||||||
|
{ value: "runs", label: "Recent Runs" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<TabsContent value="routines" className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs">
|
||||||
|
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||||
|
<span className="hidden sm:inline">Group</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-44 p-0">
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{([
|
||||||
|
["project", "Project"],
|
||||||
|
["assignee", "Agent"],
|
||||||
|
["none", "None"],
|
||||||
|
] as const).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
|
||||||
|
routineViewState.groupBy === value
|
||||||
|
? "bg-accent/50 text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="runs">
|
||||||
|
<IssuesList
|
||||||
|
issues={routineExecutionIssues ?? []}
|
||||||
|
isLoading={recentRunsLoading}
|
||||||
|
error={recentRunsError as Error | null}
|
||||||
|
agents={agents}
|
||||||
|
projects={projects}
|
||||||
|
liveIssueIds={liveIssueIds}
|
||||||
|
viewStateKey="paperclip:routine-recent-runs-view"
|
||||||
|
issueLinkState={recentRunsIssueLinkState}
|
||||||
|
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={composerOpen}
|
open={composerOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -560,165 +899,64 @@ export function Routines() {
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
{activeTab === "routines" ? (
|
||||||
{(routines ?? []).length === 0 ? (
|
<div>
|
||||||
<div className="py-12">
|
{(routines ?? []).length === 0 ? (
|
||||||
<EmptyState
|
<div className="py-12">
|
||||||
icon={Repeat}
|
<EmptyState
|
||||||
message="No routines yet. Use Create routine to define the first recurring workflow."
|
icon={Repeat}
|
||||||
/>
|
message="No routines yet. Use Create routine to define the first recurring workflow."
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="overflow-x-auto">
|
) : (
|
||||||
<table className="min-w-full text-sm">
|
<div className="rounded-lg border border-border">
|
||||||
<thead>
|
{routineGroups.map((group) => (
|
||||||
<tr className="text-left text-xs text-muted-foreground border-b border-border">
|
<Collapsible
|
||||||
<th className="px-3 py-2 font-medium">Name</th>
|
key={group.key}
|
||||||
<th className="px-3 py-2 font-medium">Project</th>
|
open={!routineViewState.collapsedGroups.includes(group.key)}
|
||||||
<th className="px-3 py-2 font-medium">Agent</th>
|
onOpenChange={(open) => {
|
||||||
<th className="px-3 py-2 font-medium">Last run</th>
|
updateRoutineView({
|
||||||
<th className="px-3 py-2 font-medium">Enabled</th>
|
collapsedGroups: open
|
||||||
<th className="w-12 px-3 py-2" />
|
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
|
||||||
</tr>
|
: [...routineViewState.collapsedGroups, group.key],
|
||||||
</thead>
|
});
|
||||||
<tbody>
|
}}
|
||||||
{(routines ?? []).map((routine) => {
|
>
|
||||||
const enabled = routine.status === "active";
|
{group.label ? (
|
||||||
const isArchived = routine.status === "archived";
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
const isStatusPending = statusMutationRoutineId === routine.id;
|
<CollapsibleTrigger className="flex items-center gap-1.5">
|
||||||
return (
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
|
||||||
<tr
|
<span className="text-sm font-semibold uppercase tracking-wide">
|
||||||
key={routine.id}
|
{group.label}
|
||||||
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
|
</span>
|
||||||
onClick={() => navigate(`/routines/${routine.id}`)}
|
</CollapsibleTrigger>
|
||||||
>
|
<span className="text-xs text-muted-foreground">
|
||||||
<td className="px-3 py-2.5">
|
{group.items.length}
|
||||||
<div className="min-w-[180px]">
|
</span>
|
||||||
<span className="font-medium">
|
</div>
|
||||||
{routine.title}
|
) : null}
|
||||||
</span>
|
<CollapsibleContent>
|
||||||
{(isArchived || routine.status === "paused") && (
|
{group.items.map((routine) => (
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<RoutineListRow
|
||||||
{isArchived ? "archived" : "paused"}
|
key={routine.id}
|
||||||
</div>
|
routine={routine}
|
||||||
)}
|
projectById={projectById}
|
||||||
</div>
|
agentById={agentById}
|
||||||
</td>
|
runningRoutineId={runningRoutineId}
|
||||||
<td className="px-3 py-2.5">
|
statusMutationRoutineId={statusMutationRoutineId}
|
||||||
{routine.projectId ? (
|
onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
onRunNow={handleRunNow}
|
||||||
<span
|
onToggleEnabled={handleToggleEnabled}
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
onToggleArchived={handleToggleArchived}
|
||||||
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }}
|
/>
|
||||||
/>
|
))}
|
||||||
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
|
</CollapsibleContent>
|
||||||
</div>
|
</Collapsible>
|
||||||
) : (
|
))}
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="px-3 py-2.5">
|
) : null}
|
||||||
{routine.assigneeAgentId ? (() => {
|
|
||||||
const agent = agentById.get(routine.assigneeAgentId);
|
|
||||||
return agent ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">{agent.name}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">Unknown</span>
|
|
||||||
);
|
|
||||||
})() : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-muted-foreground">
|
|
||||||
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
|
|
||||||
{routine.lastRun ? (
|
|
||||||
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
data-slot="toggle"
|
|
||||||
aria-checked={enabled}
|
|
||||||
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
|
|
||||||
disabled={isStatusPending || isArchived}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
enabled ? "bg-foreground" : "bg-muted"
|
|
||||||
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
|
|
||||||
onClick={() =>
|
|
||||||
updateRoutineStatus.mutate({
|
|
||||||
id: routine.id,
|
|
||||||
status: nextRoutineStatus(routine.status, !enabled),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
|
|
||||||
enabled ? "translate-x-5" : "translate-x-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isArchived ? "Archived" : enabled ? "On" : "Off"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={runningRoutineId === routine.id || isArchived}
|
|
||||||
onClick={() => handleRunNow(routine)}
|
|
||||||
>
|
|
||||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
updateRoutineStatus.mutate({
|
|
||||||
id: routine.id,
|
|
||||||
status: enabled ? "paused" : "active",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isStatusPending || isArchived}
|
|
||||||
>
|
|
||||||
{enabled ? "Pause" : "Enable"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
updateRoutineStatus.mutate({
|
|
||||||
id: routine.id,
|
|
||||||
status: routine.status === "archived" ? "active" : "archived",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isStatusPending}
|
|
||||||
>
|
|
||||||
{routine.status === "archived" ? "Restore" : "Archive"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RoutineRunVariablesDialog
|
<RoutineRunVariablesDialog
|
||||||
open={runDialogRoutine !== null}
|
open={runDialogRoutine !== null}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue