From 2f83ffd2a5947a167a874fcd93eb9dd1315af2d0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 6 Oct 2026 13:02:39 -0700 Subject: [PATCH 1/5] feat(issues): issue tracking as a workspace resource --- .claude/rules/sim-list-ordering.md | 2 +- .cursor/rules/sim-list-ordering.mdc | 2 +- apps/docs/openapi-v2-resources.json | 590 +- .../api/mothership/chats/[chatId]/route.ts | 33 +- .../sim/app/api/v2/issues/[issueKey]/route.ts | 25 + apps/sim/app/api/v2/issues/route.ts | 25 + apps/sim/app/api/v2/issues/utils.ts | 23 + .../[id]/issues/[key]/approve/route.ts | 19 + .../[id]/issues/[key]/close/route.ts | 19 + .../[key]/comments/[commentId]/route.ts | 23 + .../[id]/issues/[key]/comments/route.ts | 19 + .../[id]/issues/[key]/reopen/route.ts | 19 + .../issues/[key]/request-changes/route.ts | 19 + .../[id]/issues/[key]/request-review/route.ts | 23 + .../[id]/issues/[key]/resources/route.ts | 39 + .../api/workspaces/[id]/issues/[key]/route.ts | 29 + .../[id]/issues/[key]/start/route.ts | 23 + .../issues/[key]/tickets/[ticketId]/route.ts | 23 + .../[id]/issues/[key]/tickets/route.ts | 19 + .../app/api/workspaces/[id]/issues/route.ts | 29 + .../components/composer/composer.test.tsx | 3 + apps/sim/app/o/[organizationId]/layout.tsx | 35 +- .../components/file-viewer/file-viewer.tsx | 10 +- .../rich-markdown-editor.tsx | 31 +- .../available-resources.ts | 17 + .../organization-resource-inventory.tsx | 19 +- .../resource-content/resource-content.tsx | 42 + .../resource-invalidation.ts | 5 + .../resource-registry/resource-registry.tsx | 11 + .../user-input/components/constants.ts | 2 + .../plus-menu-dropdown/plus-menu-dropdown.tsx | 6 + .../[workspaceId]/issues/[key]/page.tsx | 13 + .../[workspaceId]/issues/components/index.ts | 3 + .../issues/components/issue-activity.tsx | 224 + .../issues/components/issue-detail.tsx | 423 + .../issues/components/issue-properties.tsx | 220 + .../issues/components/issue-status.tsx | 43 + .../issues/components/issues-feature-gate.tsx | 19 + .../issues/components/new-issue-modal.tsx | 89 + .../issues/components/priority-icon.tsx | 69 + .../workspace/[workspaceId]/issues/issues.tsx | 134 + .../workspace/[workspaceId]/issues/layout.tsx | 10 + .../workspace/[workspaceId]/issues/page.tsx | 13 + .../app/workspace/[workspaceId]/layout.tsx | 4 + .../providers/feature-flags-provider.tsx | 1 + .../w/components/sidebar/sidebar.tsx | 11 + apps/sim/hooks/queries/issues.ts | 175 + apps/sim/lib/api/contracts/issues.ts | 275 + .../lib/api/contracts/mothership-issues.ts | 41 + .../mothership-management-tools.test.ts | 5 + .../contracts/mothership-management-tools.ts | 9 + apps/sim/lib/api/contracts/v2/issues.ts | 83 + .../lib/api/contracts/v2/openapi/resources.ts | 94 +- apps/sim/lib/api/contracts/workspace-files.ts | 2 +- .../lib/api/mcp/generated/v2-operations.ts | 17 + .../server/routes/v2-route-table.generated.ts | 8 + apps/sim/lib/collab-doc/collab-state.test.ts | 3 +- apps/sim/lib/collab-doc/collab-state.ts | 3 +- apps/sim/lib/collab-doc/persist.ts | 13 +- apps/sim/lib/collab-doc/seed.test.ts | 5 +- apps/sim/lib/collab-doc/seed.ts | 8 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 5 + .../lib/data-drains/sources/copilot-chats.ts | 1 + apps/sim/lib/internal/file/operations.test.ts | 18 +- .../issue-body-access.integration.ts | 186 + .../lib/issues/application/availability.ts | 28 + apps/sim/lib/issues/application/issues.ts | 666 + apps/sim/lib/issues/application/operations.ts | 59 + apps/sim/lib/issues/application/records.ts | 94 + apps/sim/lib/issues/feature-flag.ts | 26 + apps/sim/lib/issues/repository.integration.ts | 175 + apps/sim/lib/issues/repository.ts | 435 + apps/sim/lib/issues/types.ts | 63 + .../mothership/agent-cli/resource-effects.ts | 18 +- apps/sim/lib/mothership/agent-cli/services.ts | 3 +- .../application/execute-issue-use-case.ts | 13 + apps/sim/lib/mothership/chat/payload.test.ts | 3 + apps/sim/lib/mothership/chat/post.ts | 1 + apps/sim/lib/mothership/entitlements.ts | 5 + .../sim/lib/mothership/generated/agent-cli.ts | 1 + apps/sim/lib/mothership/generated/protocol.ts | 2 +- .../sim/lib/mothership/generated/resources.ts | 1 + apps/sim/lib/mothership/resources/types.ts | 3 + .../tools/server/files/workspace-file.ts | 12 +- .../sim/lib/mothership/tools/server/issues.ts | 83 + .../sim/lib/mothership/tools/server/router.ts | 2 + apps/sim/lib/mothership/vfs/path-utils.ts | 6 +- apps/sim/lib/uploads/config.ts | 3 + .../workspace-file-live-doc-outbox.ts | 3 +- .../workspace/workspace-file-manager.ts | 185 +- .../workspace-file-secret-provenance.ts | 4 +- apps/sim/lib/uploads/shared/types.ts | 1 + .../edit-workspace-file-content.ts | 14 +- .../export-workspace-file-snapshot.test.ts | 1 + .../export-workspace-file-snapshot.ts | 15 +- .../application/file-versions.ts | 47 +- .../read-workspace-file-artifact.ts | 6 +- ...read-workspace-file-content-by-key.test.ts | 6 +- .../read-workspace-file-content-by-key.ts | 17 +- .../read-workspace-file-content.test.ts | 2 + .../read-workspace-file-content.ts | 12 +- .../read-workspace-file-metadata.ts | 15 +- .../read-workspace-file-record.test.ts | 2 + .../application/read-workspace-file-record.ts | 16 +- .../read-workspace-file-secret-provenance.ts | 18 +- .../application/read-workspace-file-text.ts | 5 +- .../resolve-workspace-file-reference.test.ts | 3 +- .../resolve-workspace-file-reference.ts | 26 +- .../update-workspace-file-content.ts | 22 +- .../application/workspace-file-context.ts | 33 + apps/sim/lib/workspace-files/query-scope.ts | 6 + .../secret-provenance-context.ts | 9 + packages/audit/src/types.ts | 9 + packages/db/migrations/0400_issue.sql | 201 + .../db/migrations/meta/0400_snapshot.json | 30881 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 160 +- packages/db/scripts/apply-issue-triggers.ts | 41 + packages/db/scripts/push.ts | 1 + .../chip-dropdown/chip-dropdown.tsx | 27 +- packages/sim-cli/src/contract/commands.ts | 3 + packages/sim-cli/src/generated/v2-api.ts | 93 + packages/sim-cli/src/http/client.test.ts | 1 + .../src/mocks/schema-tables.generated.ts | 40 + scripts/check-spec-example-ids.ts | 1 + ...check-tool-registry-boundary.baseline.json | 149 +- 127 files changed, 36975 insertions(+), 221 deletions(-) create mode 100644 apps/sim/app/api/v2/issues/[issueKey]/route.ts create mode 100644 apps/sim/app/api/v2/issues/route.ts create mode 100644 apps/sim/app/api/v2/issues/utils.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/approve/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/close/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/comments/[commentId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/comments/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/reopen/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/request-changes/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/request-review/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/resources/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/start/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/[ticketId]/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/route.ts create mode 100644 apps/sim/app/api/workspaces/[id]/issues/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/[key]/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/issue-activity.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/issue-detail.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/issue-properties.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/issue-status.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/issues-feature-gate.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/new-issue-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/components/priority-icon.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/issues.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/layout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/issues/page.tsx create mode 100644 apps/sim/hooks/queries/issues.ts create mode 100644 apps/sim/lib/api/contracts/issues.ts create mode 100644 apps/sim/lib/api/contracts/mothership-issues.ts create mode 100644 apps/sim/lib/api/contracts/v2/issues.ts create mode 100644 apps/sim/lib/issues/__integration__/issue-body-access.integration.ts create mode 100644 apps/sim/lib/issues/application/availability.ts create mode 100644 apps/sim/lib/issues/application/issues.ts create mode 100644 apps/sim/lib/issues/application/operations.ts create mode 100644 apps/sim/lib/issues/application/records.ts create mode 100644 apps/sim/lib/issues/feature-flag.ts create mode 100644 apps/sim/lib/issues/repository.integration.ts create mode 100644 apps/sim/lib/issues/repository.ts create mode 100644 apps/sim/lib/issues/types.ts create mode 100644 apps/sim/lib/mothership/application/execute-issue-use-case.ts create mode 100644 apps/sim/lib/mothership/tools/server/issues.ts create mode 100644 apps/sim/lib/workspace-files/secret-provenance-context.ts create mode 100644 packages/db/migrations/0400_issue.sql create mode 100644 packages/db/migrations/meta/0400_snapshot.json create mode 100644 packages/db/scripts/apply-issue-triggers.ts diff --git a/.claude/rules/sim-list-ordering.md b/.claude/rules/sim-list-ordering.md index de9448635b8..0c56793e405 100644 --- a/.claude/rules/sim-list-ordering.md +++ b/.claude/rules/sim-list-ordering.md @@ -104,7 +104,7 @@ An order duplicated across surfaces is an order that will drift. Export **one** ```ts /** Top-down order for every menu listing resource families. */ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ - 'integration', 'task', 'dashboard', 'table', 'file', 'filefolder', + 'integration', 'task', 'dashboard', 'issue', 'table', 'file', 'filefolder', 'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic', ] diff --git a/.cursor/rules/sim-list-ordering.mdc b/.cursor/rules/sim-list-ordering.mdc index a11aa415ab9..2424d42f0f7 100644 --- a/.cursor/rules/sim-list-ordering.mdc +++ b/.cursor/rules/sim-list-ordering.mdc @@ -103,7 +103,7 @@ An order duplicated across surfaces is an order that will drift. Export **one** ```ts /** Top-down order for every menu listing resource families. */ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ - 'integration', 'task', 'dashboard', 'table', 'file', 'filefolder', + 'integration', 'task', 'dashboard', 'issue', 'table', 'file', 'filefolder', 'knowledgebase', 'workflow', 'log', 'folder', 'browser', 'terminal', 'generic', ] diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 2426b9b5ce9..3a8219c8d0a 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Resources", - "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, organization permission groups, and the block, tool, and connector-type catalogs.", + "description": "Version 2 of the Sim REST API for workspace metadata, members, issues, MCP servers, skills, custom tools, sandboxes, credentials, write-only secrets, organization permission groups, and the block, tool, and connector-type catalogs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -49,6 +49,10 @@ "name": "Skills", "description": "Create and manage reusable instruction documents for agents." }, + { + "name": "Issues", + "description": "File issues and read their status; Sim works on them in chats." + }, { "name": "Custom Tools", "description": "Create and manage code-backed tools that agents can call." @@ -1633,6 +1637,157 @@ } } }, + "/api/v2/issues": { + "post": { + "operationId": "createIssue", + "summary": "Create Issue", + "description": "File an issue in a workspace. The body is the issue document as markdown; it lands in the inbox as new until someone starts work on it with Sim. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "issues.create", + "x-oauth-scope": "api:write", + "tags": ["Issues"], + "requestBody": { + "required": true, + "description": "Title and markdown document of a new issue.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIssueRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The issue was filed.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIssueResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/issues/{issueKey}": { + "get": { + "operationId": "getIssue", + "summary": "Get Issue", + "description": "Get one issue by its key, scoped to its workspace. Read the document with Read File Text on its bodyFileId. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "issues.read", + "x-oauth-scope": "api:read", + "tags": ["Issues"], + "parameters": [ + { + "name": "issueKey", + "in": "path", + "required": true, + "description": "Issue key, such as SIM-152.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "description": "Issue key, such as SIM-152." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the issue.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the issue." + } + } + ], + "responses": { + "200": { + "description": "The issue.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetIssueResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/custom-tools": { "get": { "operationId": "listCustomTools", @@ -10803,6 +10958,439 @@ } ] }, + "CreateIssueResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique issue identifier." + }, + "key": { + "type": "string", + "description": "Workspace-readable key, such as SIM-152." + }, + "title": { + "type": "string", + "description": "What is going wrong, in one line." + }, + "status": { + "type": "string", + "enum": ["inbox", "in_progress", "done"], + "description": "inbox waits on a person, in_progress has a Sim chat working on it, done is closed." + }, + "inboxKind": { + "anyOf": [ + { + "type": "string", + "enum": ["new", "review"] + }, + { + "type": "null" + } + ], + "description": "For inbox issues: new until Sim works on it, review once Sim hands it back." + }, + "closeReason": { + "anyOf": [ + { + "type": "string", + "enum": ["completed", "dismissed", "duplicate"] + }, + { + "type": "null" + } + ], + "description": "Why a done issue closed: completed, dismissed, or duplicate." + }, + "priority": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + } + ], + "description": "0 is no priority, then low, medium, high, and 4 is urgent." + }, + "ownerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member who owns the issue; null until work starts." + }, + "workingChatId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The Sim chat working on the issue, if any." + }, + "reviewSummary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Sim's one-line result when it asked for review." + }, + "bodyFileId": { + "type": "string", + "description": "File holding the issue document as markdown. Read and replace it with the file content operations." + }, + "createdAt": { + "type": "string", + "description": "When the issue was filed (ISO 8601)." + }, + "updatedAt": { + "type": "string", + "description": "When the issue last changed (ISO 8601)." + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When work first started with Sim." + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the issue was closed." + } + }, + "required": [ + "id", + "key", + "title", + "status", + "inboxKind", + "closeReason", + "priority", + "ownerId", + "workingChatId", + "reviewSummary", + "bodyFileId", + "createdAt", + "updatedAt", + "startedAt", + "completedAt" + ], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create issue response", + "description": "The filed issue.", + "examples": [ + { + "data": { + "id": "6f0b2c4e-1d8a-4c3b-9e7f-2a5d8c1b4e90", + "key": "SIM-152", + "title": "Order-status escalations spiked this week", + "status": "inbox", + "inboxKind": "new", + "closeReason": null, + "priority": 4, + "ownerId": null, + "workingChatId": null, + "reviewSummary": null, + "bodyFileId": "wf_V1StGXR8Z5jdHi6BmyT", + "createdAt": "2026-09-18T09:02:00.000Z", + "updatedAt": "2026-09-18T09:02:00.000Z", + "startedAt": null, + "completedAt": null + } + } + ] + }, + "CreateIssueRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace to file the issue in." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "What is going wrong, in one line." + }, + "body": { + "type": "string", + "maxLength": 1048576, + "description": "The issue document as markdown, including any dashboard, diff, or mermaid blocks." + }, + "priority": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + } + ], + "description": "0 is no priority, then low, medium, high, and 4 is urgent." + } + }, + "required": ["workspaceId", "title", "body"], + "additionalProperties": false, + "title": "Create issue request", + "description": "Title and markdown document of a new issue.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "Order-status escalations spiked this week", + "body": "## What we are seeing\n\nSeven escalations this week, against a typical two.\n", + "priority": 4 + } + ] + }, + "GetIssueResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique issue identifier." + }, + "key": { + "type": "string", + "description": "Workspace-readable key, such as SIM-152." + }, + "title": { + "type": "string", + "description": "What is going wrong, in one line." + }, + "status": { + "type": "string", + "enum": ["inbox", "in_progress", "done"], + "description": "inbox waits on a person, in_progress has a Sim chat working on it, done is closed." + }, + "inboxKind": { + "anyOf": [ + { + "type": "string", + "enum": ["new", "review"] + }, + { + "type": "null" + } + ], + "description": "For inbox issues: new until Sim works on it, review once Sim hands it back." + }, + "closeReason": { + "anyOf": [ + { + "type": "string", + "enum": ["completed", "dismissed", "duplicate"] + }, + { + "type": "null" + } + ], + "description": "Why a done issue closed: completed, dismissed, or duplicate." + }, + "priority": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + } + ], + "description": "0 is no priority, then low, medium, high, and 4 is urgent." + }, + "ownerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member who owns the issue; null until work starts." + }, + "workingChatId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The Sim chat working on the issue, if any." + }, + "reviewSummary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Sim's one-line result when it asked for review." + }, + "bodyFileId": { + "type": "string", + "description": "File holding the issue document as markdown. Read and replace it with the file content operations." + }, + "createdAt": { + "type": "string", + "description": "When the issue was filed (ISO 8601)." + }, + "updatedAt": { + "type": "string", + "description": "When the issue last changed (ISO 8601)." + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When work first started with Sim." + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "When the issue was closed." + } + }, + "required": [ + "id", + "key", + "title", + "status", + "inboxKind", + "closeReason", + "priority", + "ownerId", + "workingChatId", + "reviewSummary", + "bodyFileId", + "createdAt", + "updatedAt", + "startedAt", + "completedAt" + ], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get issue response", + "description": "One issue.", + "examples": [ + { + "data": { + "id": "6f0b2c4e-1d8a-4c3b-9e7f-2a5d8c1b4e90", + "key": "SIM-152", + "title": "Order-status escalations spiked this week", + "status": "inbox", + "inboxKind": "new", + "closeReason": null, + "priority": 4, + "ownerId": null, + "workingChatId": null, + "reviewSummary": null, + "bodyFileId": "wf_V1StGXR8Z5jdHi6BmyT", + "createdAt": "2026-09-18T09:02:00.000Z", + "updatedAt": "2026-09-18T09:02:00.000Z", + "startedAt": null, + "completedAt": null + } + } + ] + }, "V2CustomTool": { "type": "object", "properties": { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 6be0e4ada77..6debfa46bed 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -11,6 +11,7 @@ import { } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { detachChatFromIssuesInTx } from '@/lib/issues/repository' import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript' import { getAccessibleCopilotChatAuth, @@ -239,21 +240,25 @@ export const DELETE = withRouteHandler( return NextResponse.json({ success: true }) } - const [deletedChat] = await db - .update(copilotChats) - .set({ deletedAt: new Date() }) - .where( - and( - eq(copilotChats.id, chatId), - eq(copilotChats.userId, userId), - eq(copilotChats.type, 'mothership'), - isNull(copilotChats.deletedAt) + const deletedChat = await db.transaction(async (tx) => { + const [row] = await tx + .update(copilotChats) + .set({ deletedAt: new Date() }) + .where( + and( + eq(copilotChats.id, chatId), + eq(copilotChats.userId, userId), + eq(copilotChats.type, 'mothership'), + isNull(copilotChats.deletedAt) + ) ) - ) - .returning({ - workspaceId: copilotChats.workspaceId, - organizationId: copilotChats.organizationId, - }) + .returning({ + workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, + }) + if (row) await detachChatFromIssuesInTx(tx, chatId) + return row + }) if (!deletedChat) { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) diff --git a/apps/sim/app/api/v2/issues/[issueKey]/route.ts b/apps/sim/app/api/v2/issues/[issueKey]/route.ts new file mode 100644 index 00000000000..5dad254b83b --- /dev/null +++ b/apps/sim/app/api/v2/issues/[issueKey]/route.ts @@ -0,0 +1,25 @@ +import { v2GetIssueContract } from '@/lib/api/contracts/v2/issues' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getIssueDetail } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' +import { toV2Issue } from '@/app/api/v2/issues/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/issues/[issueKey] — Get an issue by its key. */ +export const GET = defineV2JsonRoute({ + contract: v2GetIssueContract, + operation: issueOperations.read, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Issue not found' }), + mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, key: params.issueKey }), + useCase: getIssueDetail, + present: ({ issue }) => ({ data: toV2Issue(issue) }), +}) diff --git a/apps/sim/app/api/v2/issues/route.ts b/apps/sim/app/api/v2/issues/route.ts new file mode 100644 index 00000000000..10352b8bc57 --- /dev/null +++ b/apps/sim/app/api/v2/issues/route.ts @@ -0,0 +1,25 @@ +import { v2CreateIssueContract } from '@/lib/api/contracts/v2/issues' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { createIssue } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' +import { toV2Issue } from '@/app/api/v2/issues/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** POST /api/v2/issues — File an issue. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateIssueContract, + operation: issueOperations.create, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: createIssue, + present: ({ issue }) => ({ data: toV2Issue(issue) }), +}) diff --git a/apps/sim/app/api/v2/issues/utils.ts b/apps/sim/app/api/v2/issues/utils.ts new file mode 100644 index 00000000000..17360e2c092 --- /dev/null +++ b/apps/sim/app/api/v2/issues/utils.ts @@ -0,0 +1,23 @@ +import type { IssueRecord } from '@/lib/api/contracts/issues' +import type { V2Issue } from '@/lib/api/contracts/v2/issues' + +/** The v2 projection of an issue: ids instead of display names. */ +export function toV2Issue(issue: IssueRecord): V2Issue { + return { + id: issue.id, + key: issue.key, + title: issue.title, + status: issue.status, + inboxKind: issue.inboxKind, + closeReason: issue.closeReason, + priority: issue.priority, + ownerId: issue.owner?.id ?? null, + workingChatId: issue.workingChat?.id ?? null, + reviewSummary: issue.reviewSummary, + bodyFileId: issue.bodyFileId, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + startedAt: issue.startedAt, + completedAt: issue.completedAt, + } +} diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/approve/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/approve/route.ts new file mode 100644 index 00000000000..ec048d473fc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/approve/route.ts @@ -0,0 +1,19 @@ +import { approveIssueContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { approveIssue } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: approveIssueContract, + auth: internalSessionAuth, + operation: issueOperations.review, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id, key: params.key }), + useCase: approveIssue, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/close/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/close/route.ts new file mode 100644 index 00000000000..9143132722b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/close/route.ts @@ -0,0 +1,19 @@ +import { closeIssueContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { closeIssue } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: closeIssueContract, + auth: internalSessionAuth, + operation: issueOperations.close, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, key: params.key, ...body }), + useCase: closeIssue, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/comments/[commentId]/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/comments/[commentId]/route.ts new file mode 100644 index 00000000000..44bde81cf73 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/comments/[commentId]/route.ts @@ -0,0 +1,23 @@ +import { deleteIssueCommentContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { deleteIssueComment } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const DELETE = defineInternalJsonRoute({ + contract: deleteIssueCommentContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + workspaceId: params.id, + key: params.key, + commentId: params.commentId, + }), + useCase: deleteIssueComment, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/comments/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/comments/route.ts new file mode 100644 index 00000000000..e84e8d9c34d --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/comments/route.ts @@ -0,0 +1,19 @@ +import { addIssueCommentContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { addIssueComment } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: addIssueCommentContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, key: params.key, body: body.body }), + useCase: addIssueComment, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/reopen/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/reopen/route.ts new file mode 100644 index 00000000000..37dc53f837b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/reopen/route.ts @@ -0,0 +1,19 @@ +import { reopenIssueContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { reopenIssue } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: reopenIssueContract, + auth: internalSessionAuth, + operation: issueOperations.close, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id, key: params.key }), + useCase: reopenIssue, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/request-changes/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/request-changes/route.ts new file mode 100644 index 00000000000..5c3bc465a49 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/request-changes/route.ts @@ -0,0 +1,19 @@ +import { requestIssueChangesContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { requestIssueChanges } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: requestIssueChangesContract, + auth: internalSessionAuth, + operation: issueOperations.review, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, key: params.key, note: body.note }), + useCase: requestIssueChanges, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/request-review/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/request-review/route.ts new file mode 100644 index 00000000000..9f77b94cbdd --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/request-review/route.ts @@ -0,0 +1,23 @@ +import { requestIssueReviewContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { requestIssueReview } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: requestIssueReviewContract, + auth: internalSessionAuth, + operation: issueOperations.review, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + key: params.key, + summary: body.summary, + }), + useCase: requestIssueReview, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/resources/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/resources/route.ts new file mode 100644 index 00000000000..7794fd6901b --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/resources/route.ts @@ -0,0 +1,39 @@ +import { addIssueResourceContract, removeIssueResourceContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { addIssueResource, removeIssueResource } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: addIssueResourceContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + key: params.key, + resourceType: body.type, + resourceId: body.resourceId, + }), + useCase: addIssueResource, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: removeIssueResourceContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + key: params.key, + resourceType: body.type, + resourceId: body.resourceId, + }), + useCase: removeIssueResource, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/route.ts new file mode 100644 index 00000000000..b28aea049b1 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/route.ts @@ -0,0 +1,29 @@ +import { getIssueContract, updateIssueContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getIssueDetail, updateIssue } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: getIssueContract, + auth: internalSessionAuth, + operation: issueOperations.read, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id, key: params.key }), + useCase: getIssueDetail, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateIssueContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, key: params.key, ...body }), + useCase: updateIssue, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/start/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/start/route.ts new file mode 100644 index 00000000000..8c39a53d0b9 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/start/route.ts @@ -0,0 +1,23 @@ +import { startIssueContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { startIssue } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: startIssueContract, + auth: internalSessionAuth, + operation: issueOperations.start, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + key: params.key, + chatId: body.chatId, + }), + useCase: startIssue, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/[ticketId]/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/[ticketId]/route.ts new file mode 100644 index 00000000000..7de0d69490e --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/[ticketId]/route.ts @@ -0,0 +1,23 @@ +import { unlinkIssueTicketContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { unlinkIssueTicket } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const DELETE = defineInternalJsonRoute({ + contract: unlinkIssueTicketContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + workspaceId: params.id, + key: params.key, + ticketId: params.ticketId, + }), + useCase: unlinkIssueTicket, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/route.ts b/apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/route.ts new file mode 100644 index 00000000000..a3295f9c2f1 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/[key]/tickets/route.ts @@ -0,0 +1,19 @@ +import { linkIssueTicketContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { linkIssueTicket } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: linkIssueTicketContract, + auth: internalSessionAuth, + operation: issueOperations.update, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, key: params.key, ticket: body }), + useCase: linkIssueTicket, +}) diff --git a/apps/sim/app/api/workspaces/[id]/issues/route.ts b/apps/sim/app/api/workspaces/[id]/issues/route.ts new file mode 100644 index 00000000000..40d23816214 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/issues/route.ts @@ -0,0 +1,29 @@ +import { createIssueContract, listIssuesContract } from '@/lib/api/contracts/issues' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { createIssue, listIssues } from '@/lib/issues/application/issues' +import { issueOperations } from '@/lib/issues/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listIssuesContract, + auth: internalSessionAuth, + operation: issueOperations.read, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: listIssues, +}) + +export const POST = defineInternalJsonRoute({ + contract: createIssueContract, + auth: internalSessionAuth, + operation: issueOperations.create, + rateLimit: internalRateLimits.user({ bucketName: 'issues' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: createIssue, +}) diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index aa1466b9499..c82e529ff9c 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -232,6 +232,7 @@ async function render( { ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 4c3dc3564d0..db5049ee7c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -2,6 +2,7 @@ import { memo, + type ReactNode, useCallback, useEffect, useImperativeHandle, @@ -238,19 +239,33 @@ interface RichMarkdownEditorProps { * listener, so only one may be enabled at a time — see {@link useFindShortcut}. */ enableFind?: boolean + /** Rendered above the document inside its scroll container, so it scrolls with the content. */ + header?: ReactNode + /** Rendered below the document inside its scroll container. */ + footer?: ReactNode } /** Source fallback unmounts the rich surface so only one editing engine owns the local draft. */ export const RichMarkdownEditor = memo(function RichMarkdownEditor(props: RichMarkdownEditorProps) { const [sourceFileId, setSourceFileId] = useState(null) - if (sourceFileId === props.file.id) - return ( + if (sourceFileId === props.file.id) { + const source = ( ) + if (!props.header && !props.footer) return source + // The source editor scrolls itself, so the surrounding chrome scrolls around a fixed-height editor. + return ( +
+ {props.header} +
{source}
+ {props.footer} +
+ ) + } return setSourceFileId(props.file.id)} /> }) @@ -280,6 +295,8 @@ function RichMarkdownSurface({ onDeriveTitleFromHeading, enableFind = false, onEditSource, + header, + footer, }: RichMarkdownSurfaceProps) { const { data: session, isPending: isSessionPending } = useSession() const userId = session?.user?.id ?? '' @@ -370,6 +387,8 @@ function RichMarkdownSurface({ onDeriveTitleFromHeading={onDeriveTitleFromHeading} enableFind={enableFind} onEditSource={onEditSource} + header={header} + footer={footer} /> ) @@ -407,6 +426,10 @@ interface LoadedRichMarkdownEditorProps { /** See {@link RichMarkdownEditorProps.enableFind}. */ enableFind: boolean onEditSource?: () => void + /** See {@link RichMarkdownEditorProps.header}. */ + header?: ReactNode + /** See {@link RichMarkdownEditorProps.footer}. */ + footer?: ReactNode } type CollaborationStatus = 'connecting' | 'ready' | 'reconnecting' | 'fatal' @@ -452,6 +475,8 @@ export function LoadedRichMarkdownEditor({ onDeriveTitleFromHeading, enableFind, onEditSource, + header, + footer, }: LoadedRichMarkdownEditorProps) { /** Whether this editor mounted mid-stream — if so it starts empty and syncs streamed chunks until settle. */ const [streamingAtMount] = useState(isStreaming) @@ -1440,6 +1465,7 @@ export function LoadedRichMarkdownEditor({ void insertImagesRef.current(images, range) }} /> + {header} {showPlaceholder && placeholder && ( + {footer} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts index 70bf34e89cc..653fbe003ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts @@ -13,6 +13,7 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature- import { listIntegrationsByPopularity } from '@/blocks/integration-matcher' import { useWorkspaceDashboard } from '@/hooks/queries/dashboards' import { useFolders } from '@/hooks/queries/folders' +import { useIssueList } from '@/hooks/queries/issues' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useLogsList } from '@/hooks/queries/logs' import { useMothershipChats } from '@/hooks/queries/mothership-chats' @@ -97,6 +98,7 @@ export function useAvailableResources( options?: UseAvailableResourcesOptions ): AvailableResources { const dashboardsEnabled = useFeatureFlag('dashboards') + const issuesEnabled = useFeatureFlag('issues') const enabled = options?.enabled ?? true const excludeTypes = options?.excludeTypes const browserAvailable = useSyncExternalStore( @@ -121,6 +123,9 @@ export function useAvailableResources( const { data: dashboardData, isPending: dashboardsPending } = useWorkspaceDashboard(workspaceId, { enabled: enabled && dashboardsEnabled && !excludeTypes?.includes('dashboard'), }) + const { data: issues, isPending: issuesPending } = useIssueList(workspaceId, { + enabled: enabled && issuesEnabled && !excludeTypes?.includes('issue'), + }) const { data: files, isPending: filesPending } = useWorkspaceFiles(workspaceId, 'active', { enabled: enabled && Boolean(workspaceId), }) @@ -172,6 +177,7 @@ export function useAvailableResources( tablesPending || filesPending || (dashboardsEnabled && !excludeTypes?.includes('dashboard') && dashboardsPending) || + (issuesEnabled && !excludeTypes?.includes('issue') && issuesPending) || knowledgeBasesPending || foldersPending || (options?.includeFolderMentions && @@ -185,6 +191,7 @@ export function useAvailableResources( if (!enabled) return NO_RESOURCE_GROUPS const excluded = new Set(excludeTypes ?? []) if (!dashboardsEnabled) excluded.add('dashboard') + if (!issuesEnabled) excluded.add('issue') const groups: AvailableItemsByType[] = [ { type: 'workflow' as const, @@ -218,6 +225,14 @@ export function useAvailableResources( ? [{ id: dashboardData.dashboard.id, name: dashboardData.dashboard.name, folderId: null }] : [], }, + { + type: 'issue' as const, + items: (issues ?? []).map((issue) => ({ + id: issue.key, + name: `${issue.key} ${issue.title}`, + folderId: null, + })), + }, { type: 'file' as const, items: (files ?? []).map((f) => ({ id: f.id, name: f.name, folderId: f.folderId ?? null })), @@ -311,11 +326,13 @@ export function useAvailableResources( tables, files, dashboardData, + issues, knowledgeBases, tasks, logs, excludeTypes, dashboardsEnabled, + issuesEnabled, ]) /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx index 1dbf07425e5..047e84cea83 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/organization-resource-inventory.tsx @@ -3,16 +3,25 @@ import { type AvailableResources, useAvailableResources, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' +import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' /** Reuses each workspace's canonical inventory without introducing another picker. */ +interface OrganizationResourceInventoryProps { + workspaceId: string + onChange: (workspaceId: string, inventory: AvailableResources) => void + /** Must be a module constant: it keys the inventory's group memo. */ + excludeTypes?: readonly MothershipResourceType[] +} + export function OrganizationResourceInventory({ workspaceId, onChange, -}: { - workspaceId: string - onChange: (workspaceId: string, inventory: AvailableResources) => void -}) { - const inventory = useAvailableResources(workspaceId, { includeFolderMentions: true }) + excludeTypes, +}: OrganizationResourceInventoryProps) { + const inventory = useAvailableResources(workspaceId, { + includeFolderMentions: true, + excludeTypes, + }) useEffect(() => { onChange(workspaceId, inventory) }, [workspaceId, inventory, onChange]) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 2329d717c9a..aafceadd276 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -39,6 +39,7 @@ import { type PreviewMode, resolveFileCategory, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' import type { BrowserPanelOverlayController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion' import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' @@ -46,6 +47,8 @@ import { TerminalSession } from '@/app/workspace/[workspaceId]/home/components/m import { RESOURCE_TAB_ICON_CLASS } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import { hasRenderableFilePreviewContent } from '@/app/workspace/[workspaceId]/home/hooks/preview' import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { IssuesFeatureGate } from '@/app/workspace/[workspaceId]/issues/components' +import { IssueView } from '@/app/workspace/[workspaceId]/issues/components/issue-detail' import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' import { LogDetailsContent } from '@/app/workspace/[workspaceId]/logs/components' import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' @@ -296,6 +299,16 @@ export const ResourceContent = memo(function ResourceContent({ case 'dashboard': return + case 'issue': + return ( + + +
+ +
+
+
+ ) case 'file': return ( + case 'issue': + return case 'table': return case 'log': @@ -543,6 +558,33 @@ function EmbeddedDashboardActions({ workspaceId }: EmbeddedDashboardActionsProps ) } +interface EmbeddedIssueActionsProps { + workspaceId: string + issueKey: string +} + +function EmbeddedIssueActions({ workspaceId, issueKey }: EmbeddedIssueActionsProps) { + const router = useRouter() + const issuesEnabled = useFeatureFlag('issues') + if (!issuesEnabled) return null + return ( + + + router.push(`/workspace/${workspaceId}/issues/${issueKey}`)} + aria-label='Open issue' + > + + + + +

Open issue

+
+
+ ) +} + interface EmbeddedKnowledgeBaseActionsProps { workspaceId: string knowledgeBaseId: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts index 6962cdab5f8..d1608a4e3a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation.ts @@ -2,6 +2,7 @@ import type { QueryClient, QueryKey } from '@tanstack/react-query' import type { MothershipResourceType } from '@/lib/mothership/resources/types' import { dashboardKeys } from '@/hooks/queries/dashboards' import { deploymentKeys, invalidateDeploymentQueries } from '@/hooks/queries/deployments' +import { issueKeys } from '@/hooks/queries/issues' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' import { folderKeys } from '@/hooks/queries/utils/folder-keys' @@ -28,6 +29,10 @@ const RESOURCE_INVALIDATORS: Record< invalidate(qc, id ? tableKeys.views(id) : tableKeys.viewsRoot()) }, dashboard: (qc, wId) => invalidate(qc, dashboardKeys.workspace(wId)), + issue: (qc, wId, key) => { + invalidate(qc, issueKeys.list(wId)) + invalidate(qc, key ? issueKeys.detail(wId, key) : issueKeys.details()) + }, file: (qc, wId, id) => { invalidate(qc, workspaceFilesKeys.lists()) invalidate(qc, id ? workspaceFilesKeys.record(wId, id) : workspaceFilesKeys.records()) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1a9de189f0d..7f8a3898635 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -10,6 +10,7 @@ import { Folder as FolderIcon, Globe, Library, + ListChecks, Search, Table as TableIcon, Task, @@ -193,6 +194,15 @@ export const RESOURCE_REGISTRY: Record , }, + issue: { + type: 'issue', + label: 'Issues', + icon: ListChecks, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, file: { type: 'file', label: 'Files', @@ -299,6 +309,7 @@ export const RESOURCE_MENU_ORDER: readonly MothershipResourceType[] = [ 'integration', 'task', 'dashboard', + 'issue', 'table', 'file', 'filefolder', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 4bb8d632e97..7e8bcffa8ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -133,6 +133,8 @@ const RESOURCE_TO_CONTEXT: Record< ...(r.viewId ? { viewId: r.viewId } : {}), }), dashboard: (r) => ({ kind: 'dashboard', dashboardId: r.id, label: r.title }), + /** No chat context: Sim reads an issue by key or as `issues/.md`, so the attach menu omits issues. */ + issue: () => null, file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }), folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }), filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx index 3759f6e3d81..1adb4b099cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx @@ -49,6 +49,9 @@ import { useSettledTerminalCommands } from '@/hooks/use-settled-terminal-command import { useBrowserSessionStore } from '@/stores/browser-session/store' import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store' +/** Resource types with no chat context to attach (see `mapResourceToContext`). */ +const CHAT_CONTEXTLESS_RESOURCE_TYPES: readonly MothershipResourceType[] = ['issue'] + /** * The `@` list is shorter than the emcn menu default (420px, sized for right-click * action menus). This one floats directly over the chat input, so a menu tall enough @@ -85,6 +88,7 @@ const MENTION_ONLY_RESOURCE_TYPES = new Set(['integratio */ const WORKSPACE_SUBMENU_EXCLUDED_TYPES: readonly MothershipResourceType[] = [ ...MENTION_ONLY_RESOURCE_TYPES, + ...CHAT_CONTEXTLESS_RESOURCE_TYPES, 'browser', 'terminal', ] @@ -154,6 +158,7 @@ export const PlusMenuDropdown = React.memo( const workspaceInventory = useAvailableResources(organizationId ? '' : workspaceId, { enabled: inventoryEnabled, includeFolderMentions: true, + excludeTypes: CHAT_CONTEXTLESS_RESOURCE_TYPES, }) const { data: allWorkspaces, isPending: workspacesPending } = useOrderedWorkspacesQuery( Boolean(organizationId) && inventoryEnabled @@ -412,6 +417,7 @@ export const PlusMenuDropdown = React.memo( key={workspace.id} workspaceId={workspace.id} onChange={receiveInventory} + excludeTypes={CHAT_CONTEXTLESS_RESOURCE_TYPES} /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/issues/[key]/page.tsx b/apps/sim/app/workspace/[workspaceId]/issues/[key]/page.tsx new file mode 100644 index 00000000000..c8a2133f341 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/issues/[key]/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import { IssueDetail } from '@/app/workspace/[workspaceId]/issues/components/issue-detail' + +export const metadata: Metadata = { title: 'Issue', robots: { index: false } } + +interface IssuePageProps { + params: Promise<{ workspaceId: string; key: string }> +} + +export default async function IssuePage({ params }: IssuePageProps) { + const { workspaceId, key } = await params + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/issues/components/index.ts b/apps/sim/app/workspace/[workspaceId]/issues/components/index.ts new file mode 100644 index 00000000000..9fe5d9fa5f2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/issues/components/index.ts @@ -0,0 +1,3 @@ +export { IssueStatusIcon, issueStatusLabel } from './issue-status' +export { IssuesFeatureGate } from './issues-feature-gate' +export { NewIssueModal } from './new-issue-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/issues/components/issue-activity.tsx b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-activity.tsx new file mode 100644 index 00000000000..3daf1a6cb2d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-activity.tsx @@ -0,0 +1,224 @@ +'use client' + +import { useState } from 'react' +import { Avatar, Chip, ChipTextarea } from '@sim/emcn' +import type { IssueEvent } from '@/lib/api/contracts/issues' +import { useSession } from '@/lib/auth/auth-client' +import { ISSUE_PRIORITY_LABELS, type IssuePriority } from '@/lib/issues/types' +import { useAddIssueComment, useDeleteIssueComment } from '@/hooks/queries/issues' + +function text(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** One sentence per change; Sim's document edits read as section changes, not diffs. */ +function describe(event: IssueEvent): { what: string; detail?: string } { + const p = event.payload + switch (event.kind) { + case 'created': + return { what: 'filed this' } + case 'renamed': + return { what: 'renamed this', detail: `${text(p.from)} → ${text(p.to)}` } + case 'section_added': + return { what: `added ${text(p.heading)}`, detail: text(p.preview) || undefined } + case 'section_removed': + return { what: `removed ${text(p.heading)}` } + case 'priority_changed': + return { what: `set priority to ${ISSUE_PRIORITY_LABELS[p.to as IssuePriority] ?? ''}` } + case 'owner_changed': + return { what: p.to ? 'changed the owner' : 'removed the owner' } + case 'work_started': + return { what: 'started work with Sim' } + case 'review_requested': + return { what: 'requested review', detail: text(p.summary) || undefined } + case 'approved': + return { what: 'approved and closed this' } + case 'changes_requested': + return { what: 'requested changes', detail: text(p.note) || undefined } + case 'closed': + return { + what: + p.reason === 'duplicate' + ? `closed this as a duplicate of ${text(p.duplicateOf)}` + : p.reason === 'dismissed' + ? 'dismissed this' + : 'closed this', + } + case 'reopened': + return { what: 'reopened this' } + case 'resource_added': + return { what: `added a ${text(p.type).replace('_', ' ')}` } + case 'resource_removed': + return { what: `removed a ${text(p.type).replace('_', ' ')}` } + case 'ticket_linked': + return { what: `linked ${text(p.key)}` } + case 'ticket_unlinked': + return { what: `unlinked ${text(p.key)}` } + case 'chat_detached': + return { what: 'lost its working chat' } + default: + return { what: event.kind.replaceAll('_', ' ') } + } +} + +function actorName(event: IssueEvent, names: Map): string { + if (!event.actor) return 'The issue' + if (event.actor.kind === 'sim') return 'Sim' + if (event.actor.kind === 'workflow') return 'A workflow' + return (event.actor.userId && names.get(event.actor.userId)?.split(' ')[0]) || 'Someone' +} + +const timeFormat = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}) + +interface IssueActivityProps { + workspaceId: string + issueKey: string + events: IssueEvent[] + members: Map + canEdit: boolean +} + +/** The issue's changes and comments in order, with a box to add a comment. */ +export function IssueActivity({ + workspaceId, + issueKey, + events, + members, + canEdit, +}: IssueActivityProps) { + const names = new Map([...members].map(([id, member]) => [id, member.name])) + return ( +
+
    + {events.map((event) => + event.kind === 'commented' ? ( + + ) : ( + + ) + )} +
+ {canEdit && } +
+ ) +} + +interface IssueChangeProps { + event: IssueEvent + names: Map +} + +function IssueChange({ event, names }: IssueChangeProps) { + const { what, detail } = describe(event) + return ( +
  • + + + {actorName(event, names)} {what} + + + + {detail && {detail}} +
  • + ) +} + +interface IssueCommentProps { + workspaceId: string + issueKey: string + event: IssueEvent + author: { name: string; image: string | null } | undefined +} + +function IssueComment({ workspaceId, issueKey, event, author }: IssueCommentProps) { + const { data: session } = useSession() + const deleteComment = useDeleteIssueComment() + const isOwn = Boolean(event.actor?.userId && event.actor.userId === session?.user?.id) + const name = author?.name ?? 'Someone' + return ( +
  • + + + {name} + + {isOwn && ( + + deleteComment.mutate({ workspaceId, key: issueKey, commentId: event.id }) + } + > + Delete + + )} + +

    + {text(event.payload.body)} +

    +
  • + ) +} + +interface CommentComposerProps { + workspaceId: string + issueKey: string +} + +function CommentComposer({ workspaceId, issueKey }: CommentComposerProps) { + const [body, setBody] = useState('') + const addComment = useAddIssueComment() + const submit = () => { + if (!body.trim() || addComment.isPending) return + const submitted = body + addComment.mutate( + { workspaceId, key: issueKey, body: submitted }, + { onSuccess: () => setBody((current) => (current === submitted ? '' : current)) } + ) + } + return ( +
    + setBody(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + submit() + } + }} + placeholder='Leave a comment' + rows={3} + aria-label='Comment' + /> + {addComment.error && ( +

    + {addComment.error.message} +

    + )} + + Comment + +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/issues/components/issue-detail.tsx b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-detail.tsx new file mode 100644 index 00000000000..9c146e07f6a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-detail.tsx @@ -0,0 +1,423 @@ +'use client' + +import { useCallback, useState } from 'react' +import { Chip, ChipLink, cn, pageHeadingClassName, toast } from '@sim/emcn' +import { ArrowUpRight, ListChecks } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { ShimmerText } from '@/components/ui/shimmer-text' +import { requestJson } from '@/lib/api/client/request' +import type { IssueDetail as IssueDetailData, IssueRecord } from '@/lib/api/contracts/issues' +import { + addMothershipChatResourceContract, + createMothershipChatContract, +} from '@/lib/api/contracts/mothership-chats' +import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/mothership/constants' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' +import { IssueActivity } from '@/app/workspace/[workspaceId]/issues/components/issue-activity' +import { IssueProperties } from '@/app/workspace/[workspaceId]/issues/components/issue-properties' +import { IssueStatusIcon } from '@/app/workspace/[workspaceId]/issues/components/issue-status' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + useApproveIssue, + useCloseIssue, + useIssue, + useReopenIssue, + useRequestIssueChanges, + useStartIssue, +} from '@/hooks/queries/issues' +import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' +import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { useAddressedWorkspaceFileRecord } from '@/hooks/queries/workspace-files' + +const dateFormat = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }) + +/** Matches the document editor's reading column so the bar, title, and footer line up with the text. */ +const COLUMN_CLASS = 'mx-auto w-full max-w-[48rem] px-8' + +interface IssueDetailProps { + workspaceId: string + issueKey: string +} + +export function IssueDetail({ workspaceId, issueKey }: IssueDetailProps) { + const router = useRouter() + const query = useIssue(workspaceId, issueKey) + const issue = query.data?.issue + const canEdit = useUserPermissionsContext().canEdit === true + const closeIssue = useCloseIssue() + const reopenIssue = useReopenIssue() + + const actions = !issue + ? [] + : issue.status === 'done' + ? [ + { + text: 'Reopen', + disabled: !canEdit || reopenIssue.isPending, + onSelect: () => reopenIssue.mutate({ workspaceId, key: issue.key }), + }, + ] + : [ + { + text: 'Dismiss', + disabled: !canEdit || closeIssue.isPending, + onSelect: () => closeIssue.mutate({ workspaceId, key: issue.key, reason: 'dismissed' }), + }, + ] + + return ( + + + router.push(`/workspace/${workspaceId}/issues`), + }, + { label: issue?.key ?? issueKey }, + ]} + actions={actions} + /> + + + + ) +} + +/** + * The issue's document, shared by the issue page and its resource tab in Chat. Render it inside a + * {@link FileDocRoomProvider} so collaborators show up in the document. + * + * Reads like a post: key and filer above the title, a row of properties under it, and one + * highlighted line for the next step. Once the properties scroll away, a compact bar takes over. + */ +export function IssueView({ workspaceId, issueKey }: IssueDetailProps) { + const query = useIssue(workspaceId, issueKey) + const issue = query.data?.issue + const file = useAddressedWorkspaceFileRecord(workspaceId, issue?.bodyFileId ?? '', { + enabled: Boolean(issue), + }) + const canEdit = useUserPermissionsContext().canEdit === true + const [bylineOutOfView, setBylineOutOfView] = useState(false) + + const bylineRef = useCallback((node: HTMLDivElement | null) => { + if (!node) return + const observer = new IntersectionObserver(([entry]) => + setBylineOutOfView(!entry.isIntersecting) + ) + observer.observe(node) + return () => observer.disconnect() + }, []) + + if (query.error) { + return ( +

    + {query.error.message} +

    + ) + } + if (!issue || !query.data) return null + + return ( +
    + + {file.data ? ( + + +

    {issue.title}

    +
    + +
    +
    + +
    +
    + } + footer={} + /> + ) : file.error ? ( +

    + {file.error.message} +

    + ) : null} + + ) +} + +/** Key, who filed it, and when, above the title. */ +interface IssueEyebrowProps { + issue: IssueRecord +} + +function IssueEyebrow({ issue }: IssueEyebrowProps) { + return ( +

    + {[issue.key, filedBy(issue), dateFormat.format(new Date(issue.createdAt))].join(' · ')} +

    + ) +} + +interface IssueBarProps { + workspaceId: string + issue: IssueRecord + canEdit: boolean + visible: boolean +} + +/** Once the properties scroll away, a compact bar keeps the title and the actions in reach. */ +function IssueBar({ workspaceId, issue, canEdit, visible }: IssueBarProps) { + return ( +
    +
    + {issue.title} +
    + +
    +
    +
    + ) +} + +interface IssueFooterProps { + workspaceId: string + detail: IssueDetailData + canEdit: boolean +} + +/** The activity timeline and comments, read after the document. */ +function IssueFooter({ workspaceId, detail, canEdit }: IssueFooterProps) { + const { events } = detail + const members = useWorkspaceMembersQuery(workspaceId) + const people = new Map( + (members.data ?? []).map((member) => [ + member.userId, + { name: member.name, image: member.image }, + ]) + ) + + return ( +
    +
    +
    +

    + Activity +

    + +
    +
    + ) +} + +/** The prompt that opens an issue's chat; Sim reads and writes the issue document by key. */ +function issuePrompt(issue: IssueRecord, followUp: boolean): string { + const document = `issues/${issue.key}.md` + return followUp + ? `Follow up on ${issue.key}: ${issue.title}. The issue document is ${document}.` + : `Work on ${issue.key}: ${issue.title}. The issue document is ${document}. Read it, find the cause, write what you learn into it, and ask me to review when it is fixed.` +} + +interface IssueAssistantProps { + workspaceId: string + issue: IssueRecord + canEdit: boolean +} + +function filedBy(issue: IssueRecord): string { + if (issue.filedBy.kind === 'workflow') return 'Filed by a workflow' + if (issue.filedBy.kind === 'sim') return 'Filed by Sim' + return issue.filedBy.name ? `Filed by ${issue.filedBy.name}` : 'Filed' +} + +function closedAs(issue: IssueRecord): string { + if (issue.closeReason === 'dismissed') return 'Dismissed' + if (issue.closeReason === 'duplicate') return 'Closed as a duplicate' + return 'Approved and closed' +} + +/** + * One highlighted line under the byline. A new issue asks to be started; from then on the line is + * Sim: what it is doing, its result to review, or how the issue closed, with the action for that. + */ +function IssueAssistant({ workspaceId, issue, canEdit }: IssueAssistantProps) { + const chatHref = issue.workingChat + ? `/workspace/${workspaceId}/chat/${issue.workingChat.id}` + : null + const isNew = issue.status === 'inbox' && issue.inboxKind === 'new' + const isReview = issue.status === 'inbox' && issue.inboxKind === 'review' + + return ( +
    + + + + + {isNew ? ( + <> + {filedBy(issue)} + · Start it to have Sim work on it + + ) : issue.status === 'in_progress' ? ( + issue.workingChat?.running ? ( + Sim is working + ) : ( + <> + Sim stopped + · Continue in the chat + + ) + ) : isReview ? ( + (issue.reviewSummary ?? 'Sim finished and is waiting for your review') + ) : ( + closedAs(issue) + )} + + +
    + ) +} + +interface IssueActionsProps { + workspaceId: string + issue: IssueRecord + canEdit: boolean + chatHref: string | null +} + +/** The actions for the issue's state; the one black chip is what moves it forward. */ +function IssueActions({ workspaceId, issue, canEdit, chatHref }: IssueActionsProps) { + const queryClient = useQueryClient() + const startIssue = useStartIssue() + const approveIssue = useApproveIssue() + const requestChanges = useRequestIssueChanges() + const [opening, setOpening] = useState(false) + + /** + * Starts a chat on the issue without leaving the page: the chat gets the issue as a tab and its + * first message, and Sim keeps working after this page stops listening to the stream. + */ + async function startChat(followUp: boolean) { + setOpening(true) + let started = false + try { + const chat = await requestJson(createMothershipChatContract, { body: { workspaceId } }) + await requestJson(addMothershipChatResourceContract, { + body: { chatId: chat.id, resource: { type: 'issue', id: issue.key, title: issue.title } }, + }) + if (!followUp) { + await startIssue.mutateAsync({ workspaceId, key: issue.key, chatId: chat.id }) + started = true + } + // boundary-raw-fetch: the chat send responds with an SSE stream; only its acceptance matters here, the run continues server-side once the reader is cancelled + const response = await fetch(MOTHERSHIP_CHAT_API_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: issuePrompt(issue, followUp), + workspaceId, + chatId: chat.id, + userMessageId: generateId(), + createNewChat: false, + }), + }) + if (!response.ok) throw new Error('Sim could not start on this issue') + await response.body?.cancel() + void queryClient.invalidateQueries({ + queryKey: mothershipChatKeys.workspaceLists(workspaceId), + }) + toast.success( + followUp ? `Started a follow-up on ${issue.key}` : `Sim is working on ${issue.key}` + ) + } catch (error) { + toast.error( + started + ? 'The chat is ready but its first message did not send. Open the chat to send it again.' + : getErrorMessage(error, 'Sim could not start on this issue') + ) + } finally { + setOpening(false) + } + } + + const openChatLink = chatHref ? ( + + Open chat + + ) : null + + if (issue.status === 'inbox' && issue.inboxKind === 'new') { + return ( + void startChat(false)}> + Start issue + + ) + } + if (issue.status === 'in_progress') return openChatLink + if (issue.status === 'done') { + return ( +
    + {openChatLink} + void startChat(true)}> + Follow up + +
    + ) + } + return ( +
    + {openChatLink} + requestChanges.mutate({ workspaceId, key: issue.key })} + > + Request changes + + approveIssue.mutate({ workspaceId, key: issue.key })} + > + Approve + +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/issues/components/issue-properties.tsx b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-properties.tsx new file mode 100644 index 00000000000..0fba7a25b87 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-properties.tsx @@ -0,0 +1,220 @@ +'use client' + +import type { ComponentType } from 'react' +import { Avatar, AvatarFallback, ChipDropdown, type ChipDropdownOption } from '@sim/emcn' +import { + Dashboard, + Database, + File as FileIcon, + Link as LinkIcon, + Table as TableIcon, + Workflow, +} from '@sim/emcn/icons' +import { JiraIcon, LinearIcon } from '@/components/icons' +import type { IssueDetail, IssueRecord } from '@/lib/api/contracts/issues' +import { + ISSUE_PRIORITIES, + ISSUE_PRIORITY_LABELS, + type IssuePriority, + type IssueResourceType, +} from '@/lib/issues/types' +import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources' +import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types' +import { PRIORITY_ICONS } from '@/app/workspace/[workspaceId]/issues/components/priority-icon' +import { useToggleIssueResource, useUpdateIssue } from '@/hooks/queries/issues' +import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' + +const PRIORITY_OPTIONS = ISSUE_PRIORITIES.map((priority) => ({ + value: String(priority), + label: ISSUE_PRIORITY_LABELS[priority], + icon: PRIORITY_ICONS[priority], +})) + +const UNASSIGNED = '__unassigned__' + +/** Linkable families, in the resource menu's order, and their Chat resource types. */ +const RESOURCE_FAMILIES: { + type: IssueResourceType + chatType: MothershipResourceType + label: string + icon: ComponentType<{ className?: string }> +}[] = [ + { type: 'dashboard', chatType: 'dashboard', label: 'Dashboards', icon: Dashboard }, + { type: 'table', chatType: 'table', label: 'Tables', icon: TableIcon }, + { type: 'file', chatType: 'file', label: 'Files', icon: FileIcon }, + { type: 'knowledge_base', chatType: 'knowledgebase', label: 'Knowledge bases', icon: Database }, + { type: 'workflow', chatType: 'workflow', label: 'Workflows', icon: Workflow }, +] + +interface IssuePropertiesProps { + workspaceId: string + detail: IssueDetail + canEdit: boolean +} + +/** Owner and priority, then what the issue is linked to: workspace resources and tickets. */ +export function IssueProperties({ workspaceId, detail, canEdit }: IssuePropertiesProps) { + const { issue } = detail + return ( +
    + + + + {detail.tickets.length > 0 && } +
    + ) +} + +interface PropertyProps { + workspaceId: string + issue: IssueRecord + canEdit: boolean +} + +function OwnerProperty({ workspaceId, issue, canEdit }: PropertyProps) { + const members = useWorkspaceMembersQuery(workspaceId) + const updateIssue = useUpdateIssue() + const options = [ + { + value: UNASSIGNED, + label: 'Unassigned', + iconElement: ( + + + + ), + }, + ...(members.data ?? []).map((member) => ({ + value: member.userId, + label: member.name, + iconElement: , + })), + ] + return ( + + updateIssue.mutate({ + workspaceId, + key: issue.key, + ownerId: value === UNASSIGNED ? null : value, + }) + } + aria-label='Owner' + /> + ) +} + +function PriorityProperty({ workspaceId, issue, canEdit }: PropertyProps) { + const updateIssue = useUpdateIssue() + return ( + + updateIssue.mutate({ + workspaceId, + key: issue.key, + priority: Number(value) as IssuePriority, + }) + } + aria-label='Priority' + /> + ) +} + +interface LinkedResourcesProps { + workspaceId: string + detail: IssueDetail + canEdit: boolean +} + +/** The resources the issue is about: a searchable multi-select over the workspace's resources. */ +function LinkedResources({ workspaceId, detail, canEdit }: LinkedResourcesProps) { + const { groups } = useAvailableResources(workspaceId) + const toggle = useToggleIssueResource() + const linked = detail.resources.map((resource) => `${resource.type}:${resource.id}`) + const linkedSet = new Set(linked) + + const options: ChipDropdownOption[] = [] + for (const family of RESOURCE_FAMILIES) { + const items = groups.find((group) => group.type === family.chatType)?.items ?? [] + const known = new Set(items.map((item) => item.id)) + for (const resource of detail.resources) { + if (resource.type === family.type && !known.has(resource.id)) + options.push({ + value: `${family.type}:${resource.id}`, + label: resource.id, + icon: family.icon, + }) + } + for (const item of items) + options.push({ value: `${family.type}:${item.id}`, label: item.name, icon: family.icon }) + } + options.sort((a, b) => Number(linkedSet.has(b.value)) - Number(linkedSet.has(a.value))) + + return ( + `${count} linked`} + searchPlaceholder='Search resources' + options={options} + value={linked} + matchTriggerWidth={false} + contentClassName='w-[320px]' + disabled={!canEdit || toggle.isPending} + onChange={(next) => { + const nextSet = new Set(next) + const added = next.find((value) => !linkedSet.has(value)) + const removed = linked.find((value) => !nextSet.has(value)) + const changed = added ?? removed + if (!changed) return + const separator = changed.indexOf(':') + toggle.mutate({ + workspaceId, + key: detail.issue.key, + type: changed.slice(0, separator) as IssueResourceType, + resourceId: changed.slice(separator + 1), + unlink: Boolean(removed && !added), + }) + }} + aria-label='Linked resources' + /> + ) +} + +/** Tickets in Linear or Jira; picking one opens it. */ +interface LinkedTicketsProps { + tickets: IssueDetail['tickets'] +} + +function LinkedTickets({ tickets }: LinkedTicketsProps) { + return ( + ({ + value: ticket.url, + label: [ticket.externalKey, ticket.title, ticket.status].filter(Boolean).join(' · '), + icon: ticket.provider === 'jira' ? JiraIcon : LinearIcon, + }))} + showSelectedCheck={false} + matchTriggerWidth={false} + onChange={(url) => window.open(url, '_blank', 'noopener')} + aria-label='Linked tickets' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/issues/components/issue-status.tsx b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-status.tsx new file mode 100644 index 00000000000..553e535905c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/issues/components/issue-status.tsx @@ -0,0 +1,43 @@ +import { Circle, CircleCheck, CirclePause, CircleX } from '@sim/emcn/icons' +import { ThinkingLoader } from '@/components/ui/thinking-loader' +import type { IssueRecord } from '@/lib/api/contracts/issues' + +/** + * The issue's state as one glyph: an empty circle when new, a yellow dot waiting for review, Sim's + * thinking loader while its chat is running (paused when the chat stopped), and a check or cross + * once closed. + */ +interface IssueStatusIconProps { + issue: IssueRecord +} + +export function IssueStatusIcon({ issue }: IssueStatusIconProps) { + if (issue.status === 'in_progress') + return issue.workingChat?.running ? ( + + ) : ( + + ) + if (issue.status === 'done') { + const Icon = issue.closeReason === 'completed' ? CircleCheck : CircleX + return + } + if (issue.inboxKind === 'review') + return