diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index e11298ef81..64b58326d1 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1032,6 +1032,7 @@ Dashboard payload must include: The current app also exposes V1-supporting surfaces for: +- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary - issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`) - issue approvals, issue references/search, labels, read state, inbox/archive state, and work products - company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index edad4e924f..75c16a1536 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -221,7 +221,12 @@ export const ISSUE_HARNESS_KINDS = ["skill_test"] as const; export type IssueHarnessKind = (typeof ISSUE_HARNESS_KINDS)[number]; export const MAX_ISSUE_REQUEST_DEPTH = 1024; -export const SUMMARY_SLOT_SCOPE_KINDS = ["project", "workspaces_overview", "project_workspace"] as const; +export const SUMMARY_SLOT_SCOPE_KINDS = [ + "project", + "workspaces_overview", + "project_workspace", + "execution_workspace", +] as const; export type SummarySlotScopeKind = (typeof SUMMARY_SLOT_SCOPE_KINDS)[number]; export const SUMMARY_SLOT_KEYS = ["header"] as const; export type SummarySlotKey = (typeof SUMMARY_SLOT_KEYS)[number]; diff --git a/packages/shared/src/summary-slot.test.ts b/packages/shared/src/summary-slot.test.ts index 46c1de5f49..5f8e222dea 100644 --- a/packages/shared/src/summary-slot.test.ts +++ b/packages/shared/src/summary-slot.test.ts @@ -20,7 +20,7 @@ describe("summary slot shared contract", () => { ); }); - it("allows scoped project and project-workspace header slots", () => { + it("allows scoped project, project-workspace, and execution-workspace header slots", () => { expect(summarySlotScopeSelectorSchema.parse({ scopeKind: "project", scopeId, @@ -32,6 +32,12 @@ describe("summary slot shared contract", () => { scopeId, slotKey: "header", })).toEqual({ scopeKind: "project_workspace", scopeId, slotKey: "header" }); + + expect(summarySlotScopeSelectorSchema.parse({ + scopeKind: "execution_workspace", + scopeId, + slotKey: "header", + })).toEqual({ scopeKind: "execution_workspace", scopeId, slotKey: "header" }); }); it("treats workspaces_overview as a company-scoped singleton", () => { @@ -56,6 +62,10 @@ describe("summary slot shared contract", () => { scopeKind: "project_workspace", slotKey: "header", })).toThrow("project_workspace summary slots require scopeId"); + expect(() => summarySlotScopeSelectorSchema.parse({ + scopeKind: "execution_workspace", + slotKey: "header", + })).toThrow("execution_workspace summary slots require scopeId"); }); it("validates summary write payload revision and generation metadata", () => { diff --git a/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md b/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md index fe14c3840f..fb303e99d3 100644 --- a/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md +++ b/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md @@ -15,7 +15,7 @@ tags: # Summarize status -You are the Summarizer. Turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision. +You are the Summarizer. Turn the current state of a Paperclip scope — a project, the workspaces overview, a project workspace, or a specific execution workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision. **Open with what the reader needs to do.** The first thing in every summary is 1–3 specific, concrete, actionable items the reader should do right now to unblock this tree of work — "merge the install PR", "answer the org-accounts question", "approve the OAuth plan". Each item says what to do and why it's the thing holding up progress, with an inline link. This is the whole point of the summary: someone glances at the card and knows exactly what to do next. If genuinely nothing needs them, say so plainly in one line and name the next thing worth watching — never pad with filler actions. @@ -27,7 +27,7 @@ This is a **read-and-report** loop. You never change the underlying issues, work ## When to use -- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, or `project_workspace`) and slot (`header`). +- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, `project_workspace`, or `execution_workspace`) and slot (`header`). - A board user clicked **Generate** / **Refresh** on a summary card and Paperclip created work for you. - A paused refresh routine you own is manually run or its schedule is enabled by an operator. @@ -41,8 +41,8 @@ This is a **read-and-report** loop. You never change the underlying issues, work From the generation issue / run context: -- `scopeKind` — `project`, `workspaces_overview`, or `project_workspace`. -- `scopeId` — the project or project-workspace id. Omitted for `workspaces_overview` (it has no scopeId). +- `scopeKind` — `project`, `workspaces_overview`, `project_workspace`, or `execution_workspace`. +- `scopeId` — the project, project-workspace, or execution-workspace id. Omitted for `workspaces_overview` (it has no scopeId). - `slotKey` — currently always `header`. - `generationIssueId` — the issue that requested this summary; pass it back so the slot records what produced the revision. - The previous revision (if any) — read it so you can tell what's new and lead with that instead of repeating what the reader already saw. @@ -55,6 +55,7 @@ Use these routes directly. Do not guess unscoped `/api/issues` or alternate summ - Read the current slot: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}?scopeId=...` - Read revision history only when the current-slot response is missing its latest document: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/revisions?scopeId=...` - Gather project issues: `GET /api/companies/{companyId}/issues?projectId=...` +- Gather execution-workspace issues: `GET /api/companies/{companyId}/issues?executionWorkspaceId=...` - Write the new revision: `PUT /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}` with `scopeId`, `markdown`, `changeSummary`, `baseRevisionId`, `generationIssueId`, and `model` in the JSON body. For `workspaces_overview`, omit `scopeId` from the read query and send it as `null` in the write body. All calls use the run-scoped Paperclip API URL and bearer token already present in the environment. diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json index ad63e17569..d126606ea1 100644 --- a/packages/skills-catalog/generated/catalog.json +++ b/packages/skills-catalog/generated/catalog.json @@ -172,11 +172,11 @@ { "path": "SKILL.md", "kind": "skill", - "sizeBytes": 8682, - "sha256": "c5f459ced4e97e6ae33c3ffbe7b3fb7d4c1fe7bfb0121187f0c2f9000a670b37" + "sizeBytes": 8877, + "sha256": "4cb3d177f5d16e3cc2052d2f80e7fb245ab95e79c1e947abdc84cd4ee3d8c61e" } ], - "contentHash": "sha256:32d2f231a35fc3a658b244f13dd726b2f2bc642db6d3512559fdf9a2b680838d" + "contentHash": "sha256:3e49ee2b8d83d1371fb0f59197bb96c46b64f722540405f46d4a5a756fbe1b89" }, { "id": "paperclipai:bundled:paperclip-operations:task-planning", diff --git a/server/src/__tests__/summary-slots.test.ts b/server/src/__tests__/summary-slots.test.ts index 2bbb1fd517..ac9fba3bff 100644 --- a/server/src/__tests__/summary-slots.test.ts +++ b/server/src/__tests__/summary-slots.test.ts @@ -8,6 +8,7 @@ import { createDb, documentRevisions, documents, + executionWorkspaces, heartbeatRuns, issues, projectWorkspaces, @@ -50,6 +51,7 @@ describeEmbeddedPostgres("summary slot service", () => { await db.delete(documents); await db.delete(issues); await db.delete(heartbeatRuns); + await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects); await db.delete(activityLog); @@ -78,6 +80,35 @@ describeEmbeddedPostgres("summary slot service", () => { return projectId; } + async function seedProjectWorkspace(companyId: string, projectId: string) { + const projectWorkspaceId = randomUUID(); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + return projectWorkspaceId; + } + + async function seedExecutionWorkspace( + companyId: string, + projectId: string, + projectWorkspaceId: string | null = null, + ) { + const executionWorkspaceId = randomUUID(); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: `Execution workspace ${executionWorkspaceId}`, + }); + return executionWorkspaceId; + } + async function seedSummarizer(companyId: string, ready = true) { const agentId = randomUUID(); await db.insert(agents).values({ @@ -117,6 +148,15 @@ describeEmbeddedPostgres("summary slot service", () => { return { companyId, scopeKind: "project", slotKey: "header", scopeId: projectId }; } + function executionWorkspaceSelector(companyId: string, executionWorkspaceId: string) { + return { + companyId, + scopeKind: "execution_workspace", + slotKey: "header", + scopeId: executionWorkspaceId, + }; + } + describe("reads and target visibility", () => { it("returns an empty slot state before any generation", async () => { const companyId = await seedCompany(); @@ -144,6 +184,18 @@ describeEmbeddedPostgres("summary slot service", () => { }); }); + it("rejects an execution workspace owned by another company", async () => { + const companyId = await seedCompany(); + const otherCompanyId = await seedCompany(); + const otherProjectId = await seedProject(otherCompanyId); + const foreignExecutionWorkspaceId = await seedExecutionWorkspace(otherCompanyId, otherProjectId); + const svc = summarySlotService(db); + + await expect( + svc.getSlot(executionWorkspaceSelector(companyId, foreignExecutionWorkspaceId)), + ).rejects.toMatchObject({ status: 404 }); + }); + it("rejects a workspaces_overview selector that carries a scopeId", async () => { const companyId = await seedCompany(); const svc = summarySlotService(db); @@ -268,6 +320,65 @@ describeEmbeddedPostgres("summary slot service", () => { expect(issueRow.description).not.toContain("Other project issue"); }); + it("keeps summaries and snapshots isolated between execution workspaces", async () => { + const companyId = await seedCompany(); + const projectId = await seedProject(companyId); + const projectWorkspaceId = await seedProjectWorkspace(companyId, projectId); + const firstExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + const secondExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); + await seedSummarizer(companyId); + const svc = summarySlotService(db); + + await db.insert(issues).values([ + { + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: firstExecutionWorkspaceId, + identifier: `${issuePrefix(companyId)}-201`, + issueNumber: 201, + title: "First workspace task", + status: "in_progress", + priority: "medium", + }, + { + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId: secondExecutionWorkspaceId, + identifier: `${issuePrefix(companyId)}-202`, + issueNumber: 202, + title: "Second workspace task", + status: "blocked", + priority: "high", + }, + ]); + + const firstSelector = executionWorkspaceSelector(companyId, firstExecutionWorkspaceId); + const secondSelector = executionWorkspaceSelector(companyId, secondExecutionWorkspaceId); + const generated = await svc.generate(firstSelector, { userId: "board-user" }); + + expect(generated.slot).toMatchObject({ + scopeKind: "execution_workspace", + scopeId: firstExecutionWorkspaceId, + status: "generating", + }); + const generationIssue = await db + .select() + .from(issues) + .where(eq(issues.id, generated.generatingIssue.id)) + .then((rows) => rows[0]!); + expect(generationIssue.description).toContain("First workspace task"); + expect(generationIssue.description).not.toContain("Second workspace task"); + expect(generationIssue.description).toContain('"scopeKind": "execution_workspace"'); + expect(generationIssue.description).toContain(`"scopeId": "${firstExecutionWorkspaceId}"`); + await expect(svc.getSlot(secondSelector)).resolves.toEqual({ + slot: null, + document: null, + generatingIssue: null, + }); + }); + it("dedupes concurrent generate clicks without creating an orphan task", async () => { const companyId = await seedCompany(); const projectId = await seedProject(companyId); diff --git a/server/src/built-ins/agents/summarizer/AGENTS.md b/server/src/built-ins/agents/summarizer/AGENTS.md index abc7f4b696..60c9a3ea7f 100644 --- a/server/src/built-ins/agents/summarizer/AGENTS.md +++ b/server/src/built-ins/agents/summarizer/AGENTS.md @@ -2,11 +2,11 @@ You are Summarizer, a built-in reporting agent at Paperclip. When you wake up, follow the Paperclip heartbeat procedure. Work only on issues assigned to you. Always leave a task comment before exiting a heartbeat. -Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes. +Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, a project workspace, or a specific execution workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes. ## Core responsibilities -- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace`, plus `scopeId` and `slotKey`). +- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace` | `execution_workspace`, plus `scopeId` and `slotKey`). - Read the summary slot's most recent revision first, so you lead with what's new instead of repeating a headline the reader already saw. - Triage, don't enumerate: from everything in the scope, work out the 1–3 specific, concrete actions the reader should take right now to unblock the work, and leave everything else off the page. Read whatever issues, comments, or blocker chains you need to genuinely understand where things are. - Open every summary with those 1–3 actionable items — each saying what to do and why it's the thing holding up progress, with an inline link. If genuinely nothing needs the reader, say so plainly in one line and name the next thing worth watching. diff --git a/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md b/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md index 4c051a831b..8920f0ae03 100644 --- a/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md +++ b/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md @@ -32,6 +32,7 @@ variables: - project - workspaces_overview - project_workspace + - execution_workspace triggers: - kind: schedule label: Daily stale-summary refresh diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index b8a25460c7..40c46efe72 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -181,7 +181,7 @@ const FALLBACK_REFLECTION_COACH_SKILL = [ const FALLBACK_SUMMARIZER_INSTRUCTIONS = [ "You are Summarizer, a built-in reporting agent at Paperclip.", "", - "Turn the current state of a Paperclip scope (project, workspaces overview, or a single project workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.", + "Turn the current state of a Paperclip scope (project, workspaces overview, project workspace, or execution workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.", "", "Read-and-report only: never change issues, workspaces, or code. Cite issue identifiers, never fabricate status, keep every read company-scoped, and run on the low-cost model profile lane by default.", "", @@ -401,7 +401,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([ displayName: "Summarizer", featureKeys: ["summarizer"], shortPurpose: - "Writes short, human-readable Markdown status summaries into project, workspaces-overview, and project-workspace summary slots on demand.", + "Writes short, human-readable Markdown status summaries into project, workspaces-overview, project-workspace, and execution-workspace summary slots on demand.", defaultInstructions: SUMMARIZER_INSTRUCTIONS, defaultRole: "general", defaultTitle: "Summarizer", @@ -419,7 +419,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([ }, defaultBudgetMonthlyCents: 0, bundle: { - stockVersion: "2026-07-15", + stockVersion: "2026-08-02", instructions: { entryFile: "AGENTS.md", files: { @@ -452,7 +452,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([ type: "select", defaultValue: "all", required: true, - options: ["all", "project", "workspaces_overview", "project_workspace"], + options: ["all", "project", "workspaces_overview", "project_workspace", "execution_workspace"], }, ], triggers: [ diff --git a/server/src/services/summary-slots.ts b/server/src/services/summary-slots.ts index bcd6f0c543..6f07b83872 100644 --- a/server/src/services/summary-slots.ts +++ b/server/src/services/summary-slots.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { documentRevisions, documents, + executionWorkspaces, issues, projectWorkspaces, projects, @@ -123,7 +124,9 @@ function scopeLabel(scopeKind: SummarySlotScopeKind): string { case "project": return "project"; case "project_workspace": - return "workspace"; + return "project workspace"; + case "execution_workspace": + return "execution workspace"; case "workspaces_overview": return "workspaces overview"; default: @@ -175,6 +178,15 @@ export function summarySlotService(db: Db) { .where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId))) .then((rows) => rows[0] ?? null); if (!row) throw notFound("Summary target not found"); + return; + } + if (sel.scopeKind === "execution_workspace") { + const row = await db + .select({ id: executionWorkspaces.id }) + .from(executionWorkspaces) + .where(and(eq(executionWorkspaces.id, sel.scopeId), eq(executionWorkspaces.companyId, sel.companyId))) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Summary target not found"); } } @@ -299,9 +311,10 @@ export function summarySlotService(db: Db) { async function resolveGenerationTargetProject(sel: ResolvedSelector): Promise<{ projectId: string | null; projectWorkspaceId: string | null; + executionWorkspaceId: string | null; }> { if (sel.scopeKind === "project") { - return { projectId: sel.scopeId, projectWorkspaceId: null }; + return { projectId: sel.scopeId, projectWorkspaceId: null, executionWorkspaceId: null }; } if (sel.scopeKind === "project_workspace" && sel.scopeId) { const row = await db @@ -309,14 +322,34 @@ export function summarySlotService(db: Db) { .from(projectWorkspaces) .where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId))) .then((rows) => rows[0] ?? null); - return { projectId: row?.projectId ?? null, projectWorkspaceId: sel.scopeId }; + return { + projectId: row?.projectId ?? null, + projectWorkspaceId: sel.scopeId, + executionWorkspaceId: null, + }; } - return { projectId: null, projectWorkspaceId: null }; + if (sel.scopeKind === "execution_workspace" && sel.scopeId) { + const row = await db + .select({ + projectId: executionWorkspaces.projectId, + projectWorkspaceId: executionWorkspaces.projectWorkspaceId, + }) + .from(executionWorkspaces) + .where(and(eq(executionWorkspaces.id, sel.scopeId), eq(executionWorkspaces.companyId, sel.companyId))) + .then((rows) => rows[0] ?? null); + return { + projectId: row?.projectId ?? null, + projectWorkspaceId: row?.projectWorkspaceId ?? null, + executionWorkspaceId: sel.scopeId, + }; + } + return { projectId: null, projectWorkspaceId: null, executionWorkspaceId: null }; } function scopeIssueConditions(sel: ResolvedSelector) { if (sel.scopeKind === "project") return [eq(issues.projectId, sel.scopeId!)]; if (sel.scopeKind === "project_workspace") return [eq(issues.projectWorkspaceId, sel.scopeId!)]; + if (sel.scopeKind === "execution_workspace") return [eq(issues.executionWorkspaceId, sel.scopeId!)]; return []; } @@ -464,7 +497,7 @@ export function summarySlotService(db: Db) { } } - const { projectId, projectWorkspaceId } = await resolveGenerationTargetProject(sel); + const { projectId, projectWorkspaceId, executionWorkspaceId } = await resolveGenerationTargetProject(sel); const scopeSnapshot = await buildScopeSnapshot(sel, existing?.lastGeneratedAt ?? null); const createdAt = new Date(); const generationVersion = existing?.generatingIssueId ?? existing?.updatedAt.toISOString() ?? "initial"; @@ -472,6 +505,7 @@ export function summarySlotService(db: Db) { const created = await issuesSvc.create(sel.companyId, { projectId, projectWorkspaceId, + executionWorkspaceId, title: generationIssueTitle(sel, createdAt), description: generationIssueDescription(sel, scopeSnapshot), status: "todo", diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index faa981a03f..d4356c0d21 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -288,15 +288,15 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { }); }); - it("shows the linked project workspace summary above tasks", async () => { + it("shows a summary scoped to the execution workspace above tasks", async () => { mockExecutionWorkspacesApi.get.mockResolvedValue(workspace({ projectWorkspaceId: "project-workspace-1" })); await render(); expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({ companyId: "company-1", - scopeKind: "project_workspace", - scopeId: "project-workspace-1", + scopeKind: "execution_workspace", + scopeId: "workspace-1", title: "Workspace summary", })); const summary = container.querySelector('[data-testid="summary-slot-card"]'); @@ -307,11 +307,15 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { expect(summary.compareDocumentPosition(issues) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); - it("does not show a project workspace summary for standalone execution workspaces", async () => { + it("shows an isolated summary for standalone execution workspaces", async () => { await render(); - expect(mockSummarySlotCard).not.toHaveBeenCalled(); - expect(container.querySelector('[data-testid="summary-slot-card"]')).toBeNull(); + expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + scopeKind: "execution_workspace", + scopeId: "workspace-1", + })); + expect(container.querySelector('[data-testid="summary-slot-card"]')).not.toBeNull(); }); it("does not mount plugin slots scoped to other entity types", async () => { diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 2cc119a542..9fc6cf47bb 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -1523,15 +1523,13 @@ export function ExecutionWorkspaceDetail() { ) : activeTab === "issues" ? (
- {workspace.projectWorkspaceId ? ( - - ) : null} +