diff --git a/control-plane/src/main/java/io/forgeloop/control/api/IssueConversationController.java b/control-plane/src/main/java/io/forgeloop/control/api/IssueConversationController.java index fc3c3fd..4dc59cb 100644 --- a/control-plane/src/main/java/io/forgeloop/control/api/IssueConversationController.java +++ b/control-plane/src/main/java/io/forgeloop/control/api/IssueConversationController.java @@ -21,6 +21,10 @@ public class IssueConversationController { @QueryMapping public IssueConversationView issueConversation(@Argument String id) { return conversations.get(id); } + @MutationMapping public boolean deleteIssueConversation(@Argument String conversationId) { + return conversations.delete(conversationId); + } + @MutationMapping public IssueConversationView startIssueConversation(@Argument String repository, @Argument String message) { return conversations.start(repository, message); } diff --git a/control-plane/src/main/java/io/forgeloop/control/application/IssueConversationService.java b/control-plane/src/main/java/io/forgeloop/control/application/IssueConversationService.java index bb47e38..1ea3cb6 100644 --- a/control-plane/src/main/java/io/forgeloop/control/application/IssueConversationService.java +++ b/control-plane/src/main/java/io/forgeloop/control/application/IssueConversationService.java @@ -133,6 +133,20 @@ public IssueConversationView createIssue(String conversationId, String title, St return view(conversation); } + /** Lock against claims/completions; retain accounting and external GitHub work. */ + @Transactional + public boolean delete(String conversationId) { + operators.requireOperator(); + IssueConversation conversation = requireConversation(conversationId); + if (!conversation.getCreatedBy().equals(operators.subject())) operators.requireAdministrator(); + if ("RUNNING".equals(conversation.getStatus())) + throw new IllegalStateException("Wait for the current reply to finish before deleting this conversation."); + messages.deleteByConversation_Id(conversationId); + conversations.delete(conversation); + audit.record("ISSUE_CHAT_DELETED", "ISSUE_CONVERSATION", conversationId, "history-deleted;usage-retained"); + return true; + } + private IssueConversation requireConversation(String id) { return conversations.lockByIdAndOrganizationId(id, operators.organizationId()) .orElseThrow(() -> new IllegalArgumentException("Issue conversation not found")); diff --git a/control-plane/src/main/java/io/forgeloop/control/domain/IssueConversationMessageRepository.java b/control-plane/src/main/java/io/forgeloop/control/domain/IssueConversationMessageRepository.java index 1511f34..33d7ec2 100644 --- a/control-plane/src/main/java/io/forgeloop/control/domain/IssueConversationMessageRepository.java +++ b/control-plane/src/main/java/io/forgeloop/control/domain/IssueConversationMessageRepository.java @@ -4,5 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface IssueConversationMessageRepository extends JpaRepository { + void deleteByConversation_Id(String conversationId); List findByConversation_IdOrderByCreatedAtAscIdAsc(String conversationId); } diff --git a/control-plane/src/main/resources/graphql/schema.graphqls b/control-plane/src/main/resources/graphql/schema.graphqls index ef78311..25e46e2 100644 --- a/control-plane/src/main/resources/graphql/schema.graphqls +++ b/control-plane/src/main/resources/graphql/schema.graphqls @@ -1,7 +1,7 @@ type Query { featureRun(id: ID!): FeatureRun!, featureRuns: [FeatureRun!]!, runAnalytics: RunAnalytics!, organizationPolicy: OrganizationPolicy!, harnessDefinitions: [HarnessDefinition!]!, localMcpConfigurations: [LocalMcpConfiguration!]!, repositoryConnections: [RepositoryConnection!]!, repositoryScans(repository: String!): [RepositoryScan!]!, runners(organizationId: String!): [Runner!]!, organizationMemberships(organizationId: String!): [OrganizationMembership!]!, featureRunAuditEvents(runId: ID!): [AuditLedgerEntry!]!, featureRunEvidence(runId: ID!): [VerificationEvidence!]!, featureRunReviewEvidence(runId: ID!): [ReviewEvidence!]!, featureRunArtifacts(runId: ID!): [ArtifactMetadata!]!, featureRunEvents(runId: ID!): [RunnerEvent!]!, featureRunEscalations(runId: ID!): [HumanEscalation!]!, featureRunPublication(runId: ID!): GithubPublication, currentOperator: OperatorSession!, availableRunnerTasks(runnerId: ID!, credential: String!): [Task!]! } extend type Query { providerActivities(days: Int!, repository: String): [ProviderActivity!]! } extend type Query { issueConversations(repository: String!): [IssueConversation!]!, issueConversation(id: ID!): IssueConversation! } -extend type Mutation { startIssueConversation(repository: String!, message: String!): IssueConversation!, sendIssueConversationMessage(conversationId: ID!, message: String!): IssueConversation!, createIssueFromConversation(conversationId: ID!, title: String!, body: String!, acceptanceCriteria: [String!]!): IssueConversation!, claimIssueChatTurn(runnerId: ID!, credential: String!): IssueChatTurnGrant, completeIssueChatTurn(conversationId: ID!, runnerId: ID!, credential: String!, input: IssueChatTurnResultInput!): IssueConversation! } +extend type Mutation { deleteIssueConversation(conversationId: ID!): Boolean!, startIssueConversation(repository: String!, message: String!): IssueConversation!, sendIssueConversationMessage(conversationId: ID!, message: String!): IssueConversation!, createIssueFromConversation(conversationId: ID!, title: String!, body: String!, acceptanceCriteria: [String!]!): IssueConversation!, claimIssueChatTurn(runnerId: ID!, credential: String!): IssueChatTurnGrant, completeIssueChatTurn(conversationId: ID!, runnerId: ID!, credential: String!, input: IssueChatTurnResultInput!): IssueConversation! } type IssueConversation { id: ID!, repository: String!, status: String!, createdAt: String!, updatedAt: String!, failureSummary: String, draftTitle: String, draftBody: String, acceptanceCriteria: [String!]!, provider: String, model: String, inputTokens: Float!, outputTokens: Float!, estimatedCostMicros: Float!, costKnown: Boolean!, issueNumber: Int, issueUrl: String, messages: [IssueChatMessage!]! } type IssueChatMessage { role: String!, content: String!, createdAt: String! } type IssueChatTurnGrant { id: ID!, repository: String!, draftTitle: String, draftBody: String, acceptanceCriteria: [String!]!, messages: [IssueChatTurnMessage!]! } diff --git a/control-plane/src/test/java/io/forgeloop/control/application/IssueConversationServiceTest.java b/control-plane/src/test/java/io/forgeloop/control/application/IssueConversationServiceTest.java index 6a8cfd2..9e3daee 100644 --- a/control-plane/src/test/java/io/forgeloop/control/application/IssueConversationServiceTest.java +++ b/control-plane/src/test/java/io/forgeloop/control/application/IssueConversationServiceTest.java @@ -26,6 +26,39 @@ import org.springframework.security.access.AccessDeniedException; class IssueConversationServiceTest { + @Test void deletesPendingOwnedHistoryButRetainsExternalWorkAndUsage() { + IssueConversation conversation = new IssueConversation("org-1", "acme/app", "owner"); + when(operators.organizationId()).thenReturn("org-1"); + when(operators.subject()).thenReturn("owner"); + when(conversations.lockByIdAndOrganizationId(conversation.getId(), "org-1")).thenReturn(Optional.of(conversation)); + assertEquals(true, service.delete(conversation.getId())); + verify(messages).deleteByConversation_Id(conversation.getId()); + verify(conversations).delete(conversation); + verify(operators, never()).requireAdministrator(); + org.mockito.Mockito.verifyNoInteractions(github, activities); + } + + @Test void refusesRunningAndCrossOrganizationDeletion() { + IssueConversation conversation = new IssueConversation("org-1", "acme/app", "owner"); + conversation.claim("runner"); + when(operators.organizationId()).thenReturn("org-1"); + when(operators.subject()).thenReturn("owner"); + when(conversations.lockByIdAndOrganizationId(conversation.getId(), "org-1")).thenReturn(Optional.of(conversation)); + assertThrows(IllegalStateException.class, () -> service.delete(conversation.getId())); + when(operators.organizationId()).thenReturn("other"); + assertThrows(IllegalArgumentException.class, () -> service.delete(conversation.getId())); + verify(conversations, never()).delete(any()); + } + + @Test void requiresAdministratorToDeleteAnotherUsersConversation() { + IssueConversation conversation = new IssueConversation("org-1", "acme/app", "owner"); + when(operators.organizationId()).thenReturn("org-1"); + when(operators.subject()).thenReturn("other-user"); + when(conversations.lockByIdAndOrganizationId(conversation.getId(), "org-1")).thenReturn(Optional.of(conversation)); + doThrow(new AccessDeniedException("administrator required")).when(operators).requireAdministrator(); + assertThrows(AccessDeniedException.class, () -> service.delete(conversation.getId())); + verify(conversations, never()).delete(any()); + } private final IssueConversationRepository conversations = mock(IssueConversationRepository.class); private final IssueConversationMessageRepository messages = mock(IssueConversationMessageRepository.class); private final RepositoryConnectionService connections = mock(RepositoryConnectionService.class); diff --git a/docs/issue-chat-deletion.md b/docs/issue-chat-deletion.md new file mode 100644 index 0000000..a35cb74 --- /dev/null +++ b/docs/issue-chat-deletion.md @@ -0,0 +1,15 @@ +# Conversation deletion + +Open a conversation and choose Delete conversation, then confirm. Owners can +delete their own chats; administrators can delete other chats in the same +workspace. Viewers cannot delete. A locked transaction checks workspace and +ownership and deletes messages before the conversation. Pending work can be +deleted before a runner claims it; running replies must finish first. + +Deletion is permanent. GitHub issues, feature runs, provider accounting and audit +records remain. No provider call is made by deletion. + +Chat replies run on an enrolled customer runner, not the website. A local dev +stack needs a separately paired runner pointing to http://localhost:5173; a +runner paired to the production hostname cannot claim local database jobs. +Do not repoint a working production runner just to inspect UI changes. diff --git a/docs/settings-consolidation.md b/docs/settings-consolidation.md new file mode 100644 index 0000000..c17444c --- /dev/null +++ b/docs/settings-consolidation.md @@ -0,0 +1,16 @@ +# Consolidated console settings + +Settings is the single editing location for organization spending/delivery policy, +repository budgets, assignment intake and retained run records. Repository pages +show summaries and link to Settings; they retain repository scans. Runner setup, +harnesses and local MCP configuration are in a collapsed advanced section. + +The organization ceiling remains a safety boundary, not a second per-run budget. +New manual runs inherit the selected repository limit capped by the current +organization ceiling. Issue-triggered behavior is unchanged. Existing runs keep +their captured limits; no stored settings or enforcement were removed. + +The getting-started banner's dismissal is persisted in localStorage per GitHub +identity and workspace. It survives reloads in that browser; clearing storage or +using another browser resets it. Blocked storage still permits session dismissal. +Getting started remains accessible in the sidebar. No secrets are stored. diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index c301f48..abc5710 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -16,8 +16,9 @@ test('renders the persisted delivery-run console', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Evidence browser & redacted logs' })).toBeVisible(); } - await page.getByRole('button', { name: /Harness & policy/ }).click(); - await expect(page.getByRole('heading', { name: 'Harness & policy' })).toBeVisible(); + await page.getByRole('button', { name: /Settings/ }).click(); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + await page.getByText('Runner setup & advanced configuration', { exact: true }).click(); await expect(page.getByRole('heading', { name: 'Execution policy' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Harness definitions' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'MCP routes' })).toBeVisible(); diff --git a/frontend/e2e/mobile-runs.spec.ts b/frontend/e2e/mobile-runs.spec.ts index 6a37941..7ee98d3 100644 --- a/frontend/e2e/mobile-runs.spec.ts +++ b/frontend/e2e/mobile-runs.spec.ts @@ -86,17 +86,21 @@ for (const width of [320, 390, 430, 768, 1440]) { await expect(page.getByRole('heading', { name: 'Connected repositories', exact: true })).toBeVisible(); const repositoryCard = page.locator('.repository-card'); await expect(repositoryCard).toHaveCount(1); - for (const setting of ['Issue intake', 'Run budget', 'Run records', 'Repository issue scan']) { - await expect(repositoryCard.getByRole('heading', { name: setting, exact: true })).toBeVisible(); + await expect(repositoryCard.getByRole('heading', { name: 'Repository issue scan', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save budget' })).toHaveCount(0); + await repositoryCard.getByRole('button', { name: 'Manage repository settings' }).click(); + for (const setting of ['Issue intake', 'Run budget', 'Run records']) { + await expect(page.getByRole('heading', { name: setting, exact: true })).toBeVisible(); } if (width <= 760) { expect(await page.locator('.repository-settings-grid').evaluate(element => getComputedStyle(element).gridTemplateColumns.split(' ').length)).toBe(1); } await fits(page); await page.screenshot({ path: `../evidence/forgeloop-ui/repository-settings-${width}.png`, fullPage: true }); - if (width <= 760) await page.getByRole('button', { name: 'Open navigation' }).click(); - await page.getByRole('button', { name: 'Harness & policy', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Execution policy', exact: true })).toBeVisible(); + await page.getByText('Runner setup & advanced configuration', { exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Harness definitions', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'MCP routes', exact: true })).toBeVisible(); await fits(page); }); } diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d586ec9..412de8f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -43,7 +43,7 @@ server { proxy_hide_header Cache-Control; add_header Cache-Control "public, max-age=60"; } - location /api/runner/ { client_max_body_size 12m; proxy_pass http://control-plane/api/runner/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + location /api/runner/ { client_max_body_size 12m; proxy_pass http://control-plane/api/runner/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } root /usr/share/nginx/html; location = /support { try_files /support.html =404; add_header Cache-Control "no-store"; add_header X-Robots-Tag "noindex" always; add_header Referrer-Policy "no-referrer" always; } # Keep support abuse limits tied to this proxy's peer, not client-supplied forwarding chains. @@ -82,12 +82,12 @@ server { proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Cookie $http_cookie; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } - location /graphql { proxy_pass http://control-plane/graphql; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header Content-Type $content_type; } - location /api/github/ { proxy_pass http://control-plane/api/github/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } - location /oauth2/ { proxy_pass http://control-plane/oauth2/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } - location /login/ { proxy_pass http://control-plane/login/; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } - location = /logout { proxy_pass http://control-plane/logout; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + location /graphql { proxy_pass http://control-plane/graphql; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; proxy_set_header Content-Type $content_type; } + location /api/github/ { proxy_pass http://control-plane/api/github/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + location /oauth2/ { proxy_pass http://control-plane/oauth2/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + location /login/ { proxy_pass http://control-plane/login/; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } + location = /logout { proxy_pass http://control-plane/logout; proxy_set_header Host $http_host; proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } } diff --git a/frontend/src/RepositoryBudgetSettings.tsx b/frontend/src/RepositoryBudgetSettings.tsx index 737050a..c80b171 100644 --- a/frontend/src/RepositoryBudgetSettings.tsx +++ b/frontend/src/RepositoryBudgetSettings.tsx @@ -28,7 +28,7 @@ export default function RepositoryBudgetSettings({ item, organizationLimit, edit .catch(reason => setError(reason instanceof Error ? reason.message : 'Unable to save repository budget')) .finally(() => setBusy(false)); }}> -

Run budget

Keep issue-triggered work inside the organization limit.

+

Run budget

One limit for new manual and issue-triggered runs.

- Issue-triggered runs use this repository ceiling. It must not exceed the organization limit (${organizationLimit}); existing runs keep their original budget. + New runs inherit this limit, capped by the organization ceiling (${organizationLimit}); existing runs keep their original budget. {editable && } {error &&

{error}

} ; diff --git a/frontend/src/RunConsole.test.tsx b/frontend/src/RunConsole.test.tsx index f95db02..5087c1f 100644 --- a/frontend/src/RunConsole.test.tsx +++ b/frontend/src/RunConsole.test.tsx @@ -5,6 +5,22 @@ import App from './main'; afterEach(() => vi.unstubAllGlobals()); +it('centralizes repository settings and inherits the configured budget for new runs', async () => { + vi.stubGlobal('fetch', controlPlane({ repositoryConnections: [{ id: 'repo', repository: 'org/repo', enabled: true, defaultBranch: 'main', issueLabel: 'forgeloop', requiredGates: ['unit'], maxBudgetUsd: 5, policyRevision: 1 }] })); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Repositories' })); + expect(screen.queryByRole('button', { name: 'Save budget' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Manage repository settings' })); + expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save budget' })).toBeInTheDocument(); + expect(screen.getByLabelText('Organization per-run ceiling (USD)')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Runs' })); + fireEvent.click(screen.getByRole('button', { name: '+ New Run' })); + fireEvent.change(screen.getByLabelText('Authorized repository'), { target: { value: 'org/repo' } }); + expect(await screen.findByText(/Run limit: \$5. Inherited from Settings/)).toBeInTheDocument(); + expect(screen.queryByLabelText('Maximum spend')).not.toBeInTheDocument(); +}); + it('places Account in the sidebar with a decorative icon matching the navigation', async () => { vi.stubGlobal('fetch', controlPlane({})); render(); @@ -143,7 +159,7 @@ it('shows persisted harness and runner-local MCP configuration', async () => { vi.stubGlobal('fetch', controlPlane({ repositoryConnections: [] })); render(); await screen.findByText('Intake queue'); - fireEvent.click(screen.getByRole('button', { name: /Harness & policy/ })); + fireEvent.click(screen.getByRole('button', { name: /Settings/ })); expect(await screen.findByText('Execution policy')).toBeInTheDocument(); expect(screen.getByText('FULL_STACK')).toBeInTheDocument(); expect(screen.getByText('No local MCP context routes configured.')).toBeInTheDocument(); diff --git a/frontend/src/SetupBanner.test.tsx b/frontend/src/SetupBanner.test.tsx new file mode 100644 index 0000000..449951c --- /dev/null +++ b/frontend/src/SetupBanner.test.tsx @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import SetupBanner from './SetupBanner'; +import type { OperatorSession } from './api'; + +const operator: OperatorSession = { subject: 'user', organizationId: 'workspace', role: 'ADMIN' }; +afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); }); + +it('keeps dismissal after remount but scopes it to the account and workspace', () => { + const first = render( {}}/>); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss getting started banner permanently' })); + expect(screen.queryByText('Open setup checklist')).not.toBeInTheDocument(); + first.unmount(); + const second = render( {}}/>); + expect(screen.queryByText('Open setup checklist')).not.toBeInTheDocument(); + second.rerender( {}}/>); + expect(screen.getByText('Open setup checklist')).toBeInTheDocument(); +}); + +it('opens the checklist and dismisses safely when browser storage is blocked', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); }); + const open = vi.fn(); + render(); + fireEvent.click(screen.getByText('Open setup checklist')); + expect(open).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss getting started banner permanently' })); + expect(screen.queryByText('Open setup checklist')).not.toBeInTheDocument(); +}); diff --git a/frontend/src/SetupBanner.tsx b/frontend/src/SetupBanner.tsx new file mode 100644 index 0000000..f193d1b --- /dev/null +++ b/frontend/src/SetupBanner.tsx @@ -0,0 +1,16 @@ +import { useState } from 'react'; +import { X } from 'lucide-react'; +import type { OperatorSession } from './api'; + +/** Persist only the preference, scoped to the signed-in identity and workspace. */ +export default function SetupBanner({ operator, onOpen }: { operator: OperatorSession; onOpen: () => void }) { + const key = `forgeloop:setup-dismissed:${encodeURIComponent(operator.subject)}:${encodeURIComponent(operator.organizationId)}`; + const [dismissed, setDismissed] = useState(() => { + try { return localStorage.getItem(key) === 'true'; } catch { return false; } + }); + if (dismissed) return null; + return
Setting up a repository or diagnosing waiting work?
; +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 49e7268..8c6e977 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -108,3 +108,6 @@ export function loadPlatformConfiguration():Promise{retur export function configureOrganizationPolicy(input:Omit):Promise{return request<{configureOrganizationPolicy:OrganizationPolicy}>('mutation($input:OrganizationPolicyInput!){configureOrganizationPolicy(input:$input){organizationId maxRunBudgetUsd maxParallelTasks allowedProviders requireHumanApproval autoMergeEnabled revision}}',{input}).then(data=>data.configureOrganizationPolicy);} export function createHarnessDefinition(input:{name:string;description:string;allowedRoles:string[];defaultAttemptBudget:number}):Promise{return request<{createHarnessDefinition:HarnessDefinition}>('mutation($input:HarnessDefinitionInput!){createHarnessDefinition(input:$input){id organizationId name description allowedRoles defaultAttemptBudget enabled revision}}',{input}).then(data=>data.createHarnessDefinition);} export function createLocalMcpConfiguration(input:{name:string;command:string;arguments:string[];allowedRoles:string[];contextTool:string;toolArguments:string}):Promise{return request<{createLocalMcpConfiguration:LocalMcpConfiguration}>('mutation($input:LocalMcpConfigurationInput!){createLocalMcpConfiguration(input:$input){id name command arguments allowedRoles contextTool toolArguments enabled revision}}',{input}).then(data=>data.createLocalMcpConfiguration);} +export function deleteIssueConversation(conversationId: string): Promise { + return request<{ deleteIssueConversation: boolean }>('mutation($conversationId:ID!){deleteIssueConversation(conversationId:$conversationId)}', { conversationId }).then(data => data.deleteIssueConversation); +} diff --git a/frontend/src/issue-chat/IssueChatPage.test.tsx b/frontend/src/issue-chat/IssueChatPage.test.tsx index 834fe3e..ad70f47 100644 --- a/frontend/src/issue-chat/IssueChatPage.test.tsx +++ b/frontend/src/issue-chat/IssueChatPage.test.tsx @@ -13,6 +13,32 @@ const publication = { repository: 'acme/app', pullRequestNumber: 27, pullRequest afterEach(() => { vi.unstubAllGlobals(); window.history.replaceState(null, '', '/'); }); +it('confirms deletion, removes history and returns to a new chat', async () => { + window.history.replaceState(null, '', '/app#chat=chat-1'); + let deleted = false; + const confirm = vi.fn(() => false); + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + const request = JSON.parse(String(init.body)); + if (request.query.includes('deleteIssueConversation')) { + expect(request.variables).toEqual({ conversationId: 'chat-1' }); + deleted = true; + return { ok: true, json: async () => ({ data: { deleteIssueConversation: true } }) }; + } + return { ok: true, json: async () => ({ data: { issueConversations: deleted ? [] : [ready], issueConversation: ready } }) }; + }); + vi.stubGlobal('fetch', fetch); + vi.stubGlobal('confirm', confirm); + render( {}}/>); + const remove = await screen.findByRole('button', { name: 'Delete conversation' }); + fireEvent.click(remove); + expect(deleted).toBe(false); + confirm.mockReturnValue(true); + fireEvent.click(remove); + expect(await screen.findByRole('heading', { name: 'What should change?' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Delete conversation' })).not.toBeInTheDocument(); + expect(window.location.hash).toBe('#chat'); +}); + it('lets an administrator refine a runner draft before explicitly creating an unassigned issue', async () => { vi.stubGlobal('confirm', vi.fn(() => true)); const fetch = vi.fn(async (_url: string, init: RequestInit) => { diff --git a/frontend/src/issue-chat/IssueChatPage.tsx b/frontend/src/issue-chat/IssueChatPage.tsx index bc30ea1..8198f91 100644 --- a/frontend/src/issue-chat/IssueChatPage.tsx +++ b/frontend/src/issue-chat/IssueChatPage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; +import { Trash2 } from 'lucide-react'; import { + deleteIssueConversation, createIssueFromConversation, loadIssueConversation, loadIssueConversations, @@ -147,6 +149,19 @@ export default function IssueChatPage({ repositories, operator, onRepositories } }); } + async function removeConversation(item: IssueConversation) { + if (!window.confirm('Permanently delete this conversation and its messages? Any GitHub issue, run, and usage records will remain.')) return; + await perform('delete', async () => { + if (!await deleteIssueConversation(item.id)) throw new Error('Conversation could not be deleted.'); + if (activeId === item.id) { + select(''); + setConversation(undefined); + setMessage(''); + } + await refreshConversations(); + }); + } + async function perform(label: string, action: () => Promise) { setBusy(label); setError(''); @@ -179,6 +194,7 @@ export default function IssueChatPage({ repositories, operator, onRepositories } {item.draftTitle || item.messages.find(entry => entry.role === 'USER')?.content || 'New issue chat'} {item.status.replaceAll('_', ' ')} · {new Date(item.updatedAt).toLocaleDateString()} + {operator.role !== 'VIEWER' && } )} :

Your issue chats will appear here.

}
diff --git a/frontend/src/issue-chat/issue-chat.css b/frontend/src/issue-chat/issue-chat.css index b47abce..3f20f79 100644 --- a/frontend/src/issue-chat/issue-chat.css +++ b/frontend/src/issue-chat/issue-chat.css @@ -45,3 +45,6 @@ .issue-chat-delivery a { justify-self: start; } @media (max-width: 900px) { .issue-chat-layout { grid-template-columns: minmax(0, 1fr); } .issue-chat-list { order: 2; } .issue-chat-workspace { order: 1; } .issue-chat-list ul { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 560px) { .issue-chat-title { align-items: stretch; } .issue-chat-repository { width: 100%; } .issue-chat-list ul { grid-template-columns: minmax(0, 1fr); } .issue-chat-heading { display: grid; } .issue-chat-heading > small { text-align: left; } .issue-chat-workspace { padding: 14px; } } +.issue-chat-list li { display: flex; align-items: center; gap: 6px; min-width: 0; } +.issue-chat-list li > button:first-child { flex: 1; } +.issue-chat-list li > .chat-delete { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 36px; width: 36px; min-height: 36px; padding: 8px; border: 1px solid var(--border); color: #ffb1c0; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7252d4b..02f1f58 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -57,6 +57,7 @@ import { isRunnerPairingRoute } from "./desktopRoute"; import IntakeSettings from "./IntakeSettings"; import RunRecordSettings from "./RunRecordSettings"; import RepositoryBudgetSettings from "./RepositoryBudgetSettings"; +import SetupBanner from "./SetupBanner"; import "./styles.css"; import "./dashboard-responsive.css"; import "./repository-settings.css"; @@ -78,14 +79,7 @@ const stamp = (value: string) => timeStyle: "short", }).format(new Date(value)); -function RepositoryPage({ items, operator, onSaved }: { items: RepositoryConnection[]; operator:OperatorSession; onSaved:(item:RepositoryConnection)=>void }) { - const [organizationLimit, setOrganizationLimit] = useState(); - const [configurationError, setConfigurationError] = useState(''); - useEffect(() => { - void loadPlatformConfiguration() - .then(configuration => setOrganizationLimit(configuration.policy.maxRunBudgetUsd)) - .catch(reason => setConfigurationError(reason instanceof Error ? reason.message : 'Unable to load the organization budget')); - }, []); +function RepositoryPage({ items, operator, onSettings }: { items: RepositoryConnection[]; operator:OperatorSession; onSettings:()=>void }) { return ( <>
@@ -123,15 +117,8 @@ function RepositoryPage({ items, operator, onSaved }: { items: RepositoryConnect Policy v{item.policyRevision} -
- - {organizationLimit !== undefined - ? - : configurationError - ?

Unable to show the repository budget control: {configurationError}

- :

Loading the organization budget limit…

} - -
+

Run limit: ${item.maxBudgetUsd} USD · Intake: {item.requireAssignee ? 'assignment required' : 'label only'} · Detailed records: {item.runRecord ? 'enabled' : 'disabled'}

+ )) @@ -155,8 +142,11 @@ function NewRun({ sourceRef: "", title: "", specification: "", - budgetUsd: 25, }); + const [organizationLimit, setOrganizationLimit] = useState(); + useEffect(() => { void loadPlatformConfiguration().then(value => setOrganizationLimit(value.policy.maxRunBudgetUsd)).catch(() => setError('Unable to load budget policy. Try again.')); }, []); + const selectedRepository = repositories.find(item => item.repository === form.repository); + const budgetUsd = organizationLimit !== undefined && selectedRepository ? Math.min(organizationLimit, selectedRepository.maxBudgetUsd) : undefined; const [error, setError] = useState(""); const [busy, setBusy] = useState(false); async function submit(event: FormEvent) { @@ -164,7 +154,8 @@ function NewRun({ setBusy(true); setError(""); try { - onAdded(await submitFeature(form)); + if (budgetUsd === undefined) throw new Error("Select a repository with a configured budget."); + onAdded(await submitFeature({ ...form, budgetUsd })); } catch (reason) { setError( reason instanceof Error ? reason.message : "Unable to create run", @@ -227,22 +218,10 @@ function NewRun({ } /> - +

{budgetUsd !== undefined ? `Run limit: $${budgetUsd}. Inherited from Settings; no separate run budget is needed.` : 'Select a repository to see its run limit.'}

@@ -839,7 +818,7 @@ function RunsPage({ ); } -function ConfigurationPage({operator}:{operator:OperatorSession}) { +function ConfigurationPage({operator, repositories, onSaved}:{operator:OperatorSession; repositories:RepositoryConnection[]; onSaved:(item:RepositoryConnection)=>void}) { const [configuration,setConfiguration]=useState(); const [error,setError]=useState(""); const [busy,setBusy]=useState(""); @@ -851,11 +830,14 @@ function ConfigurationPage({operator}:{operator:OperatorSession}) { async function perform(label:string,work:()=>Promise){setBusy(label);setError("");try{await work();setConfiguration(await loadPlatformConfiguration());}catch(reason){setError(reason instanceof Error?reason.message:"Configuration update failed");}finally{setBusy("");}} if(!configuration)return

Loading execution policy…

; return <> -

Execution governance

Harness & policy

Reusable delivery rules and runner-local context routes for this organization.

Policy revision {configuration.policy.revision}
+

Execution governance

Settings

Manage spending limits, delivery, repository intake and runner configuration in one place.

Policy revision {configuration.policy.revision}
{error&&

{error}

} -

Organization boundary

Execution policy

Hard limits applied before work enters the runner queue.

{event.preventDefault();const form=new FormData(event.currentTarget);void perform("policy",()=>configureOrganizationPolicy({maxRunBudgetUsd:Number(form.get("budget")),maxParallelTasks:Number(form.get("parallel")),allowedProviders:csv(String(form.get("providers"))),requireHumanApproval:form.get("approval")==="on",autoMergeEnabled:form.get("autoMerge")==="on"}));}}>{admin&&}
+

Organization boundary

Execution policy

Hard limits applied before work enters the runner queue.

{event.preventDefault();const form=new FormData(event.currentTarget);void perform("policy",()=>configureOrganizationPolicy({maxRunBudgetUsd:Number(form.get("budget")),maxParallelTasks:Number(form.get("parallel")),allowedProviders:csv(String(form.get("providers"))),requireHumanApproval:form.get("approval")==="on",autoMergeEnabled:form.get("autoMerge")==="on"}));}}>{admin&&}
+

Repository settings

Each repository has one run limit, bounded by the organization ceiling above. New manual and issue-triggered runs use that limit; existing runs keep their captured budget.

{repositories.length ? repositories.map(item =>

{item.repository}

) :

No connected repositories yet.

}
+
Runner setup & advanced configuration

Reusable orchestration

Harness definitions

{configuration.harnesses.length}
{configuration.harnesses.map(item=>
{item.name}{item.enabled?"ACTIVE":"DISABLED"}

{item.description}

{item.allowedRoles.join(" · ")} · {item.defaultAttemptBudget} attempts · v{item.revision}
)}{admin&&
{event.preventDefault();void perform("harness",()=>createHarnessDefinition({...harness,allowedRoles:csv(harness.allowedRoles)}));}}>

Add harness

setHarness({...harness,name:event.target.value.toUpperCase()})} required/>